diff --git a/.github/workflows/coolify-helper-next.yml b/.github/workflows/coolify-helper-next.yml index fec54d54a..2e50abbe7 100644 --- a/.github/workflows/coolify-helper-next.yml +++ b/.github/workflows/coolify-helper-next.yml @@ -44,8 +44,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -86,8 +86,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index 0c9996ec8..ed6fc3bcb 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -44,8 +44,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -85,8 +85,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml index 21871b103..477274751 100644 --- a/.github/workflows/coolify-production-build.yml +++ b/.github/workflows/coolify-production-build.yml @@ -51,8 +51,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -91,8 +91,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-realtime-next.yml b/.github/workflows/coolify-realtime-next.yml index 7ab4dcc42..8937ea27d 100644 --- a/.github/workflows/coolify-realtime-next.yml +++ b/.github/workflows/coolify-realtime-next.yml @@ -48,8 +48,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -90,8 +90,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-realtime.yml b/.github/workflows/coolify-realtime.yml index 5efe445c5..d8784dd50 100644 --- a/.github/workflows/coolify-realtime.yml +++ b/.github/workflows/coolify-realtime.yml @@ -48,8 +48,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -90,8 +90,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index 67b7b03e8..494ef6939 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -64,8 +64,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and Push Image (${{ matrix.arch }}) uses: docker/build-push-action@v6 @@ -110,8 +110,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | diff --git a/.github/workflows/coolify-testing-host.yml b/.github/workflows/coolify-testing-host.yml index 24133887a..0c1371573 100644 --- a/.github/workflows/coolify-testing-host.yml +++ b/.github/workflows/coolify-testing-host.yml @@ -44,8 +44,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and Push Image (${{ matrix.arch }}) uses: docker/build-push-action@v6 @@ -81,8 +81,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2980c7401..5660f2569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5389,7 +5389,6 @@ ### 🚀 Features - Add static ipv4 ipv6 support - Server disabled by overflow - Preview deployment logs -- Collect webhooks during maintenance - Logs and execute commands with several servers ### 🐛 Bug Fixes diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 61a3c4615..a1476e120 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -461,9 +461,10 @@ private function aggregateApplicationStatus($application, Collection $containerS } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount); + return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true); } private function aggregateServiceContainerStatuses($services) @@ -518,8 +519,9 @@ private function aggregateServiceContainerStatuses($services) } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, preserveRestarting: true); // Update service sub-resource status with aggregated result if ($aggregatedStatus) { diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 6bf094c32..076f7d0c5 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -13,7 +13,6 @@ class CleanupDocker public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false) { - $settings = instanceSettings(); $realtimeImage = config('constants.coolify.realtime_image'); $realtimeImageVersion = config('constants.coolify.realtime_version'); $realtimeImageWithVersion = "$realtimeImage:$realtimeImageVersion"; @@ -26,9 +25,25 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ $helperImageWithoutPrefix = 'coollabsio/coolify-helper'; $helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion"; + $cleanupLog = []; + + // Get all application image repositories to exclude from prune + $applications = $server->applications(); + $applicationImageRepos = collect($applications)->map(function ($app) { + return $app->docker_registry_image_name ?? $app->uuid; + })->unique()->values(); + + // Clean up old application images while preserving N most recent for rollback + $applicationCleanupLog = $this->cleanupApplicationImages($server, $applications); + $cleanupLog = array_merge($cleanupLog, $applicationCleanupLog); + + // Build image prune command that excludes application images + // This ensures we clean up non-Coolify images while preserving rollback images + $imagePruneCmd = $this->buildImagePruneCommand($applicationImageRepos); + $commands = [ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', - 'docker image prune -af --filter "label!=coolify.managed=true"', + $imagePruneCmd, 'docker builder prune -af', "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f", @@ -44,7 +59,6 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ $commands[] = 'docker network prune -f'; } - $cleanupLog = []; foreach ($commands as $command) { $commandOutput = instant_remote_process([$command], $server, false); if ($commandOutput !== null) { @@ -57,4 +71,122 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ return $cleanupLog; } + + /** + * Build a docker image prune command that excludes application image repositories. + * + * Since docker image prune doesn't support excluding by repository name directly, + * we use a shell script approach to delete unused images while preserving application images. + */ + private function buildImagePruneCommand($applicationImageRepos): string + { + // Step 1: Always prune dangling images (untagged) + $commands = ['docker image prune -f']; + + if ($applicationImageRepos->isEmpty()) { + // No applications, add original prune command for all unused images + $commands[] = 'docker image prune -af --filter "label!=coolify.managed=true"'; + } else { + // Build grep pattern to exclude application image repositories + $excludePatterns = $applicationImageRepos->map(function ($repo) { + // Escape special characters for grep extended regex (ERE) + // ERE special chars: . \ + * ? [ ^ ] $ ( ) { } | + return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo); + })->implode('|'); + + // Delete unused images that: + // - Are not application images (don't match app repos) + // - Don't have coolify.managed=true label + // Images in use by containers will fail silently with docker rmi + // Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build) + $commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ". + "grep -v -E '^({$excludePatterns})[_:].+' | ". + "grep -v '' | ". + "xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true"; + } + + return implode(' && ', $commands); + } + + private function cleanupApplicationImages(Server $server, $applications = null): array + { + $cleanupLog = []; + + if ($applications === null) { + $applications = $server->applications(); + } + + $disableRetention = $server->settings->disable_application_image_retention ?? false; + + foreach ($applications as $application) { + $imagesToKeep = $disableRetention ? 0 : ($application->settings->docker_images_to_keep ?? 2); + $imageRepository = $application->docker_registry_image_name ?? $application->uuid; + + // Get the currently running image tag + $currentTagCommand = "docker inspect --format='{{.Config.Image}}' {$application->uuid} 2>/dev/null | grep -oP '(?<=:)[^:]+$' || true"; + $currentTag = instant_remote_process([$currentTagCommand], $server, false); + $currentTag = trim($currentTag ?? ''); + + // List all images for this application with their creation timestamps + // Use wildcard to match both uuid:tag and uuid_servicename:tag (Docker Compose with build) + $listCommand = "docker images --format '{{.Repository}}:{{.Tag}}#{{.CreatedAt}}' --filter reference='{$imageRepository}*' 2>/dev/null || true"; + $output = instant_remote_process([$listCommand], $server, false); + + if (empty($output)) { + continue; + } + + $images = collect(explode("\n", trim($output))) + ->filter() + ->map(function ($line) { + $parts = explode('#', $line); + $imageRef = $parts[0] ?? ''; + $tagParts = explode(':', $imageRef); + + return [ + 'repository' => $tagParts[0] ?? '', + 'tag' => $tagParts[1] ?? '', + 'created_at' => $parts[1] ?? '', + 'image_ref' => $imageRef, + ]; + }) + ->filter(fn ($image) => ! empty($image['tag'])); + + // Separate images into categories + // PR images (pr-*) and build images (*-build) are excluded from retention + // Build images will be cleaned up by docker image prune -af + $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + // Always delete all PR images + foreach ($prImages as $image) { + $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true"; + $deleteOutput = instant_remote_process([$deleteCommand], $server, false); + $cleanupLog[] = [ + 'command' => $deleteCommand, + 'output' => $deleteOutput ?? 'PR image removed or was in use', + ]; + } + + // Filter out current running image from regular images and sort by creation date + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // Keep only N images (imagesToKeep), delete the rest + $imagesToDelete = $sortedRegularImages->skip($imagesToKeep); + + foreach ($imagesToDelete as $image) { + $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true"; + $deleteOutput = instant_remote_process([$deleteCommand], $server, false); + $cleanupLog[] = [ + 'command' => $deleteCommand, + 'output' => $deleteOutput ?? 'Image removed or was in use', + ]; + } + } + + return $cleanupLog; + } } diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 23b41e3f2..675f0f955 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -3,10 +3,12 @@ namespace App\Actions\Service; use App\Actions\Server\CleanupDocker; +use App\Enums\ProcessStatus; use App\Events\ServiceStatusChanged; use App\Models\Server; use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; class StopService { @@ -17,6 +19,17 @@ class StopService public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true) { try { + // Cancel any in-progress deployment activities so status doesn't stay stuck at "starting" + Activity::where('properties->type_uuid', $service->uuid) + ->where(function ($q) { + $q->where('properties->status', ProcessStatus::IN_PROGRESS->value) + ->orWhere('properties->status', ProcessStatus::QUEUED->value); + }) + ->each(function ($activity) { + $activity->properties = $activity->properties->put('status', ProcessStatus::CANCELLED->value); + $activity->save(); + }); + $server = $service->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 832bed5ae..9fb5e8a19 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,7 +2,6 @@ namespace App\Console; -use App\Jobs\CheckAndStartSentinelJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckTraefikVersionJob; @@ -100,17 +99,7 @@ private function pullImages(): void } else { $servers = $this->allServers->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get(); } - foreach ($servers as $server) { - try { - if ($server->isSentinelEnabled()) { - $this->scheduleInstance->job(function () use ($server) { - CheckAndStartSentinelJob::dispatch($server); - })->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer(); - } - } catch (\Exception $e) { - Log::error('Error pulling images: '.$e->getMessage()); - } - } + // Sentinel update checks are now handled by ServerManagerJob $this->scheduleInstance->job(new CheckHelperImageJob) ->cron($this->updateCheckFrequency) ->timezone($this->instanceTimezone) diff --git a/app/Events/ProxyStatusChangedUI.php b/app/Events/ProxyStatusChangedUI.php index bd99a0f3c..3994dc0f8 100644 --- a/app/Events/ProxyStatusChangedUI.php +++ b/app/Events/ProxyStatusChangedUI.php @@ -14,12 +14,15 @@ class ProxyStatusChangedUI implements ShouldBroadcast public ?int $teamId = null; - public function __construct(?int $teamId = null) + public ?int $activityId = null; + + public function __construct(?int $teamId = null, ?int $activityId = null) { if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { $teamId = auth()->user()->currentTeam()->id; } $this->teamId = $teamId; + $this->activityId = $activityId; } public function broadcastOn(): array diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index f847f33cc..723c6d4a5 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -149,7 +149,7 @@ public static function generateScpCommand(Server $server, string $source, string return $scp_command; } - public static function generateSshCommand(Server $server, string $command) + public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false) { if ($server->settings->force_disabled) { throw new \RuntimeException('Server is disabled.'); @@ -168,7 +168,7 @@ public static function generateSshCommand(Server $server, string $command) $ssh_command = "timeout $timeout ssh "; $multiplexingSuccessful = false; - if (self::isMultiplexingEnabled()) { + if (! $disableMultiplexing && self::isMultiplexingEnabled()) { try { $multiplexingSuccessful = self::ensureMultiplexedConnection($server); if ($multiplexingSuccessful) { diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 078494f82..2f228119d 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers\Webhook; use App\Http\Controllers\Controller; -use App\Livewire\Project\Service\Storage; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,23 +14,6 @@ class Bitbucket extends Controller public function manual(Request $request) { try { - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Bitbicket::manual_bitbucket", $json); - - return; - } $return_payloads = collect([]); $payload = $request->collect(); $headers = $request->headers->all(); diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 3e0c5a0b6..e41825aba 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -7,7 +7,6 @@ use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -18,30 +17,6 @@ public function manual(Request $request) try { $return_payloads = collect([]); $x_gitea_delivery = request()->header('X-Gitea-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $gitea_delivery_found = collect($files)->filter(function ($file) use ($x_gitea_delivery) { - return Str::contains($file, $x_gitea_delivery); - })->first(); - if ($gitea_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitea::manual_{$x_gitea_delivery}", $json); - - return; - } $x_gitea_event = Str::lower($request->header('X-Gitea-Event')); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); $content_type = $request->header('Content-Type'); diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index a1fcaa7f5..2402b71ae 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -14,7 +14,6 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -25,30 +24,6 @@ public function manual(Request $request) try { $return_payloads = collect([]); $x_github_delivery = request()->header('X-GitHub-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) { - return Str::contains($file, $x_github_delivery); - })->first(); - if ($github_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::manual_{$x_github_delivery}", $json); - - return; - } $x_github_event = Str::lower($request->header('X-GitHub-Event')); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); $content_type = $request->header('Content-Type'); @@ -310,30 +285,6 @@ public function normal(Request $request) $return_payloads = collect([]); $id = null; $x_github_delivery = $request->header('X-GitHub-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) { - return Str::contains($file, $x_github_delivery); - })->first(); - if ($github_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::normal_{$x_github_delivery}", $json); - - return; - } $x_github_event = Str::lower($request->header('X-GitHub-Event')); $x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id'); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); @@ -624,23 +575,6 @@ public function install(Request $request) { try { $installation_id = $request->get('installation_id'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::install_{$installation_id}", $json); - - return; - } $source = $request->get('source'); $setup_action = $request->get('setup_action'); $github_app = GithubApp::where('uuid', $source)->firstOrFail(); diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 3187663d4..56a9c0d1b 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -7,7 +7,6 @@ use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -16,24 +15,6 @@ class Gitlab extends Controller public function manual(Request $request) { try { - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitlab::manual_gitlab", $json); - - return; - } - $return_payloads = collect([]); $payload = $request->collect(); $headers = $request->headers->all(); diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php index ae50aac42..d59adf0ca 100644 --- a/app/Http/Controllers/Webhook/Stripe.php +++ b/app/Http/Controllers/Webhook/Stripe.php @@ -6,7 +6,6 @@ use App\Jobs\StripeProcessJob; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; class Stripe extends Controller { @@ -20,23 +19,6 @@ public function events(Request $request) $signature, $webhookSecret ); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Stripe::events_stripe", $json); - - return response('Webhook received. Cool cool cool cool cool.', 200); - } StripeProcessJob::dispatch($event); return response('Webhook received. Cool cool cool cool cool.', 200); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 3c428cf5f..6b13d2cb7 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -620,7 +620,7 @@ private function deploy_docker_compose_buildpack() $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); } } else { - $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); + $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit); // Always add .env file to services $services = collect(data_get($composeFile, 'services', [])); $services = $services->map(function ($service, $name) { @@ -670,13 +670,20 @@ private function deploy_docker_compose_buildpack() $build_command = "DOCKER_BUILDKIT=1 {$build_command}"; } - // Append build arguments if not using build secrets (matching default behavior) + // Inject build arguments after build subcommand if not using build secrets if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) { $build_args_string = $this->build_args->implode(' '); // Escape single quotes for bash -c context used by executeInDocker $build_args_string = str_replace("'", "'\\''", $build_args_string); - $build_command .= " {$build_args_string}"; - $this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.'); + + // Inject build args right after 'build' subcommand (not at the end) + $original_command = $build_command; + $build_command = injectDockerComposeBuildArgs($build_command, $build_args_string); + + // Only log if build args were actually injected (command was modified) + if ($build_command !== $original_command) { + $this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.'); + } } $this->execute_remote_command( @@ -1806,9 +1813,9 @@ private function health_check() $this->application->update(['status' => 'running']); $this->application_deployment_queue->addLogEntry('New container is healthy.'); break; - } - if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') { + } elseif (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') { $this->newVersionIsHealthy = false; + $this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error'); $this->query_logs(); break; } @@ -2274,13 +2281,13 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = collect([]); if ($this->pull_request_id === 0) { foreach ($this->application->nixpacks_environment_variables as $env) { - if (! is_null($env->real_value)) { + if (! is_null($env->real_value) && $env->real_value !== '') { $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}"); } } } else { foreach ($this->application->nixpacks_environment_variables_preview as $env) { - if (! is_null($env->real_value)) { + if (! is_null($env->real_value) && $env->real_value !== '') { $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}"); } } @@ -2289,7 +2296,10 @@ private function generate_nixpacks_env_variables() // Add COOLIFY_* environment variables to Nixpacks build context $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { - $this->env_nixpacks_args->push("--env {$key}={$value}"); + // Only add environment variables with non-null and non-empty values + if (! is_null($value) && $value !== '') { + $this->env_nixpacks_args->push("--env {$key}={$value}"); + } }); $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); @@ -3180,6 +3190,18 @@ private function stop_running_container(bool $force = false) $this->graceful_shutdown_container($this->container_name); } } catch (Exception $e) { + // If new version is healthy, this is just cleanup - don't fail the deployment + if ($this->newVersionIsHealthy || $force) { + $this->application_deployment_queue->addLogEntry( + "Warning: Could not remove old container: {$e->getMessage()}", + 'stderr', + hidden: true + ); + + return; // Don't re-throw - cleanup failures shouldn't fail successful deployments + } + + // Only re-throw if deployment hasn't succeeded yet throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e); } } diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 88484bcce..92ec4cbd4 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; use Illuminate\Bus\Queueable; @@ -38,6 +39,8 @@ public function handle(): void $this->server->update(['detected_traefik_version' => $currentVersion]); if (! $currentVersion) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -48,16 +51,22 @@ public function handle(): void // Handle empty/null response from SSH command if (empty(trim($imageTag))) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } if (str_contains(strtolower(trim($imageTag)), ':latest')) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } // Parse current version to extract major.minor.patch $current = ltrim($currentVersion, 'v'); if (! preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches)) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -77,6 +86,8 @@ public function handle(): void $this->server->update(['traefik_outdated_info' => null]); } + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -96,6 +107,9 @@ public function handle(): void // Fully up to date $this->server->update(['traefik_outdated_info' => null]); } + + // Dispatch UI update event so warning state refreshes in real-time + ProxyStatusChangedUI::dispatch($this->server->team_id); } /** diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 6917de6d5..a585baa69 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -121,7 +121,7 @@ public function handle(): void $this->container_name = "{$this->database->name}-$serviceUuid"; $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env | grep POSTGRES_"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); $user = $envs->filter(function ($env) { @@ -152,7 +152,7 @@ public function handle(): void $this->container_name = "{$this->database->name}-$serviceUuid"; $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env | grep MYSQL_"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); $rootPassword = $envs->filter(function ($env) { @@ -175,7 +175,7 @@ public function handle(): void $this->container_name = "{$this->database->name}-$serviceUuid"; $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); $rootPassword = $envs->filter(function ($env) { return str($env)->startsWith('MARIADB_ROOT_PASSWORD='); @@ -217,7 +217,7 @@ public function handle(): void try { $commands = []; $commands[] = "docker exec $this->container_name env | grep MONGO_INITDB_"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); if (filled($envs)) { $envs = str($envs)->explode("\n"); @@ -508,7 +508,7 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi } } } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -537,7 +537,7 @@ private function backup_standalone_postgresql(string $database): void } $commands[] = $backupCommand; - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -560,7 +560,7 @@ private function backup_standalone_mysql(string $database): void $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location"; } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -583,7 +583,7 @@ private function backup_standalone_mariadb(string $database): void $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location"; } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -614,7 +614,7 @@ private function add_to_error_output($output): void private function calculate_size() { - return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false); + return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false, false, null, disableMultiplexing: true); } private function upload_to_s3(): void @@ -637,9 +637,9 @@ private function upload_to_s3(): void $fullImageName = $this->getFullImageName(); - $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false); + $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true); if (filled($containerExists)) { - instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false); + instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true); } if (isDev()) { @@ -661,7 +661,7 @@ private function upload_to_s3(): void $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; - instant_remote_process($commands, $this->server); + instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $this->s3_uploaded = true; } catch (\Throwable $e) { @@ -670,7 +670,7 @@ private function upload_to_s3(): void throw $e; } finally { $command = "docker rm -f backup-of-{$this->backup_log_uuid}"; - instant_remote_process([$command], $this->server); + instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true); } } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 9d44e08f9..e6c64ada7 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -300,8 +300,9 @@ private function aggregateMultiContainerStatuses() } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); // Update application status with aggregated result if ($aggregatedStatus && $application->status !== $aggregatedStatus) { @@ -360,8 +361,9 @@ private function aggregateServiceContainerStatuses() // Use ContainerStatusAggregator service for state machine logic // NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0 + // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); // Update service sub-resource status with aggregated result if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index e3e809c8d..2815c73bc 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -2,9 +2,12 @@ namespace App\Jobs; -use App\Actions\Proxy\StartProxy; -use App\Actions\Proxy\StopProxy; +use App\Actions\Proxy\GetProxyConfiguration; +use App\Actions\Proxy\SaveProxyConfiguration; +use App\Enums\ProxyTypes; +use App\Events\ProxyStatusChangedUI; use App\Models\Server; +use App\Services\ProxyDashboardCacheService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -19,11 +22,13 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue public $tries = 1; - public $timeout = 60; + public $timeout = 120; + + public ?int $activity_id = null; public function middleware(): array { - return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(60)->dontRelease()]; + return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(120)->dontRelease()]; } public function __construct(public Server $server) {} @@ -31,15 +36,125 @@ public function __construct(public Server $server) {} public function handle() { try { - StopProxy::run($this->server, restarting: true); - + // Set status to restarting + $this->server->proxy->status = 'restarting'; $this->server->proxy->force_stop = false; $this->server->save(); - StartProxy::run($this->server, force: true, restarting: true); + // Build combined stop + start commands for a single activity + $commands = $this->buildRestartCommands(); + + // Create activity and dispatch immediately - returns Activity right away + // The remote_process runs asynchronously, so UI gets activity ID instantly + $activity = remote_process( + $commands, + $this->server, + callEventOnFinish: 'ProxyStatusChanged', + callEventData: $this->server->id + ); + + // Store activity ID and notify UI immediately with it + $this->activity_id = $activity->id; + ProxyStatusChangedUI::dispatch($this->server->team_id, $this->activity_id); } catch (\Throwable $e) { + // Set error status + $this->server->proxy->status = 'error'; + $this->server->save(); + + // Notify UI of error + ProxyStatusChangedUI::dispatch($this->server->team_id); + + // Clear dashboard cache on error + ProxyDashboardCacheService::clearCache($this->server); + return handleError($e); } } + + /** + * Build combined stop + start commands for proxy restart. + * This creates a single command sequence that shows all logs in one activity. + */ + private function buildRestartCommands(): array + { + $proxyType = $this->server->proxyType(); + $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; + $proxy_path = $this->server->proxyPath(); + $stopTimeout = 30; + + // Get proxy configuration + $configuration = GetProxyConfiguration::run($this->server); + if (! $configuration) { + throw new \Exception('Configuration is not synced'); + } + SaveProxyConfiguration::run($this->server, $configuration); + $docker_compose_yml_base64 = base64_encode($configuration); + $this->server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); + $this->server->save(); + + $commands = collect([]); + + // === STOP PHASE === + $commands = $commands->merge([ + "echo 'Stopping proxy...'", + "docker stop -t=$stopTimeout $containerName 2>/dev/null || true", + "docker rm -f $containerName 2>/dev/null || true", + '# Wait for container to be fully removed', + 'for i in {1..15}; do', + " if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then", + " echo 'Container removed successfully.'", + ' break', + ' fi', + ' echo "Waiting for container to be removed... ($i/15)"', + ' sleep 1', + ' # Force remove on each iteration in case it got stuck', + " docker rm -f $containerName 2>/dev/null || true", + 'done', + '# Final verification and force cleanup', + "if docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then", + " echo 'Container still exists after wait, forcing removal...'", + " docker rm -f $containerName 2>/dev/null || true", + ' sleep 2', + 'fi', + "echo 'Proxy stopped successfully.'", + ]); + + // === START PHASE === + if ($this->server->isSwarmManager()) { + $commands = $commands->merge([ + "echo 'Starting proxy (Swarm mode)...'", + "mkdir -p $proxy_path/dynamic", + "cd $proxy_path", + "echo 'Creating required Docker Compose file.'", + "echo 'Starting coolify-proxy.'", + 'docker stack deploy --detach=true -c docker-compose.yml coolify-proxy', + "echo 'Successfully started coolify-proxy.'", + ]); + } else { + if (isDev() && $proxyType === ProxyTypes::CADDY->value) { + $proxy_path = '/data/coolify/proxy/caddy'; + } + $caddyfile = 'import /dynamic/*.caddy'; + $commands = $commands->merge([ + "echo 'Starting proxy...'", + "mkdir -p $proxy_path/dynamic", + "cd $proxy_path", + "echo '$caddyfile' > $proxy_path/dynamic/Caddyfile", + "echo 'Creating required Docker Compose file.'", + "echo 'Pulling docker image.'", + 'docker compose pull', + ]); + // Ensure required networks exist BEFORE docker compose up + $commands = $commands->merge(ensureProxyNetworksExist($this->server)); + $commands = $commands->merge([ + "echo 'Starting coolify-proxy.'", + 'docker compose up -d --wait --remove-orphans', + "echo 'Successfully started coolify-proxy.'", + ]); + $commands = $commands->merge(connectProxyToNetworks($this->server)); + } + + return $commands->toArray(); + } } diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index e55db5440..b21bc11a1 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -139,7 +139,9 @@ public function handle(): void if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $exec = "docker exec {$containerName} {$cmd}"; - $this->task_output = instant_remote_process([$exec], $this->server, true); + // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently + // See: https://github.com/coollabsio/coolify/issues/6736 + $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 45ab1dde8..20dc9987e 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -111,34 +111,48 @@ private function processScheduledTasks(Collection $servers): void private function processServerTasks(Server $server): void { + // Get server timezone (used for all scheduled tasks) + $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); + } + // Check if we should run sentinel-based checks $lastSentinelUpdate = $server->sentinel_updated_at; $waitTime = $server->waitBeforeDoingSshCheck(); - $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($waitTime)); + $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->copy()->subSeconds($waitTime)); if ($sentinelOutOfSync) { - // Dispatch jobs if Sentinel is out of sync - if ($this->shouldRunNow($this->checkFrequency)) { + // Dispatch ServerCheckJob if Sentinel is out of sync + if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) { ServerCheckJob::dispatch($server); } + } - // Dispatch ServerStorageCheckJob if due - $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *'); + $isSentinelEnabled = $server->isSentinelEnabled(); + $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); + // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) + + if ($shouldRestartSentinel) { + dispatch(function () use ($server) { + $server->restartContainer('coolify-sentinel'); + }); + } + + // Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled) + // When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data + if ($sentinelOutOfSync) { + $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'); if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; } - $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency); + $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone); if ($shouldRunStorageCheck) { ServerStorageCheckJob::dispatch($server); } } - $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - // Dispatch ServerPatchCheckJob if due (weekly) $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone); @@ -146,14 +160,13 @@ private function processServerTasks(Server $server): void ServerPatchCheckJob::dispatch($server); } - // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) - $isSentinelEnabled = $server->isSentinelEnabled(); - $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); + // Check for sentinel updates hourly (independent of user-configurable update_check_frequency) + if ($server->isSentinelEnabled()) { + $shouldCheckSentinel = $this->shouldRunNow('0 * * * *', $serverTimezone); - if ($shouldRestartSentinel) { - dispatch(function () use ($server) { - $server->restartContainer('coolify-sentinel'); - }); + if ($shouldCheckSentinel) { + CheckAndStartSentinelJob::dispatch($server); + } } } diff --git a/app/Listeners/MaintenanceModeDisabledNotification.php b/app/Listeners/MaintenanceModeDisabledNotification.php deleted file mode 100644 index 6c3ab83d8..000000000 --- a/app/Listeners/MaintenanceModeDisabledNotification.php +++ /dev/null @@ -1,48 +0,0 @@ -files(); - $files = collect($files); - $files = $files->sort(); - foreach ($files as $file) { - $content = Storage::disk('webhooks-during-maintenance')->get($file); - $data = json_decode($content, true); - $symfonyRequest = new SymfonyRequest( - $data['query'], - $data['request'], - $data['attributes'], - $data['cookies'], - $data['files'], - $data['server'], - $data['content'] - ); - - foreach ($data['headers'] as $key => $value) { - $symfonyRequest->headers->set($key, $value); - } - $request = Request::createFromBase($symfonyRequest); - $endpoint = str($file)->after('_')->beforeLast('_')->value(); - $class = "App\Http\Controllers\Webhook\\".ucfirst(str($endpoint)->before('::')->value()); - $method = str($endpoint)->after('::')->value(); - try { - $instance = new $class; - $instance->$method($request); - } catch (\Throwable $th) { - } finally { - Storage::disk('webhooks-during-maintenance')->delete($file); - } - } - } -} diff --git a/app/Listeners/MaintenanceModeEnabledNotification.php b/app/Listeners/MaintenanceModeEnabledNotification.php deleted file mode 100644 index 5aab248ea..000000000 --- a/app/Listeners/MaintenanceModeEnabledNotification.php +++ /dev/null @@ -1,21 +0,0 @@ -setupDynamicProxyConfiguration(); $server->proxy->force_stop = false; $server->save(); + + // Check Traefik version after proxy is running + if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { + $traefikVersions = get_traefik_versions(); + if ($traefikVersions !== null) { + CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions); + } else { + Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + ]); + } + } } if ($status === 'created') { instant_remote_process([ diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index cdac47d3d..87f7cff8a 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -18,6 +18,8 @@ class Show extends Component public $isKeepAliveOn = true; + public bool $is_debug_enabled = false; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -56,9 +58,23 @@ public function mount() $this->application_deployment_queue = $application_deployment_queue; $this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus(); $this->deployment_uuid = $deploymentUuid; + $this->is_debug_enabled = $this->application->settings->is_debug_enabled; $this->isKeepAliveOn(); } + public function toggleDebug() + { + try { + $this->authorize('update', $this->application); + $this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled; + $this->application->settings->save(); + $this->is_debug_enabled = $this->application->settings->is_debug_enabled; + $this->application_deployment_queue->refresh(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function refreshQueue() { $this->application_deployment_queue->refresh(); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 71ca9720e..c84de9d8d 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -521,7 +521,7 @@ public function instantSave() } } - public function loadComposeFile($isInit = false, $showToast = true) + public function loadComposeFile($isInit = false, $showToast = true, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null) { try { $this->authorize('update', $this->application); @@ -530,7 +530,7 @@ public function loadComposeFile($isInit = false, $showToast = true) return; } - ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit); + ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit, $restoreBaseDirectory, $restoreDockerComposeLocation); if (is_null($this->parsedServices)) { $showToast && $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.'); @@ -606,13 +606,6 @@ public function generateDomain(string $serviceName) } } - public function updatedBaseDirectory() - { - if ($this->buildPack === 'dockercompose') { - $this->loadComposeFile(); - } - } - public function updatedIsStatic($value) { if ($value) { @@ -786,11 +779,13 @@ public function submit($showToaster = true) try { $this->authorize('update', $this->application); + $this->resetErrorBag(); $this->validate(); $oldPortsExposes = $this->application->ports_exposes; $oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled; $oldDockerComposeLocation = $this->initialDockerComposeLocation; + $oldBaseDirectory = $this->application->base_directory; // Process FQDN with intermediate variable to avoid Collection/string confusion $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); @@ -821,6 +816,42 @@ public function submit($showToaster = true) return; // Stop if there are conflicts and user hasn't confirmed } + // Normalize paths BEFORE validation + if ($this->baseDirectory && $this->baseDirectory !== '/') { + $this->baseDirectory = rtrim($this->baseDirectory, '/'); + $this->application->base_directory = $this->baseDirectory; + } + if ($this->publishDirectory && $this->publishDirectory !== '/') { + $this->publishDirectory = rtrim($this->publishDirectory, '/'); + $this->application->publish_directory = $this->publishDirectory; + } + + // Validate docker compose file path BEFORE saving to database + // This prevents invalid paths from being persisted when validation fails + if ($this->buildPack === 'dockercompose' && + ($oldDockerComposeLocation !== $this->dockerComposeLocation || + $oldBaseDirectory !== $this->baseDirectory)) { + // Pass original values to loadComposeFile so it can restore them on failure + // The finally block in Application::loadComposeFile will save these original + // values if validation fails, preventing invalid paths from being persisted + $compose_return = $this->loadComposeFile( + isInit: false, + showToast: false, + restoreBaseDirectory: $oldBaseDirectory, + restoreDockerComposeLocation: $oldDockerComposeLocation + ); + if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { + // Validation failed - restore original values to component properties + $this->baseDirectory = $oldBaseDirectory; + $this->dockerComposeLocation = $oldDockerComposeLocation; + // The model was saved by loadComposeFile's finally block with original values + // Refresh to sync component with database state + $this->application->refresh(); + + return; + } + } + $this->application->save(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && ! $this->application->settings->is_container_label_readonly_enabled) { $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); @@ -828,13 +859,6 @@ public function submit($showToaster = true) $this->application->save(); } - if ($this->buildPack === 'dockercompose' && $oldDockerComposeLocation !== $this->dockerComposeLocation) { - $compose_return = $this->loadComposeFile(showToast: false); - if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { - return; - } - } - if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) { $this->resetDefaultLabels(); } @@ -855,14 +879,6 @@ public function submit($showToaster = true) $this->application->ports_exposes = $port; } } - if ($this->baseDirectory && $this->baseDirectory !== '/') { - $this->baseDirectory = rtrim($this->baseDirectory, '/'); - $this->application->base_directory = $this->baseDirectory; - } - if ($this->publishDirectory && $this->publishDirectory !== '/') { - $this->publishDirectory = rtrim($this->publishDirectory, '/'); - $this->application->publish_directory = $this->publishDirectory; - } if ($this->buildPack === 'dockercompose') { $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); if ($this->application->isDirty('docker_compose_domains')) { @@ -1018,11 +1034,27 @@ public function getDockerComposeBuildCommandPreviewProperty(): string // Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml) // Actual deployment uses absolute path: /artifacts/{deployment_uuid}{base_directory}{docker_compose_location} // Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth - return injectDockerComposeFlags( + $command = injectDockerComposeFlags( $this->dockerComposeCustomBuildCommand, ".{$normalizedBase}{$this->dockerComposeLocation}", \App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH ); + + // Inject build args if not using build secrets + if (! $this->application->settings->use_build_secrets) { + $buildTimeEnvs = $this->application->environment_variables() + ->where('is_buildtime', true) + ->get(); + + if ($buildTimeEnvs->isNotEmpty()) { + $buildArgs = generateDockerBuildArgs($buildTimeEnvs); + $buildArgsString = $buildArgs->implode(' '); + + $command = injectDockerComposeBuildArgs($command, $buildArgsString); + } + } + + return $command; } public function getDockerComposeStartCommandPreviewProperty(): string diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index da67a5707..d6b490c79 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -4,6 +4,7 @@ use App\Models\Application; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Validate; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -19,9 +20,30 @@ class Rollback extends Component public array $parameters; + #[Validate(['integer', 'min:0', 'max:100'])] + public int $dockerImagesToKeep = 2; + + public bool $serverRetentionDisabled = false; + public function mount() { $this->parameters = get_route_parameters(); + $this->dockerImagesToKeep = $this->application->settings->docker_images_to_keep ?? 2; + $server = $this->application->destination->server; + $this->serverRetentionDisabled = $server->settings->disable_application_image_retention ?? false; + } + + public function saveSettings() + { + try { + $this->authorize('update', $this->application); + $this->validate(); + $this->application->settings->docker_images_to_keep = $this->dockerImagesToKeep; + $this->application->settings->save(); + $this->dispatch('success', 'Settings saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function rollbackImage($commit) @@ -66,14 +88,12 @@ public function loadImages($showToast = false) return str($item)->contains($image); })->map(function ($item) { $item = str($item)->explode('#'); - if ($item[1] === $this->current) { - // $is_current = true; - } + $is_current = $item[1] === $this->current; return [ 'tag' => $item[1], 'created_at' => $item[2], - 'is_current' => $is_current ?? null, + 'is_current' => $is_current, ]; })->toArray(); } diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 6eba4f368..40d2674e2 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -75,16 +75,6 @@ public function mount() $this->github_apps = GithubApp::private(); } - public function updatedBaseDirectory() - { - if ($this->base_directory) { - $this->base_directory = rtrim($this->base_directory, '/'); - if (! str($this->base_directory)->startsWith('/')) { - $this->base_directory = '/'.$this->base_directory; - } - } - } - public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 89814ee7f..2fffff6b9 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -107,26 +107,6 @@ public function mount() $this->query = request()->query(); } - public function updatedBaseDirectory() - { - if ($this->base_directory) { - $this->base_directory = rtrim($this->base_directory, '/'); - if (! str($this->base_directory)->startsWith('/')) { - $this->base_directory = '/'.$this->base_directory; - } - } - } - - public function updatedDockerComposeLocation() - { - if ($this->docker_compose_location) { - $this->docker_compose_location = rtrim($this->docker_compose_location, '/'); - if (! str($this->docker_compose_location)->startsWith('/')) { - $this->docker_compose_location = '/'.$this->docker_compose_location; - } - } - } - public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 259b9dbec..68544f1ab 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -82,6 +82,21 @@ public function instantSave() } } + public function instantSaveSettings() + { + try { + $this->authorize('update', $this->application); + // Save checkbox states without port validation + $this->application->is_gzip_enabled = $this->isGzipEnabled; + $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled; + $this->application->exclude_from_status = $this->excludeFromStatus; + $this->application->save(); + $this->dispatch('success', 'Settings saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function instantSaveAdvanced() { try { diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 3ed2befba..f57563330 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -39,10 +39,14 @@ class GetLogs extends Component public ?bool $streamLogs = false; - public ?bool $showTimeStamps = false; + public ?bool $showTimeStamps = true; public ?int $numberOfLines = 100; + public bool $expandByDefault = false; + + public bool $collapsible = true; + public function mount() { if (! is_null($this->resource)) { @@ -92,12 +96,33 @@ public function instantSave() } } + public function toggleTimestamps() + { + $previousValue = $this->showTimeStamps; + $this->showTimeStamps = ! $this->showTimeStamps; + + try { + $this->instantSave(); + $this->getLogs(true); + } catch (\Throwable $e) { + // Revert the flag to its previous value on failure + $this->showTimeStamps = $previousValue; + + return handleError($e, $this); + } + } + + public function toggleStreamLogs() + { + $this->streamLogs = ! $this->streamLogs; + } + public function getLogs($refresh = false) { if (! $this->server->isFunctional()) { return; } - if (! $refresh && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) { + if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) { return; } if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index d7210c15d..2d6b76c25 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -41,7 +41,7 @@ class Add extends Component 'command' => 'required|string', 'frequency' => 'required|string', 'container' => 'nullable|string', - 'timeout' => 'required|integer|min:60|max:3600', + 'timeout' => 'required|integer|min:60|max:36000', ]; protected $validationAttributes = [ diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 088de0a76..f7947951b 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -40,7 +40,7 @@ class Show extends Component #[Validate(['string', 'nullable'])] public ?string $container = null; - #[Validate(['integer', 'required', 'min:60', 'max:3600'])] + #[Validate(['integer', 'required', 'min:60', 'max:36000'])] public $timeout = 300; #[Locked] diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 764e583cd..92094c950 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -31,6 +31,9 @@ class DockerCleanup extends Component #[Validate('boolean')] public bool $deleteUnusedNetworks = false; + #[Validate('boolean')] + public bool $disableApplicationImageRetention = false; + public function mount(string $server_uuid) { try { @@ -52,6 +55,7 @@ public function syncData(bool $toModel = false) $this->server->settings->docker_cleanup_threshold = $this->dockerCleanupThreshold; $this->server->settings->delete_unused_volumes = $this->deleteUnusedVolumes; $this->server->settings->delete_unused_networks = $this->deleteUnusedNetworks; + $this->server->settings->disable_application_image_retention = $this->disableApplicationImageRetention; $this->server->settings->save(); } else { $this->forceDockerCleanup = $this->server->settings->force_docker_cleanup; @@ -59,6 +63,7 @@ public function syncData(bool $toModel = false) $this->dockerCleanupThreshold = $this->server->settings->docker_cleanup_threshold; $this->deleteUnusedVolumes = $this->server->settings->delete_unused_volumes; $this->deleteUnusedNetworks = $this->server->settings->delete_unused_networks; + $this->disableApplicationImageRetention = $this->server->settings->disable_application_image_retention; } } diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 6725e5d0a..cd9cfcba6 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -6,11 +6,10 @@ use App\Actions\Proxy\StartProxy; use App\Actions\Proxy\StopProxy; use App\Enums\ProxyTypes; -use App\Jobs\CheckTraefikVersionForServerJob; +use App\Jobs\RestartProxyJob; use App\Models\Server; use App\Services\ProxyDashboardCacheService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Log; use Livewire\Component; class Navbar extends Component @@ -29,6 +28,10 @@ class Navbar extends Component public ?string $proxyStatus = 'unknown'; + public ?string $lastNotifiedStatus = null; + + public bool $restartInitiated = false; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -63,27 +66,19 @@ public function restart() { try { $this->authorize('manageProxy', $this->server); - StopProxy::run($this->server, restarting: true); - $this->server->proxy->force_stop = false; - $this->server->save(); - - $activity = StartProxy::run($this->server, force: true, restarting: true); - $this->dispatch('activityMonitor', $activity->id); - - // Check Traefik version after restart to provide immediate feedback - if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) { - $traefikVersions = get_traefik_versions(); - if ($traefikVersions !== null) { - CheckTraefikVersionForServerJob::dispatch($this->server, $traefikVersions); - } else { - Log::warning('Traefik version check skipped: versions.json data unavailable', [ - 'server_id' => $this->server->id, - 'server_name' => $this->server->name, - ]); - } + // Prevent duplicate restart calls + if ($this->restartInitiated) { + return; } + $this->restartInitiated = true; + + // Always use background job for all servers + RestartProxyJob::dispatch($this->server); + } catch (\Throwable $e) { + $this->restartInitiated = false; + return handleError($e, $this); } } @@ -137,12 +132,27 @@ public function checkProxyStatus() } } - public function showNotification() + public function showNotification($event = null) { $previousStatus = $this->proxyStatus; $this->server->refresh(); $this->proxyStatus = $this->server->proxy->status ?? 'unknown'; + // If event contains activityId, open activity monitor + if ($event && isset($event['activityId'])) { + $this->dispatch('activityMonitor', $event['activityId']); + } + + // Reset restart flag when proxy reaches a stable state + if (in_array($this->proxyStatus, ['running', 'exited', 'error'])) { + $this->restartInitiated = false; + } + + // Skip notification if we already notified about this status (prevents duplicates) + if ($this->lastNotifiedStatus === $this->proxyStatus) { + return; + } + switch ($this->proxyStatus) { case 'running': $this->loadProxyConfiguration(); @@ -150,6 +160,7 @@ public function showNotification() // Don't show during normal start/restart flows (starting, restarting, stopping) if (in_array($previousStatus, ['exited', 'stopped', 'unknown', null])) { $this->dispatch('success', 'Proxy is running.'); + $this->lastNotifiedStatus = $this->proxyStatus; } break; case 'exited': @@ -157,19 +168,30 @@ public function showNotification() // Don't show during normal stop/restart flows (stopping, restarting) if (in_array($previousStatus, ['running'])) { $this->dispatch('info', 'Proxy has exited.'); + $this->lastNotifiedStatus = $this->proxyStatus; } break; case 'stopping': - $this->dispatch('info', 'Proxy is stopping.'); + // $this->dispatch('info', 'Proxy is stopping.'); + $this->lastNotifiedStatus = $this->proxyStatus; break; case 'starting': - $this->dispatch('info', 'Proxy is starting.'); + // $this->dispatch('info', 'Proxy is starting.'); + $this->lastNotifiedStatus = $this->proxyStatus; + break; + case 'restarting': + // $this->dispatch('info', 'Proxy is restarting.'); + $this->lastNotifiedStatus = $this->proxyStatus; + break; + case 'error': + $this->dispatch('error', 'Proxy restart failed. Check logs.'); + $this->lastNotifiedStatus = $this->proxyStatus; break; case 'unknown': - $this->dispatch('info', 'Proxy status is unknown.'); + // Don't notify for unknown status - too noisy break; default: - $this->dispatch('info', 'Proxy status updated.'); + // Don't notify for other statuses break; } diff --git a/app/Models/Application.php b/app/Models/Application.php index 6e920f8e6..118245546 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1500,10 +1500,10 @@ public function oldRawParser() instant_remote_process($commands, $this->destination->server, false); } - public function parse(int $pull_request_id = 0, ?int $preview_id = null) + public function parse(int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null) { if ((int) $this->compose_parsing_version >= 3) { - return applicationParser($this, $pull_request_id, $preview_id); + return applicationParser($this, $pull_request_id, $preview_id, $commit); } elseif ($this->docker_compose_raw) { return parseDockerComposeFile(resource: $this, isNew: false, pull_request_id: $pull_request_id, preview_id: $preview_id); } else { @@ -1511,9 +1511,11 @@ public function parse(int $pull_request_id = 0, ?int $preview_id = null) } } - public function loadComposeFile($isInit = false) + public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null) { - $initialDockerComposeLocation = $this->docker_compose_location; + // Use provided restore values or capture current values as fallback + $initialDockerComposeLocation = $restoreDockerComposeLocation ?? $this->docker_compose_location; + $initialBaseDirectory = $restoreBaseDirectory ?? $this->base_directory; if ($isInit && $this->docker_compose_raw) { return; } @@ -1580,6 +1582,7 @@ public function loadComposeFile($isInit = false) throw new \RuntimeException($e->getMessage()); } finally { $this->docker_compose_location = $initialDockerComposeLocation; + $this->base_directory = $initialBaseDirectory; $this->save(); $commands = collect([ "rm -rf /tmp/{$uuid}", diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index de545e9bb..f40977b3e 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -25,6 +25,7 @@ class ApplicationSetting extends Model 'is_git_submodules_enabled' => 'boolean', 'is_git_lfs_enabled' => 'boolean', 'is_git_shallow_clone_enabled' => 'boolean', + 'docker_images_to_keep' => 'integer', ]; protected $guarded = []; diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 843f01e59..895dc1c43 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -65,6 +65,8 @@ protected static function booted() 'value' => $environment_variable->value, 'is_multiline' => $environment_variable->is_multiline ?? false, 'is_literal' => $environment_variable->is_literal ?? false, + 'is_runtime' => $environment_variable->is_runtime ?? false, + 'is_buildtime' => $environment_variable->is_buildtime ?? false, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 6da4dd4c6..4b33df300 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -61,6 +61,7 @@ class ServerSetting extends Model 'is_reachable' => 'boolean', 'is_usable' => 'boolean', 'is_terminal_enabled' => 'boolean', + 'disable_application_image_retention' => 'boolean', ]; protected static function booted() diff --git a/app/Models/Service.php b/app/Models/Service.php index 2f8a64464..2cea4c805 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -712,6 +712,84 @@ public function extraFields() $fields->put('MinIO', $data->toArray()); break; + case $image->contains('garage'): + $data = collect([]); + $s3_api_url = $this->environment_variables()->where('key', 'GARAGE_S3_API_URL')->first(); + $web_url = $this->environment_variables()->where('key', 'GARAGE_WEB_URL')->first(); + $admin_url = $this->environment_variables()->where('key', 'GARAGE_ADMIN_URL')->first(); + $admin_token = $this->environment_variables()->where('key', 'GARAGE_ADMIN_TOKEN')->first(); + if (is_null($admin_token)) { + $admin_token = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GARAGE')->first(); + } + $rpc_secret = $this->environment_variables()->where('key', 'GARAGE_RPC_SECRET')->first(); + if (is_null($rpc_secret)) { + $rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first(); + } + $metrics_token = $this->environment_variables()->where('key', 'GARAGE_METRICS_TOKEN')->first(); + if (is_null($metrics_token)) { + $metrics_token = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GARAGEMETRICS')->first(); + } + + if ($s3_api_url) { + $data = $data->merge([ + 'S3 API URL' => [ + 'key' => data_get($s3_api_url, 'key'), + 'value' => data_get($s3_api_url, 'value'), + 'rules' => 'required|url', + ], + ]); + } + if ($web_url) { + $data = $data->merge([ + 'Web URL' => [ + 'key' => data_get($web_url, 'key'), + 'value' => data_get($web_url, 'value'), + 'rules' => 'required|url', + ], + ]); + } + if ($admin_url) { + $data = $data->merge([ + 'Admin URL' => [ + 'key' => data_get($admin_url, 'key'), + 'value' => data_get($admin_url, 'value'), + 'rules' => 'required|url', + ], + ]); + } + if ($admin_token) { + $data = $data->merge([ + 'Admin Token' => [ + 'key' => data_get($admin_token, 'key'), + 'value' => data_get($admin_token, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + if ($rpc_secret) { + $data = $data->merge([ + 'RPC Secret' => [ + 'key' => data_get($rpc_secret, 'key'), + 'value' => data_get($rpc_secret, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + if ($metrics_token) { + $data = $data->merge([ + 'Metrics Token' => [ + 'key' => data_get($metrics_token, 'key'), + 'value' => data_get($metrics_token, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + + $fields->put('Garage', $data->toArray()); + break; case $image->contains('weblate'): $data = collect([]); $admin_email = $this->environment_variables()->where('key', 'WEBLATE_ADMIN_EMAIL')->first(); diff --git a/app/Notifications/Server/TraefikVersionOutdated.php b/app/Notifications/Server/TraefikVersionOutdated.php index 09ef4257d..c94cc1732 100644 --- a/app/Notifications/Server/TraefikVersionOutdated.php +++ b/app/Notifications/Server/TraefikVersionOutdated.php @@ -43,9 +43,19 @@ public function toMail($notifiable = null): MailMessage $mail = new MailMessage; $count = $this->servers->count(); + // Transform servers to include URLs + $serversWithUrls = $this->servers->map(function ($server) { + return [ + 'name' => $server->name, + 'uuid' => $server->uuid, + 'url' => base_url().'/server/'.$server->uuid.'/proxy', + 'outdatedInfo' => $server->outdatedInfo ?? [], + ]; + }); + $mail->subject("Coolify: Traefik proxy outdated on {$count} server(s)"); $mail->view('emails.traefik-version-outdated', [ - 'servers' => $this->servers, + 'servers' => $serversWithUrls, 'count' => $count, ]); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 2d9910add..9163d595c 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,10 +2,6 @@ namespace App\Providers; -use App\Listeners\MaintenanceModeDisabledNotification; -use App\Listeners\MaintenanceModeEnabledNotification; -use Illuminate\Foundation\Events\MaintenanceModeDisabled; -use Illuminate\Foundation\Events\MaintenanceModeEnabled; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use SocialiteProviders\Authentik\AuthentikExtendSocialite; use SocialiteProviders\Azure\AzureExtendSocialite; @@ -19,12 +15,6 @@ class EventServiceProvider extends ServiceProvider { protected $listen = [ - MaintenanceModeEnabled::class => [ - MaintenanceModeEnabledNotification::class, - ], - MaintenanceModeDisabled::class => [ - MaintenanceModeDisabledNotification::class, - ], SocialiteWasCalled::class => [ AzureExtendSocialite::class.'@handle', AuthentikExtendSocialite::class.'@handle', diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php index 4a17ecdd6..2be36d905 100644 --- a/app/Services/ContainerStatusAggregator.php +++ b/app/Services/ContainerStatusAggregator.php @@ -16,14 +16,23 @@ * UI components transform this to human-readable format (e.g., "Running (Healthy)"). * * State Priority (highest to lowest): - * 1. Restarting → degraded:unhealthy - * 2. Crash Loop (exited with restarts) → degraded:unhealthy - * 3. Mixed (running + exited) → degraded:unhealthy - * 4. Running → running:healthy/unhealthy/unknown - * 5. Dead/Removing → degraded:unhealthy - * 6. Paused → paused:unknown - * 7. Starting/Created → starting:unknown - * 8. Exited → exited + * 1. Degraded (from sub-resources) → degraded:unhealthy + * 2. Restarting → degraded:unhealthy (or restarting:unknown if preserveRestarting=true) + * 3. Crash Loop (exited with restarts) → degraded:unhealthy + * 4. Mixed (running + exited) → degraded:unhealthy + * 5. Mixed (running + starting) → starting:unknown + * 6. Running → running:healthy/unhealthy/unknown + * 7. Dead/Removing → degraded:unhealthy + * 8. Paused → paused:unknown + * 9. Starting/Created → starting:unknown + * 10. Exited → exited + * + * The $preserveRestarting parameter controls whether "restarting" containers should be + * reported as "restarting:unknown" (true) or "degraded:unhealthy" (false, default). + * - Use preserveRestarting=true for individual sub-resources (ServiceApplication/ServiceDatabase) + * so they show "Restarting" in the UI. + * - Use preserveRestarting=false for overall Service status aggregation where any restarting + * container should mark the entire service as "Degraded". */ class ContainerStatusAggregator { @@ -32,9 +41,10 @@ class ContainerStatusAggregator * * @param Collection $containerStatuses Collection of status strings (e.g., "running (healthy)", "running:healthy") * @param int $maxRestartCount Maximum restart count across containers (for crash loop detection) + * @param bool $preserveRestarting If true, "restarting" containers return "restarting:unknown" instead of "degraded:unhealthy" * @return string Aggregated status in colon format (e.g., "running:healthy") */ - public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0): string + public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0, bool $preserveRestarting = false): string { // Validate maxRestartCount parameter if ($maxRestartCount < 0) { @@ -64,10 +74,16 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest $hasStarting = false; $hasPaused = false; $hasDead = false; + $hasDegraded = false; // Parse each status string and set flags foreach ($containerStatuses as $status) { - if (str($status)->contains('restarting')) { + if (str($status)->contains('degraded')) { + $hasDegraded = true; + if (str($status)->contains('unhealthy')) { + $hasUnhealthy = true; + } + } elseif (str($status)->contains('restarting')) { $hasRestarting = true; } elseif (str($status)->contains('running')) { $hasRunning = true; @@ -98,7 +114,9 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest $hasStarting, $hasPaused, $hasDead, - $maxRestartCount + $hasDegraded, + $maxRestartCount, + $preserveRestarting ); } @@ -107,9 +125,10 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest * * @param Collection $containers Collection of Docker container objects with State property * @param int $maxRestartCount Maximum restart count across containers (for crash loop detection) + * @param bool $preserveRestarting If true, "restarting" containers return "restarting:unknown" instead of "degraded:unhealthy" * @return string Aggregated status in colon format (e.g., "running:healthy") */ - public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0): string + public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0, bool $preserveRestarting = false): string { // Validate maxRestartCount parameter if ($maxRestartCount < 0) { @@ -175,7 +194,9 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC $hasStarting, $hasPaused, $hasDead, - $maxRestartCount + false, // $hasDegraded - not applicable for container objects, only for status strings + $maxRestartCount, + $preserveRestarting ); } @@ -190,7 +211,9 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC * @param bool $hasStarting Has at least one starting/created container * @param bool $hasPaused Has at least one paused container * @param bool $hasDead Has at least one dead/removing container + * @param bool $hasDegraded Has at least one degraded container * @param int $maxRestartCount Maximum restart count (for crash loop detection) + * @param bool $preserveRestarting If true, return "restarting:unknown" instead of "degraded:unhealthy" for restarting containers * @return string Status in colon format (e.g., "running:healthy") */ private function resolveStatus( @@ -202,24 +225,40 @@ private function resolveStatus( bool $hasStarting, bool $hasPaused, bool $hasDead, - int $maxRestartCount + bool $hasDegraded, + int $maxRestartCount, + bool $preserveRestarting = false ): string { - // Priority 1: Restarting containers (degraded state) - if ($hasRestarting) { + // Priority 1: Degraded containers from sub-resources (highest priority) + // If any service/application within a service stack is degraded, the entire stack is degraded + if ($hasDegraded) { return 'degraded:unhealthy'; } - // Priority 2: Crash loop detection (exited with restart count > 0) + // Priority 2: Restarting containers + // When preserveRestarting is true (for individual sub-resources), keep as "restarting" + // When false (for overall service status), mark as "degraded" + if ($hasRestarting) { + return $preserveRestarting ? 'restarting:unknown' : 'degraded:unhealthy'; + } + + // Priority 3: Crash loop detection (exited with restart count > 0) if ($hasExited && $maxRestartCount > 0) { return 'degraded:unhealthy'; } - // Priority 3: Mixed state (some running, some exited = degraded) + // Priority 4: Mixed state (some running, some exited = degraded) if ($hasRunning && $hasExited) { return 'degraded:unhealthy'; } - // Priority 4: Running containers (check health status) + // Priority 5: Mixed state (some running, some starting = still starting) + // If any component is still starting, the entire service stack is not fully ready + if ($hasRunning && $hasStarting) { + return 'starting:unknown'; + } + + // Priority 6: Running containers (check health status) if ($hasRunning) { if ($hasUnhealthy) { return 'running:unhealthy'; @@ -230,22 +269,22 @@ private function resolveStatus( } } - // Priority 5: Dead or removing containers + // Priority 7: Dead or removing containers if ($hasDead) { return 'degraded:unhealthy'; } - // Priority 6: Paused containers + // Priority 8: Paused containers if ($hasPaused) { return 'paused:unknown'; } - // Priority 7: Starting/created containers + // Priority 9: Starting/created containers if ($hasStarting) { return 'starting:unknown'; } - // Priority 8: All containers exited (no restart count = truly stopped) + // Priority 10: All containers exited (no restart count = truly stopped) return 'exited'; } } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index db7767c1e..7a36c4b63 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -68,10 +68,16 @@ function queue_application_deployment(Application $application, string $deployme ]); if ($no_questions_asked) { + $deployment->update([ + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); ApplicationDeploymentJob::dispatch( application_deployment_queue_id: $deployment->id, ); } elseif (next_queuable($server_id, $application_id, $commit, $pull_request_id)) { + $deployment->update([ + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); ApplicationDeploymentJob::dispatch( application_deployment_queue_id: $deployment->id, ); diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 178876b89..114c4bb98 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -48,6 +48,8 @@ 'influxdb', 'clickhouse/clickhouse-server', 'timescaledb/timescaledb', + 'timescaledb', // Matches timescale/timescaledb + 'timescaledb-ha', // Matches timescale/timescaledb-ha 'pgvector/pgvector', ]; const SPECIFIC_SERVICES = [ @@ -56,6 +58,7 @@ 'ghcr.io/coollabsio/minio', 'coollabsio/minio', 'svhd/logto', + 'dxflrs/garage', ]; // Based on /etc/os-release diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 4a0faaec1..a0f810480 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -312,6 +312,36 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource) $LOGTO_ADMIN_ENDPOINT->value.':3002', ]); break; + case $type?->contains('garage'): + $GARAGE_S3_API_URL = $variables->where('key', 'GARAGE_S3_API_URL')->first(); + $GARAGE_WEB_URL = $variables->where('key', 'GARAGE_WEB_URL')->first(); + $GARAGE_ADMIN_URL = $variables->where('key', 'GARAGE_ADMIN_URL')->first(); + + if (is_null($GARAGE_S3_API_URL) || is_null($GARAGE_WEB_URL) || is_null($GARAGE_ADMIN_URL)) { + return collect([]); + } + + if (str($GARAGE_S3_API_URL->value ?? '')->isEmpty()) { + $GARAGE_S3_API_URL->update([ + 'value' => generateUrl(server: $server, random: 's3-'.$uuid, forceHttps: true), + ]); + } + if (str($GARAGE_WEB_URL->value ?? '')->isEmpty()) { + $GARAGE_WEB_URL->update([ + 'value' => generateUrl(server: $server, random: 'web-'.$uuid, forceHttps: true), + ]); + } + if (str($GARAGE_ADMIN_URL->value ?? '')->isEmpty()) { + $GARAGE_ADMIN_URL->update([ + 'value' => generateUrl(server: $server, random: 'admin-'.$uuid, forceHttps: true), + ]); + } + $payload = collect([ + $GARAGE_S3_API_URL->value.':3900', + $GARAGE_WEB_URL->value.':3902', + $GARAGE_ADMIN_URL->value.':3903', + ]); + break; } return $payload; @@ -770,10 +800,26 @@ function isDatabaseImage(?string $image = null, ?array $serviceConfig = null) } $imageName = $image->before(':'); - // First check if it's a known database image + // Extract base image name (ignore registry prefix) + // Examples: + // docker.io/library/postgres -> postgres + // ghcr.io/postgrest/postgrest -> postgrest + // postgres -> postgres + // postgrest/postgrest -> postgrest + $baseImageName = $imageName; + if (str($imageName)->contains('/')) { + $baseImageName = str($imageName)->afterLast('/'); + } + + // Check if base image name exactly matches a known database image $isKnownDatabase = false; foreach (DATABASE_DOCKER_IMAGES as $database_docker_image) { - if (str($imageName)->contains($database_docker_image)) { + // Extract base name from database pattern for comparison + $databaseBaseName = str($database_docker_image)->contains('/') + ? str($database_docker_image)->afterLast('/') + : $database_docker_image; + + if ($baseImageName == $databaseBaseName) { $isKnownDatabase = true; break; } @@ -1376,3 +1422,62 @@ function injectDockerComposeFlags(string $command, string $composeFilePath, stri // Replace only first occurrence to avoid modifying comments/strings/chained commands return preg_replace('/docker\s+compose/', $dockerComposeReplacement, $command, 1); } + +/** + * Inject build arguments right after build-related subcommands in docker/docker compose commands. + * This ensures build args are only applied to build operations, not to push, pull, up, etc. + * + * Supports: + * - docker compose build + * - docker buildx build + * - docker builder build + * - docker build (legacy) + * + * Examples: + * - Input: "docker compose -f file.yml build" + * Output: "docker compose -f file.yml build --build-arg X --build-arg Y" + * + * - Input: "docker buildx build --platform linux/amd64" + * Output: "docker buildx build --build-arg X --build-arg Y --platform linux/amd64" + * + * - Input: "docker builder build --tag myimage:latest" + * Output: "docker builder build --build-arg X --build-arg Y --tag myimage:latest" + * + * - Input: "docker compose build && docker compose push" + * Output: "docker compose build --build-arg X --build-arg Y && docker compose push" + * + * - Input: "docker compose push" + * Output: "docker compose push" (unchanged - no build command found) + * + * @param string $command The docker command + * @param string $buildArgsString The build arguments to inject (e.g., "--build-arg X --build-arg Y") + * @return string The modified command with build args injected after build subcommand + */ +function injectDockerComposeBuildArgs(string $command, string $buildArgsString): string +{ + // Early return if no build args to inject + if (empty(trim($buildArgsString))) { + return $command; + } + + // Match build-related commands: + // - ' builder build' (docker builder build) + // - ' buildx build' (docker buildx build) + // - ' build' (docker compose build, docker build) + // Followed by either: + // - whitespace (allowing service names, flags, or any valid arguments) + // - end of string ($) + // This regex ensures we match build subcommands, not "build" in other contexts + // IMPORTANT: Order matters - check longer patterns first (builder build, buildx build) before ' build' + $pattern = '/( builder build| buildx build| build)(?=\s|$)/'; + + // Replace the first occurrence of build command with build command + build-args + $modifiedCommand = preg_replace( + $pattern, + '$1 '.$buildArgsString, + $command, + 1 // Only replace first occurrence + ); + + return $modifiedCommand ?? $command; +} diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index e7d875777..d58a4b4fe 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -358,7 +358,7 @@ function parseDockerVolumeString(string $volumeString): array ]; } -function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection +function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null): Collection { $uuid = data_get($resource, 'uuid'); $compose = data_get($resource, 'docker_compose_raw'); @@ -1324,6 +1324,20 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ->values(); $payload['env_file'] = $envFiles; + + // Inject commit-based image tag for services with build directive (for rollback support) + // Only inject if service has build but no explicit image defined + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + if ($isPullRequest) { + $imageTag = "pr-{$pullRequestId}"; + } + $imageRepo = "{$uuid}_{$serviceName}"; + $payload['image'] = "{$imageRepo}:{$imageTag}"; + } + if ($isPullRequest) { $serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId); } diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 3218bf878..bdfbaba48 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -118,7 +118,7 @@ function () use ($server, $command_string) { ); } -function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string +function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false, ?int $timeout = null, bool $disableMultiplexing = false): ?string { $command = $command instanceof Collection ? $command->toArray() : $command; @@ -126,11 +126,12 @@ function instant_remote_process(Collection|array $command, Server $server, bool $command = parseCommandsByLineForSudo(collect($command), $server); } $command_string = implode("\n", $command); + $effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout'); return \App\Helpers\SshRetryHandler::retry( - function () use ($server, $command_string) { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); - $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand); + function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) { + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing); + $process = Process::timeout($effectiveTimeout)->run($sshCommand); $output = trim($process->output()); $exitCode = $process->exitCode(); diff --git a/config/constants.php b/config/constants.php index 9b1dd5f68..c55bec981 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.452', + 'version' => '4.0.0-beta.453', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/config/filesystems.php b/config/filesystems.php index c2df26c84..ba0921a79 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -35,13 +35,6 @@ 'throw' => false, ], - 'webhooks-during-maintenance' => [ - 'driver' => 'local', - 'root' => storage_path('app/webhooks-during-maintenance'), - 'visibility' => 'private', - 'throw' => false, - ], - 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php new file mode 100644 index 000000000..97547ac45 --- /dev/null +++ b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php @@ -0,0 +1,22 @@ +integer('docker_images_to_keep')->default(2); + }); + } + + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('docker_images_to_keep'); + }); + } +}; diff --git a/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php new file mode 100644 index 000000000..a2433e5c9 --- /dev/null +++ b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php @@ -0,0 +1,22 @@ +boolean('disable_application_image_retention')->default(false); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('disable_application_image_retention'); + }); + } +}; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b90f126a2..46e0e88e5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -11,7 +11,6 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups - - /data/coolify/webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index cd4a307aa..3116a4185 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -25,7 +25,6 @@ services: - ./databases:/var/www/html/storage/app/databases - ./services:/var/www/html/storage/app/services - ./backups:/var/www/html/storage/app/backups - - ./webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance env_file: - .env environment: @@ -75,13 +74,7 @@ services: POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_DB: "${DB_DATABASE:-coolify}" healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${DB_USERNAME}", - "-d", - "${DB_DATABASE:-coolify}" - ] + test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ] interval: 5s retries: 10 timeout: 2s @@ -121,7 +114,7 @@ services: SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s retries: 10 timeout: 2s diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index b90f126a2..46e0e88e5 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -11,7 +11,6 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups - - /data/coolify/webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 09ce3ead3..6306ab381 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -25,7 +25,6 @@ services: - ./databases:/var/www/html/storage/app/databases - ./services:/var/www/html/storage/app/services - ./backups:/var/www/html/storage/app/backups - - ./webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance env_file: - .env environment: @@ -75,13 +74,7 @@ services: POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_DB: "${DB_DATABASE:-coolify}" healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${DB_USERNAME}", - "-d", - "${DB_DATABASE:-coolify}" - ] + test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ] interval: 5s retries: 10 timeout: 2s @@ -121,7 +114,7 @@ services: SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s retries: 10 timeout: 2s diff --git a/other/nightly/install.sh b/other/nightly/install.sh index ac4e3caff..b037fe382 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -223,7 +223,7 @@ if [ "$WARNING_SPACE" = true ]; then sleep 5 fi -mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,sentinel} +mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} mkdir -p /data/coolify/ssh/{keys,mux} mkdir -p /data/coolify/proxy/dynamic diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 577fdfe18..6d3f90371 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.452" + "version": "4.0.0-beta.453" }, "nightly": { - "version": "4.0.0-beta.453" + "version": "4.0.0-beta.454" }, "helper": { "version": "1.0.12" diff --git a/public/svgs/fizzy.png b/public/svgs/fizzy.png new file mode 100644 index 000000000..44efbd781 Binary files /dev/null and b/public/svgs/fizzy.png differ diff --git a/public/svgs/garage.svg b/public/svgs/garage.svg new file mode 100644 index 000000000..18aedeaaf --- /dev/null +++ b/public/svgs/garage.svg @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/rustfs.png b/public/svgs/rustfs.png new file mode 100644 index 000000000..927b8c5c4 Binary files /dev/null and b/public/svgs/rustfs.png differ diff --git a/public/svgs/rustfs.svg b/public/svgs/rustfs.svg new file mode 100644 index 000000000..18e9b8418 --- /dev/null +++ b/public/svgs/rustfs.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/resources/css/app.css b/resources/css/app.css index 931e3fe19..30371d307 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -185,4 +185,15 @@ .input[type="password"] { .lds-heart { animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1); +} + +.log-highlight { + background-color: rgba(234, 179, 8, 0.4); + border-radius: 2px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.dark .log-highlight { + background-color: rgba(234, 179, 8, 0.3); } \ No newline at end of file diff --git a/resources/css/utilities.css b/resources/css/utilities.css index fbfb98e9f..abb177835 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -230,7 +230,7 @@ @utility box-without-bg-without-border { } @utility coolbox { - @apply relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded-lg border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem]; + @apply relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem]; } @utility on-box { diff --git a/resources/views/emails/traefik-version-outdated.blade.php b/resources/views/emails/traefik-version-outdated.blade.php index 28effabf3..91c627a73 100644 --- a/resources/views/emails/traefik-version-outdated.blade.php +++ b/resources/views/emails/traefik-version-outdated.blade.php @@ -5,10 +5,12 @@ @foreach ($servers as $server) @php - $info = $server->outdatedInfo ?? []; - $current = $info['current'] ?? 'unknown'; - $latest = $info['latest'] ?? 'unknown'; - $isPatch = ($info['type'] ?? 'patch_update') === 'patch_update'; + $serverName = data_get($server, 'name', 'Unknown Server'); + $serverUrl = data_get($server, 'url', '#'); + $info = data_get($server, 'outdatedInfo', []); + $current = data_get($info, 'current', 'unknown'); + $latest = data_get($info, 'latest', 'unknown'); + $isPatch = (data_get($info, 'type', 'patch_update') === 'patch_update'); $hasNewerBranch = isset($info['newer_branch_target']); $hasUpgrades = $hasUpgrades ?? false; if (!$isPatch || $hasNewerBranch) { @@ -19,8 +21,9 @@ $latest = str_starts_with($latest, 'v') ? $latest : "v{$latest}"; // For minor upgrades, use the upgrade_target (e.g., "v3.6") - if (!$isPatch && isset($info['upgrade_target'])) { - $upgradeTarget = str_starts_with($info['upgrade_target'], 'v') ? $info['upgrade_target'] : "v{$info['upgrade_target']}"; + if (!$isPatch && data_get($info, 'upgrade_target')) { + $upgradeTarget = data_get($info, 'upgrade_target'); + $upgradeTarget = str_starts_with($upgradeTarget, 'v') ? $upgradeTarget : "v{$upgradeTarget}"; } else { // For patch updates, show the full version $upgradeTarget = $latest; @@ -28,22 +31,23 @@ // Get newer branch info if available if ($hasNewerBranch) { - $newerBranchTarget = $info['newer_branch_target']; - $newerBranchLatest = str_starts_with($info['newer_branch_latest'], 'v') ? $info['newer_branch_latest'] : "v{$info['newer_branch_latest']}"; + $newerBranchTarget = data_get($info, 'newer_branch_target', 'unknown'); + $newerBranchLatest = data_get($info, 'newer_branch_latest', 'unknown'); + $newerBranchLatest = str_starts_with($newerBranchLatest, 'v') ? $newerBranchLatest : "v{$newerBranchLatest}"; } @endphp @if ($isPatch && $hasNewerBranch) -- **{{ $server->name }}**: {{ $current }} → {{ $upgradeTarget }} (patch update available) | Also available: {{ $newerBranchTarget }} (latest patch: {{ $newerBranchLatest }}) - new minor version +- [**{{ $serverName }}**]({{ $serverUrl }}): {{ $current }} → {{ $upgradeTarget }} (patch update available) | Also available: {{ $newerBranchTarget }} (latest patch: {{ $newerBranchLatest }}) - new minor version @elseif ($isPatch) -- **{{ $server->name }}**: {{ $current }} → {{ $upgradeTarget }} (patch update available) +- [**{{ $serverName }}**]({{ $serverUrl }}): {{ $current }} → {{ $upgradeTarget }} (patch update available) @else -- **{{ $server->name }}**: {{ $current }} (latest patch: {{ $latest }}) → {{ $upgradeTarget }} (new minor version available) +- [**{{ $serverName }}**]({{ $serverUrl }}): {{ $current }} (latest patch: {{ $latest }}) → {{ $upgradeTarget }} (new minor version available) @endif @endforeach ## Recommendation -It is recommended to test the new Traefik version before switching it in production environments. You can update your proxy configuration through your [Coolify Dashboard]({{ config('app.url') }}). +It is recommended to test the new Traefik version before switching it in production environments. You can update your proxy configuration by clicking on any server name above. @if ($hasUpgrades ?? false) **Important for minor version upgrades:** Before upgrading to a new minor version, please read the [Traefik changelog](https://github.com/traefik/traefik/releases) to understand breaking changes and new features. @@ -58,5 +62,5 @@ --- -You can manage your server proxy settings in your Coolify Dashboard. +Click on any server name above to manage its proxy settings. diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 7bb366cd4..2b4ca6054 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -83,7 +83,7 @@ if (!html) return ''; const URL_RE = /^(https?:|mailto:)/i; const config = { - ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'p', 'pre', 's', 'span', 'strong', + ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'mark', 'p', 'pre', 's', 'span', 'strong', 'u' ], ALLOWED_ATTR: ['class', 'href', 'target', 'title', 'rel'], diff --git a/resources/views/livewire/project/application/deployment-navbar.blade.php b/resources/views/livewire/project/application/deployment-navbar.blade.php index 60c660bf7..8d0fc18fb 100644 --- a/resources/views/livewire/project/application/deployment-navbar.blade.php +++ b/resources/views/livewire/project/application/deployment-navbar.blade.php @@ -1,18 +1,12 @@

Deployment Log

- @if ($is_debug_enabled) - Hide Debug Logs - @else - Show Debug Logs - @endif - @if (isDev()) - Copy Logs - @endif @if (data_get($application_deployment_queue, 'status') === 'queued') Force Start @endif - @if (data_get($application_deployment_queue, 'status') === 'in_progress' || - data_get($application_deployment_queue, 'status') === 'queued') + @if ( + data_get($application_deployment_queue, 'status') === 'in_progress' || + data_get($application_deployment_queue, 'status') === 'queued' + ) Cancel @endif -
+ \ No newline at end of file diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index b52a6eaf1..e5d1ce8e6 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -1,15 +1,18 @@
{{ data_get_str($application, 'name')->limit(10) }} > Deployment | Coolify - -

Deployment

- - -
Deployment + + +
- - @if (data_get($application_deployment_queue, 'status') === 'in_progress') -
Deployment is -
- {{ Str::headline(data_get($this->application_deployment_queue, 'status')) }}. -
- -
- {{--
Logs will be updated automatically.
--}} - @else -
Deployment is {{ Str::headline(data_get($application_deployment_queue, 'status')) }}. -
- @endif -
-
-
-
- - - - - + + @if (data_get($application_deployment_queue, 'status') === 'in_progress') +
Deployment is +
+ {{ Str::headline(data_get($this->application_deployment_queue, 'status')) }}.
+
- -
- @forelse ($this->logLines as $line) -
isset($line['command']) && $line['command'], - 'flex gap-2 dark:hover:bg-coolgray-500 hover:bg-gray-100', - ])> - {{ $line['timestamp'] }} - $line['hidden'], - 'text-red-500' => $line['stderr'], - 'font-bold' => isset($line['command']) && $line['command'], - 'whitespace-pre-wrap', - ])>{!! (isset($line['command']) && $line['command'] ? '[CMD]: ' : '') . trim($line['line']) !!} + {{--
Logs will be updated automatically.
--}} + @else +
Deployment is {{ Str::headline(data_get($application_deployment_queue, 'status')) }}. +
+ @endif +
+
+
+ + +
+
+ + + + + +
+ + + + + +
- @empty - No logs yet. - @endforelse +
+
+
+
+ No matches found. +
+ @forelse ($this->logLines as $line) + @php + $lineContent = (isset($line['command']) && $line['command'] ? '[CMD]: ' : '') . trim($line['line']); + $searchableContent = $line['timestamp'] . ' ' . $lineContent; + @endphp +
isset($line['command']) && $line['command'], + 'flex gap-2', + ])> + {{ $line['timestamp'] }} + $line['hidden'], + 'text-red-500' => $line['stderr'], + 'font-bold' => isset($line['command']) && $line['command'], + 'whitespace-pre-wrap', + ]) + x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"> +
+ @empty + No logs yet. + @endforelse +
+
-
-
+
\ No newline at end of file diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index d1a331d1a..8cf46d2f3 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -241,12 +241,32 @@ @else
@endcan -
- +
+ + wire:model.defer="dockerComposeLocation" label="Docker Compose Location" + helper="It is calculated together with the Base Directory:
{{ Str::start($application->base_directory . $application->docker_compose_location, '/') }}" + x-model="composeLocation" @blur="normalizeComposeLocation()" />
@else -
- +
+ @if ($application->build_pack === 'dockerfile' && !$application->dockerfile) - + x-bind:disabled="!canUpdate" x-model="dockerfileLocation" @blur="normalizeDockerfileLocation()" /> @endif @if ($application->build_pack === 'dockerfile') diff --git a/resources/views/livewire/project/application/rollback.blade.php b/resources/views/livewire/project/application/rollback.blade.php index e0b1465dc..476712842 100644 --- a/resources/views/livewire/project/application/rollback.blade.php +++ b/resources/views/livewire/project/application/rollback.blade.php @@ -5,25 +5,51 @@ Reload Available Images @endcan
-
You can easily rollback to a previously built (local) images - quickly.
+
You can easily rollback to a previously built (local) images quickly.
+ + @if($serverRetentionDisabled) + + Image retention is disabled at the server level. This setting has no effect until the server administrator enables it. + + @endif + +
+
+ + Save + +
@forelse ($images as $image)
-
+
+ @php + $tag = data_get($image, 'tag'); + $date = data_get($image, 'created_at'); + $interval = \Illuminate\Support\Carbon::parse($date); + // Check if tag looks like a commit SHA (hex string) or PR tag (pr-N) + $isCommitSha = preg_match('/^[0-9a-f]{7,128}$/i', $tag); + $isPrTag = preg_match('/^pr-\d+$/', $tag); + $isRollbackable = $isCommitSha || $isPrTag; + @endphp
@if (data_get($image, 'is_current')) LIVE | @endif - SHA: {{ data_get($image, 'tag') }} + @if ($isCommitSha) + SHA: {{ $tag }} + @elseif ($isPrTag) + PR: {{ $tag }} + @else + Tag: {{ $tag }} + @endif
- @php - $date = data_get($image, 'created_at'); - $interval = \Illuminate\Support\Carbon::parse($date); - @endphp
{{ $interval->diffForHumans() }}
{{ $date }}
@@ -33,9 +59,13 @@ Rollback + @elseif (!$isRollbackable) + + Rollback + @else + wire:click="rollbackImage('{{ $tag }}')"> Rollback @endif @@ -49,4 +79,4 @@
Loading available docker images...
-
+
\ No newline at end of file diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 9017f7c09..2010e0afc 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -66,7 +66,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index 855a2ecdc..2b2e5d355 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -103,7 +103,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index a4b0eb471..e1a121b4e 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -103,7 +103,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php index b428c3144..eba0deafa 100644 --- a/resources/views/livewire/project/database/mariadb/general.blade.php +++ b/resources/views/livewire/project/database/mariadb/general.blade.php @@ -127,7 +127,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index 871ac55c4..6cdd7b81f 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -141,7 +141,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index 512a3eb1b..04af62d27 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -144,7 +144,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 9c378a33f..509efe993 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -152,7 +152,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 7ffc8f218..39af4709e 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -122,7 +122,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 6d644ba2c..43b54db9c 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -7,7 +7,7 @@
@forelse ($private_keys as $key) @if ($private_key_id == $key->id) -
@@ -20,7 +20,7 @@ class="loading loading-xs dark:text-warning loading-spinner">
@else -
@@ -61,12 +61,33 @@ class="loading loading-xs dark:text-warning loading-spinner"> @endif
@if ($build_pack === 'dockercompose') -
- - + + + x-model="composeLocation" @blur="normalizeComposeLocation()" />
Compose file location in your repository: @foreach ($github_apps as $ghapp)
-
@@ -95,15 +95,35 @@ @endif
@if ($build_pack === 'dockercompose') -
- + + helper="Directory to use as root. Useful for monorepos." x-model="baseDir" + @blur="normalizeBaseDir()" /> + x-model="composeLocation" @blur="normalizeComposeLocation()" />
Compose file location in your repository: @if ($build_pack === 'dockercompose') -
- - + + + x-model="composeLocation" @blur="normalizeComposeLocation()" />
Compose file location in your repository:
-
@@ -460,17 +460,18 @@ function searchResources() { PostgreSQL is a powerful, open-source object-relational database system (no extensions).
-
- - + + + + +
-
@@ -480,16 +481,18 @@ function searchResources() { Supabase is a modern, open-source alternative to PostgreSQL with lots of extensions.
-
- + + + + +
-
@@ -499,16 +502,18 @@ function searchResources() { PostGIS is a PostgreSQL extension for geographic objects.
-
- + + + + +
-
@@ -518,15 +523,16 @@ function searchResources() { PGVector is a PostgreSQL extension for vector data types.
-
- - + + + + +
diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index 9b81e4bec..7379ca706 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -37,6 +37,16 @@

Services

+ @if($applications->isEmpty() && $databases->isEmpty()) +
+ No services defined in this Docker Compose file. +
+ @elseif($applications->isEmpty()) +
+ No applications with domains defined. Only database services are available. +
+ @endif + @foreach ($applications as $application)
str( diff --git a/resources/views/livewire/project/service/service-application-view.blade.php b/resources/views/livewire/project/service/service-application-view.blade.php index 5fb4a62d0..f04e33817 100644 --- a/resources/views/livewire/project/service/service-application-view.blade.php +++ b/resources/views/livewire/project/service/service-application-view.blade.php @@ -56,18 +56,18 @@

Advanced

@if (str($application->image)->contains('pocketbase')) - @else - @endif - - -
+
{ - const screen = document.getElementById('screen'); - const logs = document.getElementById('logs'); - if (screen.scrollTop !== logs.scrollHeight) { - screen.scrollTop = logs.scrollHeight; + const logsContainer = document.getElementById('logsContainer'); + if (logsContainer) { + this.isScrolling = true; + logsContainer.scrollTop = logsContainer.scrollHeight; + setTimeout(() => { this.isScrolling = false; }, 50); } }, 100); } else { @@ -26,63 +34,258 @@ this.intervalId = null; } }, - goTop() { - this.alwaysScroll = false; - clearInterval(this.intervalId); - const screen = document.getElementById('screen'); - screen.scrollTop = 0; + handleScroll(event) { + if (!this.alwaysScroll || this.isScrolling) return; + const el = event.target; + // Check if user scrolled away from the bottom + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceFromBottom > 50) { + this.alwaysScroll = false; + clearInterval(this.intervalId); + this.intervalId = null; + } + }, + toggleColorLogs() { + this.colorLogs = !this.colorLogs; + localStorage.setItem('coolify-color-logs', this.colorLogs); + }, + getLogLevel(text) { + const lowerText = text.toLowerCase(); + // Error detection (highest priority) + if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(lowerText)) { + return 'error'; + } + // Warning detection + if (/\b(warn|warning|wrn|caution)\b/.test(lowerText)) { + return 'warning'; + } + // Debug detection + if (/\b(debug|dbg|trace|verbose)\b/.test(lowerText)) { + return 'debug'; + } + // Info detection + if (/\b(info|inf|notice)\b/.test(lowerText)) { + return 'info'; + } + return null; + }, + matchesSearch(line) { + if (!this.searchQuery.trim()) return true; + return line.toLowerCase().includes(this.searchQuery.toLowerCase()); + }, + decodeHtml(text) { + // Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS + let decoded = text; + let prev = ''; + let iterations = 0; + const maxIterations = 3; // Prevent DoS from deeply nested HTML entities + + while (decoded !== prev && iterations < maxIterations) { + prev = decoded; + const doc = new DOMParser().parseFromString(decoded, 'text/html'); + decoded = doc.documentElement.textContent; + iterations++; + } + return decoded; + }, + renderHighlightedLog(el, text) { + const decoded = this.decodeHtml(text); + el.textContent = ''; + + if (!this.searchQuery.trim()) { + el.textContent = decoded; + return; + } + + const query = this.searchQuery.toLowerCase(); + const lowerText = decoded.toLowerCase(); + let lastIndex = 0; + + let index = lowerText.indexOf(query, lastIndex); + while (index !== -1) { + // Add text before match + if (index > lastIndex) { + el.appendChild(document.createTextNode(decoded.substring(lastIndex, index))); + } + // Add highlighted match + const mark = document.createElement('span'); + mark.className = 'log-highlight'; + mark.textContent = decoded.substring(index, index + this.searchQuery.length); + el.appendChild(mark); + + lastIndex = index + this.searchQuery.length; + index = lowerText.indexOf(query, lastIndex); + } + + // Add remaining text + if (lastIndex < decoded.length) { + el.appendChild(document.createTextNode(decoded.substring(lastIndex))); + } + }, + getMatchCount() { + if (!this.searchQuery.trim()) return 0; + const logs = document.getElementById('logs'); + if (!logs) return 0; + const lines = logs.querySelectorAll('[data-log-line]'); + let count = 0; + lines.forEach(line => { + if (line.textContent.toLowerCase().includes(this.searchQuery.toLowerCase())) { + count++; + } + }); + return count; + }, + downloadLogs() { + const logs = document.getElementById('logs'); + if (!logs) return; + const visibleLines = logs.querySelectorAll('[data-log-line]:not(.hidden)'); + let content = ''; + visibleLines.forEach(line => { + const text = line.textContent.replace(/\s+/g, ' ').trim(); + if (text) { + content += text + String.fromCharCode(10); + } + }); + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-'); + a.download = this.containerName + '-logs-' + timestamp + '.txt'; + a.click(); + URL.revokeObjectURL(url); + }, + init() { + if (this.expanded) { + this.$wire.getLogs(); + this.logsLoaded = true; + } + // Re-render logs after Livewire updates + Livewire.hook('commit', ({ succeed }) => { + succeed(() => { + this.$nextTick(() => { this.renderTrigger++; }); + }); + }); } }"> -
- @if ($displayName) -

{{ $displayName }}

- @elseif ($resource?->type() === 'application' || str($resource?->type())->startsWith('standalone')) -

{{ $container }}

- @else -

{{ str($container)->beforeLast('-')->headline() }}

- @endif - @if ($pull_request) -
({{ $pull_request }})
- @endif - @if ($streamLogs) - - @endif -
-
-
- + @if ($collapsible) +
+ + + + @if ($displayName) +

{{ $displayName }}

+ @elseif ($resource?->type() === 'application' || str($resource?->type())->startsWith('standalone')) +

{{ $container }}

+ @else +

{{ str($container)->beforeLast('-')->headline() }}

+ @endif + @if ($pull_request) +
({{ $pull_request }})
+ @endif + @if ($streamLogs) + + @endif
-
- Refresh - - -
- -
-
-
-
- {{-- +
+ -
- @if ($outputs) -
- @foreach (explode("\n", trim($outputs)) as $line) - @if (!empty(trim($line))) +
+ @if ($outputs) +
+
+ No matches found. +
+ @foreach (explode("\n", $outputs) as $line) @php - $lowerLine = strtolower($line); - $isError = - str_contains($lowerLine, 'error') || - str_contains($lowerLine, 'err') || - str_contains($lowerLine, 'failed') || - str_contains($lowerLine, 'exception'); - $isWarning = - str_contains($lowerLine, 'warn') || - str_contains($lowerLine, 'warning') || - str_contains($lowerLine, 'wrn'); - $isDebug = - str_contains($lowerLine, 'debug') || - str_contains($lowerLine, 'dbg') || - str_contains($lowerLine, 'trace'); - $barColor = $isError - ? 'bg-red-500 dark:bg-red-400' - : ($isWarning - ? 'bg-warning-500 dark:bg-warning-400' - : ($isDebug - ? 'bg-purple-500 dark:bg-purple-400' - : 'bg-blue-500 dark:bg-blue-400')); - $bgColor = $isError - ? 'bg-red-50/50 dark:bg-red-900/20 hover:bg-red-100/50 dark:hover:bg-red-800/30' - : ($isWarning - ? 'bg-warning-50/50 dark:bg-warning-900/20 hover:bg-warning-100/50 dark:hover:bg-warning-800/30' - : ($isDebug - ? 'bg-purple-50/50 dark:bg-purple-900/20 hover:bg-purple-100/50 dark:hover:bg-purple-800/30' - : 'bg-blue-50/50 dark:bg-blue-900/20 hover:bg-blue-100/50 dark:hover:bg-blue-800/30')); + // Skip empty lines + if (trim($line) === '') { + continue; + } + + // Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z) + $timestamp = ''; + $logContent = $line; + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z?\s*(.*)$/', $line, $matches)) { + $year = $matches[1]; + $month = $matches[2]; + $day = $matches[3]; + $time = $matches[4]; + $microseconds = isset($matches[5]) ? substr($matches[5], 0, 6) : '000000'; + $logContent = $matches[6]; + + // Convert month number to abbreviated name + $monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + $monthName = $monthNames[(int)$month - 1] ?? $month; + + // Format: 2025-Dec-04 09:44:58.198879 + $timestamp = "{$year}-{$monthName}-{$day} {$time}.{$microseconds}"; + } - // Check for timestamp at the beginning (ISO 8601 format) - $timestampPattern = '/^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?)\s+/'; - $hasTimestamp = preg_match($timestampPattern, $line, $matches); - $timestamp = $hasTimestamp ? $matches[1] : null; - $logContent = $hasTimestamp ? preg_replace($timestampPattern, '', $line) : $line; @endphp -
-
-
- @if ($hasTimestamp) - {{ $timestamp }} - {{ $logContent }} - @else - {{ $line }} - @endif -
+
+ @if ($timestamp && $showTimeStamps) + {{ $timestamp }} + @endif +
- @endif - @endforeach -
- @else -
- Refresh to get the logs... -
- @endif + @endforeach +
+ @else +
Refresh to get the logs...
+ @endif +
-
+
\ No newline at end of file diff --git a/resources/views/livewire/project/shared/logs.blade.php b/resources/views/livewire/project/shared/logs.blade.php index 87bb1a6b6..3a1afaa1c 100644 --- a/resources/views/livewire/project/shared/logs.blade.php +++ b/resources/views/livewire/project/shared/logs.blade.php @@ -17,13 +17,17 @@
@forelse ($servers as $server)
-

Server: {{ $server->name }}

+

Server: {{ $server->name }}

@if ($server->isFunctional()) @if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0) + @php + $totalContainers = collect($serverContainers)->flatten(1)->count(); + @endphp @foreach ($serverContainers[$server->id] as $container) + :resource="$resource" :container="data_get($container, 'Names')" + :expandByDefault="$totalContainers === 1" /> @endforeach @else
No containers are running on server: {{ $server->name }}
@@ -53,7 +57,8 @@ @forelse ($containers as $container) @if (data_get($servers, '0')) + :resource="$resource" :container="$container" + :expandByDefault="count($containers) === 1" /> @else
No functional server found for the database.
@endif @@ -77,7 +82,8 @@ @forelse ($containers as $container) @if (data_get($servers, '0')) + :resource="$resource" :container="$container" + :expandByDefault="count($containers) === 1" /> @else
No functional server found for the service.
@endif diff --git a/resources/views/livewire/server/docker-cleanup.blade.php b/resources/views/livewire/server/docker-cleanup.blade.php index 8e96bc963..251137fa7 100644 --- a/resources/views/livewire/server/docker-cleanup.blade.php +++ b/resources/views/livewire/server/docker-cleanup.blade.php @@ -78,6 +78,10 @@
  • Networks not attached to running containers will be permanently deleted (networks used by stopped containers are affected).
  • Containers may lose connectivity if required networks are removed.
  • " /> +
    diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index 8525f5d60..4f43ef7e2 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -2,6 +2,13 @@ Proxy Startup Logs + @if ($server->id === 0) +
    + Note: This is the localhost server where Coolify runs. + During proxy restart, the connection may be temporarily lost. + If logs stop updating, please refresh the browser after a few minutes. +
    + @endif
    @@ -174,6 +181,7 @@ class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar } }); $wire.$on('restartEvent', () => { + if ($wire.restartInitiated) return; window.dispatchEvent(new CustomEvent('startproxy')) $wire.$call('restart'); }); diff --git a/resources/views/livewire/server/proxy/logs.blade.php b/resources/views/livewire/server/proxy/logs.blade.php index 17fe65f4c..8b72d78f1 100644 --- a/resources/views/livewire/server/proxy/logs.blade.php +++ b/resources/views/livewire/server/proxy/logs.blade.php @@ -7,7 +7,7 @@

    Logs

    - +
    diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index b3284ab51..f9311bb83 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -337,7 +337,8 @@ class="w-full input opacity-50 cursor-not-allowed" Sentinel Logs + container="coolify-sentinel" displayName="Sentinel" :collapsible="false" + lazy /> Logs @@ -353,7 +354,8 @@ class="w-full input opacity-50 cursor-not-allowed" Sentinel Logs + container="coolify-sentinel" displayName="Sentinel" :collapsible="false" + lazy /> Logs diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index c47c2cfef..7d714a409 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -18,28 +18,28 @@ class="flex flex-col h-full gap-8 sm:flex-row">

    DNS Settings

    API Settings

    + helper="If enabled, authenticated requests to Coolify's REST API will be allowed. Configure API tokens in Security > API Tokens." />

    Confirmation Settings

    + helper="Show monthly sponsorship reminders to support Coolify development. Disable to hide these messages permanently." />
    diff --git a/scripts/install.sh b/scripts/install.sh index c8b791185..c06b26eff 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -223,7 +223,7 @@ if [ "$WARNING_SPACE" = true ]; then sleep 5 fi -mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,sentinel} +mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} mkdir -p /data/coolify/ssh/{keys,mux} mkdir -p /data/coolify/proxy/dynamic diff --git a/templates/compose/fizzy.yaml b/templates/compose/fizzy.yaml new file mode 100644 index 000000000..8265d09be --- /dev/null +++ b/templates/compose/fizzy.yaml @@ -0,0 +1,30 @@ +# documentation: https://github.com/basecamp/fizzy +# slogan: Kanban tracking tool for issues and ideas by 37signals +# category: productivity +# tags: kanban, project management, issues, rails, ruby, basecamp, 37signals +# logo: svgs/fizzy.png +# port: 80 + +services: + fizzy: + image: ghcr.io/basecamp/fizzy:main + environment: + - SERVICE_FQDN_FIZZY_80 + - SECRET_KEY_BASE=$SERVICE_PASSWORD_FIZZY + - RAILS_MASTER_KEY=$SERVICE_PASSWORD_64_MASTERKEY + - RAILS_ENV=production + - RAILS_LOG_TO_STDOUT=true + - RAILS_SERVE_STATIC_FILES=true + - DATABASE_ADAPTER=sqlite + - SOLID_QUEUE_CONNECTS_TO=false + - VAPID_PRIVATE_KEY=$VAPID_PRIVATE_KEY + - VAPID_PUBLIC_KEY=$VAPID_PUBLIC_KEY + volumes: + - fizzy-data:/rails/db + - fizzy-storage:/rails/storage + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:80/up"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s diff --git a/templates/compose/garage.yaml b/templates/compose/garage.yaml new file mode 100644 index 000000000..493ff53bf --- /dev/null +++ b/templates/compose/garage.yaml @@ -0,0 +1,59 @@ +# ignore: true +# documentation: https://garagehq.deuxfleurs.fr/documentation/ +# slogan: Garage is an S3-compatible distributed object storage service designed for self-hosting. +# category: storage +# tags: object, storage, server, s3, api, distributed +# logo: svgs/garage.svg +# port: 3900 + +services: + garage: + image: dxflrs/garage:v2.1.0 + environment: + - GARAGE_S3_API_URL=$GARAGE_S3_API_URL + - GARAGE_WEB_URL=$GARAGE_WEB_URL + - GARAGE_ADMIN_URL=$GARAGE_ADMIN_URL + - GARAGE_RPC_SECRET=${SERVICE_HEX_32_RPCSECRET} + - GARAGE_ADMIN_TOKEN=$SERVICE_PASSWORD_GARAGE + - GARAGE_METRICS_TOKEN=$SERVICE_PASSWORD_GARAGEMETRICS + - GARAGE_ALLOW_WORLD_READABLE_SECRETS=true + - RUST_LOG=${RUST_LOG:-garage=info} + volumes: + - garage-meta:/var/lib/garage/meta + - garage-data:/var/lib/garage/data + - type: bind + source: ./garage.toml + target: /etc/garage.toml + content: | + metadata_dir = "/var/lib/garage/meta" + data_dir = "/var/lib/garage/data" + db_engine = "lmdb" + + replication_factor = 1 + consistency_mode = "consistent" + + compression_level = 1 + block_size = "1M" + + rpc_bind_addr = "[::]:3901" + rpc_secret_file = "env:GARAGE_RPC_SECRET" + bootstrap_peers = [] + + [s3_api] + s3_region = "garage" + api_bind_addr = "[::]:3900" + root_domain = ".s3.garage.localhost" + + [s3_web] + bind_addr = "[::]:3902" + root_domain = ".web.garage.localhost" + + [admin] + api_bind_addr = "[::]:3903" + admin_token_file = "env:GARAGE_ADMIN_TOKEN" + metrics_token_file = "env:GARAGE_METRICS_TOKEN" + healthcheck: + test: ["CMD", "/garage", "stats", "-a"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/templates/compose/rustfs.yaml b/templates/compose/rustfs.yaml new file mode 100644 index 000000000..0ae4a14db --- /dev/null +++ b/templates/compose/rustfs.yaml @@ -0,0 +1,35 @@ +# ignore: true +# documentation: https://docs.rustfs.com/installation/docker/ +# slogan: RustFS is a high-performance distributed storage system built with Rust, compatible with Amazon S3 APIs. +# category: storage +# tags: object, storage, server, s3, api, rust +# logo: svgs/rustfs.png + +services: + rustfs: + image: rustfs/rustfs:latest + command: /data + environment: + - RUSTFS_SERVER_URL=$RUSTFS_SERVER_URL + - RUSTFS_BROWSER_REDIRECT_URL=$RUSTFS_BROWSER_REDIRECT_URL + - RUSTFS_ADDRESS=${RUSTFS_ADDRESS:-0.0.0.0:9000} + - RUSTFS_CONSOLE_ADDRESS=${RUSTFS_CONSOLE_ADDRESS:-0.0.0.0:9001} + - RUSTFS_CORS_ALLOWED_ORIGINS=${RUSTFS_CORS_ALLOWED_ORIGINS:-*} + - RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=${RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS:-*} + - RUSTFS_ACCESS_KEY=$SERVICE_USER_RUSTFS + - RUSTFS_SECRET_KEY=$SERVICE_PASSWORD_RUSTFS + - RUSTFS_CONSOLE_ENABLE=${RUSTFS_CONSOLE_ENABLE:-true} + - RUSTFS_SERVER_DOMAINS=${RUSTFS_SERVER_DOMAINS} + - RUSTFS_EXTERNAL_ADDRESS=${RUSTFS_EXTERNAL_ADDRESS} + volumes: + - rustfs-data:/data + healthcheck: + test: + [ + "CMD", + "sh", "-c", + "curl -f http://127.0.0.1:9000/health && curl -f http://127.0.0.1:9001/rustfs/console/health" + ] + interval: 5s + timeout: 20s + retries: 10 \ No newline at end of file diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 063556a14..bc46c3c5d 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -1146,6 +1146,24 @@ "minversion": "0.0.0", "port": "5800" }, + "fizzy": { + "documentation": "https://github.com/basecamp/fizzy?utm_source=coolify.io", + "slogan": "Kanban tracking tool for issues and ideas by 37signals", + "compose": "c2VydmljZXM6CiAgZml6enk6CiAgICBpbWFnZTogJ2doY3IuaW8vYmFzZWNhbXAvZml6enk6bWFpbicKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9GSVpaWV84MAogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9QQVNTV09SRF9GSVpaWQogICAgICAtIFJBSUxTX01BU1RFUl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfTUFTVEVSS0VZCiAgICAgIC0gUkFJTFNfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBSQUlMU19MT0dfVE9fU1RET1VUPXRydWUKICAgICAgLSBSQUlMU19TRVJWRV9TVEFUSUNfRklMRVM9dHJ1ZQogICAgICAtIERBVEFCQVNFX0FEQVBURVI9c3FsaXRlCiAgICAgIC0gU09MSURfUVVFVUVfQ09OTkVDVFNfVE89ZmFsc2UKICAgICAgLSBWQVBJRF9QUklWQVRFX0tFWT0kVkFQSURfUFJJVkFURV9LRVkKICAgICAgLSBWQVBJRF9QVUJMSUNfS0VZPSRWQVBJRF9QVUJMSUNfS0VZCiAgICB2b2x1bWVzOgogICAgICAtICdmaXp6eS1kYXRhOi9yYWlscy9kYicKICAgICAgLSAnZml6enktc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MC91cCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCg==", + "tags": [ + "kanban", + "project management", + "issues", + "rails", + "ruby", + "basecamp", + "37signals" + ], + "category": "productivity", + "logo": "svgs/fizzy.png", + "minversion": "0.0.0", + "port": "80" + }, "flipt": { "documentation": "https://docs.flipt.io/cloud/overview?utm_source=coolify.io", "slogan": "Flipt is a fully managed feature flag solution that enables you to keep your feature flags and remote config next to your code in Git.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 398a23e42..7536800a0 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -1146,6 +1146,24 @@ "minversion": "0.0.0", "port": "5800" }, + "fizzy": { + "documentation": "https://github.com/basecamp/fizzy?utm_source=coolify.io", + "slogan": "Kanban tracking tool for issues and ideas by 37signals", + "compose": "c2VydmljZXM6CiAgZml6enk6CiAgICBpbWFnZTogJ2doY3IuaW8vYmFzZWNhbXAvZml6enk6bWFpbicKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9GSVpaWV84MAogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9QQVNTV09SRF9GSVpaWQogICAgICAtIFJBSUxTX01BU1RFUl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfTUFTVEVSS0VZCiAgICAgIC0gUkFJTFNfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBSQUlMU19MT0dfVE9fU1RET1VUPXRydWUKICAgICAgLSBSQUlMU19TRVJWRV9TVEFUSUNfRklMRVM9dHJ1ZQogICAgICAtIERBVEFCQVNFX0FEQVBURVI9c3FsaXRlCiAgICAgIC0gU09MSURfUVVFVUVfQ09OTkVDVFNfVE89ZmFsc2UKICAgICAgLSBWQVBJRF9QUklWQVRFX0tFWT0kVkFQSURfUFJJVkFURV9LRVkKICAgICAgLSBWQVBJRF9QVUJMSUNfS0VZPSRWQVBJRF9QVUJMSUNfS0VZCiAgICB2b2x1bWVzOgogICAgICAtICdmaXp6eS1kYXRhOi9yYWlscy9kYicKICAgICAgLSAnZml6enktc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MC91cCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCg==", + "tags": [ + "kanban", + "project management", + "issues", + "rails", + "ruby", + "basecamp", + "37signals" + ], + "category": "productivity", + "logo": "svgs/fizzy.png", + "minversion": "0.0.0", + "port": "80" + }, "flipt": { "documentation": "https://docs.flipt.io/cloud/overview?utm_source=coolify.io", "slogan": "Flipt is a fully managed feature flag solution that enables you to keep your feature flags and remote config next to your code in Git.", diff --git a/tests/Feature/CheckTraefikVersionJobTest.php b/tests/Feature/CheckTraefikVersionJobTest.php index b7c5dd50d..cee156485 100644 --- a/tests/Feature/CheckTraefikVersionJobTest.php +++ b/tests/Feature/CheckTraefikVersionJobTest.php @@ -214,3 +214,90 @@ expect($notification->servers)->toHaveCount(1); expect($notification->servers->first()->outdatedInfo['type'])->toBe('patch_update'); }); + +it('notification generates correct server proxy URLs', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'name' => 'Test Server', + 'team_id' => $team->id, + 'uuid' => 'test-uuid-123', + ]); + + $server->outdatedInfo = [ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + ]; + + $notification = new TraefikVersionOutdated(collect([$server])); + $mail = $notification->toMail($team); + + // Verify the mail has the transformed servers with URLs + expect($mail->viewData['servers'])->toHaveCount(1); + expect($mail->viewData['servers'][0]['name'])->toBe('Test Server'); + expect($mail->viewData['servers'][0]['uuid'])->toBe('test-uuid-123'); + expect($mail->viewData['servers'][0]['url'])->toBe(base_url().'/server/test-uuid-123/proxy'); + expect($mail->viewData['servers'][0]['outdatedInfo'])->toBeArray(); +}); + +it('notification transforms multiple servers with URLs correctly', function () { + $team = Team::factory()->create(); + $server1 = Server::factory()->create([ + 'name' => 'Server 1', + 'team_id' => $team->id, + 'uuid' => 'uuid-1', + ]); + $server1->outdatedInfo = [ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + ]; + + $server2 = Server::factory()->create([ + 'name' => 'Server 2', + 'team_id' => $team->id, + 'uuid' => 'uuid-2', + ]); + $server2->outdatedInfo = [ + 'current' => '3.4.0', + 'latest' => '3.6.0', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.6', + ]; + + $servers = collect([$server1, $server2]); + $notification = new TraefikVersionOutdated($servers); + $mail = $notification->toMail($team); + + // Verify both servers have URLs + expect($mail->viewData['servers'])->toHaveCount(2); + + expect($mail->viewData['servers'][0]['name'])->toBe('Server 1'); + expect($mail->viewData['servers'][0]['url'])->toBe(base_url().'/server/uuid-1/proxy'); + + expect($mail->viewData['servers'][1]['name'])->toBe('Server 2'); + expect($mail->viewData['servers'][1]['url'])->toBe(base_url().'/server/uuid-2/proxy'); +}); + +it('notification uses base_url helper not config app.url', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'name' => 'Test Server', + 'team_id' => $team->id, + 'uuid' => 'test-uuid', + ]); + + $server->outdatedInfo = [ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + ]; + + $notification = new TraefikVersionOutdated(collect([$server])); + $mail = $notification->toMail($team); + + // Verify URL starts with base_url() not config('app.url') + $generatedUrl = $mail->viewData['servers'][0]['url']; + expect($generatedUrl)->toStartWith(base_url()); + expect($generatedUrl)->not->toContain('localhost'); +}); diff --git a/tests/Feature/Proxy/RestartProxyTest.php b/tests/Feature/Proxy/RestartProxyTest.php new file mode 100644 index 000000000..5771a58f7 --- /dev/null +++ b/tests/Feature/Proxy/RestartProxyTest.php @@ -0,0 +1,139 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(['name' => 'Test Team']); + $this->user->teams()->attach($this->team); + + // Create test server + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Test Server', + 'ip' => '192.168.1.100', + ]); + + // Authenticate user + $this->actingAs($this->user); + } + + public function test_restart_dispatches_job_for_all_servers() + { + Queue::fake(); + + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart'); + + // Assert job was dispatched + Queue::assertPushed(RestartProxyJob::class, function ($job) { + return $job->server->id === $this->server->id; + }); + } + + public function test_restart_dispatches_job_for_localhost_server() + { + Queue::fake(); + + // Create localhost server (id = 0) + $localhostServer = Server::factory()->create([ + 'id' => 0, + 'team_id' => $this->team->id, + 'name' => 'Localhost', + 'ip' => 'host.docker.internal', + ]); + + Livewire::test('server.navbar', ['server' => $localhostServer]) + ->call('restart'); + + // Assert job was dispatched + Queue::assertPushed(RestartProxyJob::class, function ($job) use ($localhostServer) { + return $job->server->id === $localhostServer->id; + }); + } + + public function test_restart_shows_info_message() + { + Queue::fake(); + + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart') + ->assertDispatched('info', 'Proxy restart initiated. Monitor progress in activity logs.'); + } + + public function test_unauthorized_user_cannot_restart_proxy() + { + Queue::fake(); + + // Create another user without access + $unauthorizedUser = User::factory()->create(); + $this->actingAs($unauthorizedUser); + + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart') + ->assertForbidden(); + + // Assert job was NOT dispatched + Queue::assertNotPushed(RestartProxyJob::class); + } + + public function test_restart_prevents_concurrent_jobs_via_without_overlapping() + { + Queue::fake(); + + // Dispatch job twice + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart'); + + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart'); + + // Assert job was pushed twice (WithoutOverlapping middleware will handle deduplication) + Queue::assertPushed(RestartProxyJob::class, 2); + + // Get the jobs + $jobs = Queue::pushed(RestartProxyJob::class); + + // Verify both jobs have WithoutOverlapping middleware + foreach ($jobs as $job) { + $middleware = $job['job']->middleware(); + $this->assertCount(1, $middleware); + $this->assertInstanceOf(\Illuminate\Queue\Middleware\WithoutOverlapping::class, $middleware[0]); + } + } + + public function test_restart_uses_server_team_id() + { + Queue::fake(); + + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart'); + + Queue::assertPushed(RestartProxyJob::class, function ($job) { + return $job->server->team_id === $this->team->id; + }); + } +} diff --git a/tests/Feature/SentinelUpdateCheckIndependenceTest.php b/tests/Feature/SentinelUpdateCheckIndependenceTest.php new file mode 100644 index 000000000..080a3ee7c --- /dev/null +++ b/tests/Feature/SentinelUpdateCheckIndependenceTest.php @@ -0,0 +1,187 @@ +create(); + $this->team = $user->teams()->first(); + + // Create server with sentinel enabled + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + + // Enable sentinel on the server + $this->server->settings->update([ + 'is_sentinel_enabled' => true, + 'server_timezone' => 'UTC', + ]); + + $this->server->refresh(); +}); + +afterEach(function () { + Carbon::setTestNow(); // Reset frozen time +}); + +it('dispatches sentinel check hourly regardless of instance update_check_frequency setting', function () { + // Set instance update_check_frequency to yearly (most infrequent option) + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'update_check_frequency' => '0 0 1 1 *', // Yearly - January 1st at midnight + 'instance_timezone' => 'UTC', + ]); + + // Set time to top of any hour (sentinel should check every hour) + Carbon::setTestNow('2025-06-15 14:00:00'); // Random hour, not January 1st + + // Run ServerManagerJob + $job = new ServerManagerJob; + $job->handle(); + + // Assert that CheckAndStartSentinelJob was dispatched despite yearly update check frequency + Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) { + return $job->server->id === $this->server->id; + }); +}); + +it('does not dispatch sentinel check when not at top of hour', function () { + // Set instance update_check_frequency to hourly (most frequent) + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'update_check_frequency' => '0 * * * *', // Hourly + 'instance_timezone' => 'UTC', + ]); + + // Set time to middle of the hour (sentinel check cron won't match) + Carbon::setTestNow('2025-06-15 14:30:00'); // 30 minutes past the hour + + // Run ServerManagerJob + $job = new ServerManagerJob; + $job->handle(); + + // Assert that CheckAndStartSentinelJob was NOT dispatched (not top of hour) + Queue::assertNotPushed(CheckAndStartSentinelJob::class); +}); + +it('dispatches sentinel check at every hour mark throughout the day', function () { + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'update_check_frequency' => '0 0 1 1 *', // Yearly + 'instance_timezone' => 'UTC', + ]); + + // Test multiple hours throughout a day + $hoursToTest = [0, 6, 12, 18, 23]; // Various hours of the day + + foreach ($hoursToTest as $hour) { + Queue::fake(); // Reset queue for each test + + Carbon::setTestNow("2025-06-15 {$hour}:00:00"); + + $job = new ServerManagerJob; + $job->handle(); + + Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) { + return $job->server->id === $this->server->id; + }, "Failed to dispatch sentinel check at hour {$hour}"); + } +}); + +it('respects server timezone when checking sentinel updates', function () { + // Update server timezone to America/New_York + $this->server->settings->update([ + 'server_timezone' => 'America/New_York', + ]); + + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'instance_timezone' => 'UTC', + ]); + + // Set time to 17:00 UTC which is 12:00 PM EST (top of hour in server's timezone) + Carbon::setTestNow('2025-01-15 17:00:00'); + + $job = new ServerManagerJob; + $job->handle(); + + // Should dispatch because it's top of hour in server's timezone (America/New_York) + Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) { + return $job->server->id === $this->server->id; + }); +}); + +it('does not dispatch sentinel check for servers without sentinel enabled', function () { + // Disable sentinel + $this->server->settings->update([ + 'is_sentinel_enabled' => false, + ]); + + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'update_check_frequency' => '0 * * * *', + 'instance_timezone' => 'UTC', + ]); + + Carbon::setTestNow('2025-06-15 14:00:00'); + + $job = new ServerManagerJob; + $job->handle(); + + // Should NOT dispatch because sentinel is disabled + Queue::assertNotPushed(CheckAndStartSentinelJob::class); +}); + +it('handles multiple servers with different sentinel configurations', function () { + // Create a second server with sentinel disabled + $server2 = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + $server2->settings->update([ + 'is_sentinel_enabled' => false, + 'server_timezone' => 'UTC', + ]); + + // Create a third server with sentinel enabled + $server3 = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + $server3->settings->update([ + 'is_sentinel_enabled' => true, + 'server_timezone' => 'UTC', + ]); + + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'instance_timezone' => 'UTC', + ]); + + Carbon::setTestNow('2025-06-15 14:00:00'); + + $job = new ServerManagerJob; + $job->handle(); + + // Should dispatch for server1 (sentinel enabled) and server3 (sentinel enabled) + Queue::assertPushed(CheckAndStartSentinelJob::class, 2); + + // Verify it was dispatched for the correct servers + Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) { + return $job->server->id === $this->server->id; + }); + + Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server3) { + return $job->server->id === $server3->id; + }); +}); diff --git a/tests/Feature/ServerStorageCheckIndependenceTest.php b/tests/Feature/ServerStorageCheckIndependenceTest.php new file mode 100644 index 000000000..57b392e2f --- /dev/null +++ b/tests/Feature/ServerStorageCheckIndependenceTest.php @@ -0,0 +1,186 @@ +create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now(), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 23 * * *', + 'server_timezone' => 'UTC', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should NOT be dispatched (Sentinel handles it via PushServerUpdateJob) + Queue::assertNotPushed(ServerStorageCheckJob::class); +}); + +it('dispatches storage check when sentinel is out of sync', function () { + // When: ServerManagerJob runs at 11 PM + Carbon::setTestNow(Carbon::parse('2025-01-15 23:00:00', 'UTC')); + + // Given: A server with Sentinel out of sync (last update 10 minutes ago) + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now()->subMinutes(10), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 23 * * *', + 'server_timezone' => 'UTC', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: Both ServerCheckJob and ServerStorageCheckJob should be dispatched + Queue::assertPushed(ServerCheckJob::class); + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('dispatches storage check when sentinel is disabled', function () { + // When: ServerManagerJob runs at 11 PM + Carbon::setTestNow(Carbon::parse('2025-01-15 23:00:00', 'UTC')); + + // Given: A server with Sentinel disabled + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now()->subHours(24), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 23 * * *', + 'server_timezone' => 'UTC', + 'is_metrics_enabled' => false, + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should be dispatched + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('respects custom hourly storage check frequency when sentinel is out of sync', function () { + // When: ServerManagerJob runs at the top of the hour (23:00) + Carbon::setTestNow(Carbon::parse('2025-01-15 23:00:00', 'UTC')); + + // Given: A server with hourly storage check frequency and Sentinel out of sync + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now()->subMinutes(10), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 * * * *', + 'server_timezone' => 'UTC', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should be dispatched + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('handles VALID_CRON_STRINGS mapping correctly when sentinel is out of sync', function () { + // When: ServerManagerJob runs at the top of the hour + Carbon::setTestNow(Carbon::parse('2025-01-15 23:00:00', 'UTC')); + + // Given: A server with 'hourly' string (should be converted to '0 * * * *') and Sentinel out of sync + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now()->subMinutes(10), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => 'hourly', + 'server_timezone' => 'UTC', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should be dispatched (hourly was converted to cron) + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('respects server timezone for storage checks when sentinel is out of sync', function () { + // When: ServerManagerJob runs at 11 PM New York time (4 AM UTC next day) + Carbon::setTestNow(Carbon::parse('2025-01-16 04:00:00', 'UTC')); + + // Given: A server in America/New_York timezone (UTC-5) configured for 11 PM local time and Sentinel out of sync + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now()->subMinutes(10), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 23 * * *', + 'server_timezone' => 'America/New_York', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should be dispatched + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('does not dispatch storage check outside schedule', function () { + // When: ServerManagerJob runs at 10 PM (not 11 PM) + Carbon::setTestNow(Carbon::parse('2025-01-15 22:00:00', 'UTC')); + + // Given: A server with daily storage check at 11 PM + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now(), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 23 * * *', + 'server_timezone' => 'UTC', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should NOT be dispatched + Queue::assertNotPushed(ServerStorageCheckJob::class); +}); diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php new file mode 100644 index 000000000..ebf73da06 --- /dev/null +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -0,0 +1,253 @@ + 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'], + ['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'], + ['repository' => 'app-uuid', 'tag' => 'pr-123', 'created_at' => '2024-01-03', 'image_ref' => 'app-uuid:pr-123'], + ['repository' => 'app-uuid', 'tag' => 'pr-456', 'created_at' => '2024-01-04', 'image_ref' => 'app-uuid:pr-456'], + ['repository' => 'app-uuid', 'tag' => 'abc123-build', 'created_at' => '2024-01-05', 'image_ref' => 'app-uuid:abc123-build'], + ['repository' => 'app-uuid', 'tag' => 'def456-build', 'created_at' => '2024-01-06', 'image_ref' => 'app-uuid:def456-build'], + ]); + + // PR images (tags starting with 'pr-') + $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + expect($prImages)->toHaveCount(2); + expect($prImages->pluck('tag')->toArray())->toContain('pr-123', 'pr-456'); + + // Regular images (neither PR nor build) - these are subject to retention policy + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + expect($regularImages)->toHaveCount(2); + expect($regularImages->pluck('tag')->toArray())->toContain('abc123', 'def456'); +}); + +it('filters out currently running image from deletion candidates', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'], + ['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'], + ['repository' => 'app-uuid', 'tag' => 'ghi789', 'created_at' => '2024-01-03', 'image_ref' => 'app-uuid:ghi789'], + ]); + + $currentTag = 'def456'; + + $deletionCandidates = $images->filter(fn ($image) => $image['tag'] !== $currentTag); + + expect($deletionCandidates)->toHaveCount(2); + expect($deletionCandidates->pluck('tag')->toArray())->not->toContain('def456'); + expect($deletionCandidates->pluck('tag')->toArray())->toContain('abc123', 'ghi789'); +}); + +it('keeps the correct number of images based on docker_images_to_keep setting', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], + ['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'], + ['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'], + ]); + + $currentTag = 'commit5'; + $imagesToKeep = 2; + + // Filter out current, sort by date descending, keep N + $sortedImages = $images + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + $imagesToDelete = $sortedImages->skip($imagesToKeep); + + // Should delete commit1, commit2 (oldest 2 after keeping 2 newest: commit4, commit3) + expect($imagesToDelete)->toHaveCount(2); + expect($imagesToDelete->pluck('tag')->toArray())->toContain('commit1', 'commit2'); +}); + +it('deletes all images when docker_images_to_keep is 0', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], + ]); + + $currentTag = 'commit3'; + $imagesToKeep = 0; + + $sortedImages = $images + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + $imagesToDelete = $sortedImages->skip($imagesToKeep); + + // Should delete all images except the currently running one + expect($imagesToDelete)->toHaveCount(2); + expect($imagesToDelete->pluck('tag')->toArray())->toContain('commit1', 'commit2'); +}); + +it('does not delete any images when there are fewer than images_to_keep', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ]); + + $currentTag = 'commit2'; + $imagesToKeep = 5; + + $sortedImages = $images + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + $imagesToDelete = $sortedImages->skip($imagesToKeep); + + // Should not delete anything - we have fewer images than the keep limit + expect($imagesToDelete)->toHaveCount(0); +}); + +it('handles images with custom registry names', function () { + // Test that the logic works regardless of repository name format + $images = collect([ + ['repository' => 'registry.example.com/my-app', 'tag' => 'v1.0.0', 'created_at' => '2024-01-01', 'image_ref' => 'registry.example.com/my-app:v1.0.0'], + ['repository' => 'registry.example.com/my-app', 'tag' => 'v1.1.0', 'created_at' => '2024-01-02', 'image_ref' => 'registry.example.com/my-app:v1.1.0'], + ['repository' => 'registry.example.com/my-app', 'tag' => 'pr-99', 'created_at' => '2024-01-03', 'image_ref' => 'registry.example.com/my-app:pr-99'], + ]); + + $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + expect($prImages)->toHaveCount(1); + expect($regularImages)->toHaveCount(2); +}); + +it('correctly identifies PR build images as PR images', function () { + // PR build images have tags like 'pr-123-build' + // They are identified as PR images (start with 'pr-') and will be deleted + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'pr-123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:pr-123'], + ['repository' => 'app-uuid', 'tag' => 'pr-123-build', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:pr-123-build'], + ]); + + // PR images include both pr-123 and pr-123-build (both start with 'pr-') + $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + + expect($prImages)->toHaveCount(2); +}); + +it('defaults to keeping 2 images when setting is null', function () { + $defaultValue = 2; + + // Simulate the null coalescing behavior + $dockerImagesToKeep = null ?? $defaultValue; + + expect($dockerImagesToKeep)->toBe(2); +}); + +it('does not delete images when count equals images_to_keep', function () { + // Scenario: User has 3 images, 1 is running, 2 remain, keep limit is 2 + // Expected: No images should be deleted + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], + ]); + + $currentTag = 'commit3'; // This is running + $imagesToKeep = 2; + + $sortedImages = $images + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // After filtering out running image, we have 2 images + expect($sortedImages)->toHaveCount(2); + + $imagesToDelete = $sortedImages->skip($imagesToKeep); + + // Skip 2, leaving 0 to delete + expect($imagesToDelete)->toHaveCount(0); +}); + +it('handles scenario where no container is running', function () { + // Scenario: 2 images exist, none running, keep limit is 2 + // Expected: No images should be deleted (keep all 2) + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ]); + + $currentTag = ''; // No container running, empty tag + $imagesToKeep = 2; + + $sortedImages = $images + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // All images remain since none match the empty current tag + expect($sortedImages)->toHaveCount(2); + + $imagesToDelete = $sortedImages->skip($imagesToKeep); + + // Skip 2, leaving 0 to delete + expect($imagesToDelete)->toHaveCount(0); +}); + +it('handles Docker Compose service images with uuid_servicename pattern', function () { + // Docker Compose with build: directive creates images like uuid_servicename:tag + $images = collect([ + ['repository' => 'app-uuid_web', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid_web:commit1'], + ['repository' => 'app-uuid_web', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid_web:commit2'], + ['repository' => 'app-uuid_worker', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid_worker:commit1'], + ['repository' => 'app-uuid_worker', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid_worker:commit2'], + ]); + + // All images should be categorized as regular images (not PR, not build) + $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + expect($prImages)->toHaveCount(0); + expect($regularImages)->toHaveCount(4); +}); + +it('correctly excludes Docker Compose images from general prune', function () { + // Test the grep pattern that excludes application images + // Pattern should match both uuid:tag and uuid_servicename:tag + $appUuid = 'abc123def456'; + $excludePattern = preg_quote($appUuid, '/'); + + // Images that should be EXCLUDED (protected) + $protectedImages = [ + "{$appUuid}:commit1", // Standard app image + "{$appUuid}_web:commit1", // Docker Compose service + "{$appUuid}_worker:commit2", // Docker Compose service + ]; + + // Images that should be INCLUDED (deleted) + $deletableImages = [ + 'other-app:latest', + 'nginx:alpine', + 'postgres:15', + ]; + + // Test the regex pattern used in buildImagePruneCommand + $pattern = "/^({$excludePattern})[_:].+/"; + + foreach ($protectedImages as $image) { + expect(preg_match($pattern, $image))->toBe(1, "Image {$image} should be protected"); + } + + foreach ($deletableImages as $image) { + expect(preg_match($pattern, $image))->toBe(0, "Image {$image} should be deletable"); + } +}); diff --git a/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php b/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php index fc29f19c3..3dd18ee6c 100644 --- a/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php +++ b/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php @@ -615,3 +615,58 @@ expect($path)->not->toContain('//', "Double slash found for baseDir: {$case['baseDir']}"); } }); + +// Tests for injectDockerComposeBuildArgs() helper function +it('injects build args when building specific service', function () { + $command = 'docker compose build web'; + $buildArgs = '--build-arg ENV=prod'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker compose build --build-arg ENV=prod web'); +}); + +it('injects build args with service name containing hyphens', function () { + $command = 'docker compose build my-service-name'; + $buildArgs = '--build-arg TEST=value'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker compose build --build-arg TEST=value my-service-name'); +}); + +it('injects build args with service name containing underscores', function () { + $command = 'docker compose build my_service_name'; + $buildArgs = '--build-arg TEST=value'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker compose build --build-arg TEST=value my_service_name'); +}); + +it('injects build args before service name and existing flags', function () { + $command = 'docker compose build backend --no-cache'; + $buildArgs = '--build-arg FOO=bar'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker compose build --build-arg FOO=bar backend --no-cache'); +}); + +it('handles buildx with target and flags', function () { + $command = 'docker buildx build --platform linux/amd64 -t myimage:latest .'; + $buildArgs = '--build-arg VERSION=1.0'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker buildx build --build-arg VERSION=1.0 --platform linux/amd64 -t myimage:latest .'); +}); + +it('handles docker compose build with no arguments', function () { + $command = 'docker compose build'; + $buildArgs = '--build-arg FOO=bar'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker compose build --build-arg FOO=bar'); +}); diff --git a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php new file mode 100644 index 000000000..bd925444a --- /dev/null +++ b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php @@ -0,0 +1,299 @@ +shouldReceive('getAttribute') + ->with('build_pack') + ->andReturn('nixpacks'); + $mockApplication->build_pack = 'nixpacks'; + + // Mock environment variables - some with null/empty values + $envVar1 = Mockery::mock(EnvironmentVariable::class); + $envVar1->key = 'VALID_VAR'; + $envVar1->real_value = 'valid_value'; + + $envVar2 = Mockery::mock(EnvironmentVariable::class); + $envVar2->key = 'NULL_VAR'; + $envVar2->real_value = null; + + $envVar3 = Mockery::mock(EnvironmentVariable::class); + $envVar3->key = 'EMPTY_VAR'; + $envVar3->real_value = ''; + + $envVar4 = Mockery::mock(EnvironmentVariable::class); + $envVar4->key = 'ANOTHER_VALID_VAR'; + $envVar4->real_value = 'another_value'; + + $nixpacksEnvVars = collect([$envVar1, $envVar2, $envVar3, $envVar4]); + + $mockApplication->shouldReceive('getAttribute') + ->with('nixpacks_environment_variables') + ->andReturn($nixpacksEnvVars); + + // Mock application deployment queue + $mockQueue = Mockery::mock(ApplicationDeploymentQueue::class); + $mockQueue->shouldReceive('getAttribute')->with('application_id')->andReturn(1); + $mockQueue->application_id = 1; + + // Mock the job + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new \ReflectionClass(ApplicationDeploymentJob::class); + + // Set private properties + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $mockApplication); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + // Mock generate_coolify_env_variables to return some values including null + $job->shouldReceive('generate_coolify_env_variables') + ->andReturn(collect([ + 'COOLIFY_FQDN' => 'example.com', + 'COOLIFY_URL' => null, // null value that should be filtered + 'COOLIFY_BRANCH' => '', // empty value that should be filtered + 'SOURCE_COMMIT' => 'abc123', + ])); + + // Call the private method + $method = $reflection->getMethod('generate_nixpacks_env_variables'); + $method->setAccessible(true); + $method->invoke($job); + + // Get the generated env_nixpacks_args + $envArgsProperty = $reflection->getProperty('env_nixpacks_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + // Verify that only valid environment variables are included + expect($envArgs)->toContain('--env VALID_VAR=valid_value'); + expect($envArgs)->toContain('--env ANOTHER_VALID_VAR=another_value'); + expect($envArgs)->toContain('--env COOLIFY_FQDN=example.com'); + expect($envArgs)->toContain('--env SOURCE_COMMIT=abc123'); + + // Verify that null and empty environment variables are filtered out + expect($envArgs)->not->toContain('NULL_VAR'); + expect($envArgs)->not->toContain('EMPTY_VAR'); + expect($envArgs)->not->toContain('COOLIFY_URL'); + expect($envArgs)->not->toContain('COOLIFY_BRANCH'); + + // Verify no environment variables end with just '=' (which indicates null/empty value) + expect($envArgs)->not->toMatch('/--env [A-Z_]+=$/'); + expect($envArgs)->not->toMatch('/--env [A-Z_]+= /'); +}); + +it('filters out null environment variables from nixpacks preview deployments', function () { + // Mock application with nixpacks build pack + $mockApplication = Mockery::mock(Application::class); + $mockApplication->shouldReceive('getAttribute') + ->with('build_pack') + ->andReturn('nixpacks'); + $mockApplication->build_pack = 'nixpacks'; + + // Mock preview environment variables - some with null/empty values + $envVar1 = Mockery::mock(EnvironmentVariable::class); + $envVar1->key = 'PREVIEW_VAR'; + $envVar1->real_value = 'preview_value'; + + $envVar2 = Mockery::mock(EnvironmentVariable::class); + $envVar2->key = 'NULL_PREVIEW_VAR'; + $envVar2->real_value = null; + + $previewEnvVars = collect([$envVar1, $envVar2]); + + $mockApplication->shouldReceive('getAttribute') + ->with('nixpacks_environment_variables_preview') + ->andReturn($previewEnvVars); + + // Mock application deployment queue + $mockQueue = Mockery::mock(ApplicationDeploymentQueue::class); + $mockQueue->shouldReceive('getAttribute')->with('application_id')->andReturn(1); + $mockQueue->application_id = 1; + + // Mock the job + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new \ReflectionClass(ApplicationDeploymentJob::class); + + // Set private properties + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $mockApplication); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 123); // Non-zero for preview deployment + + // Mock generate_coolify_env_variables + $job->shouldReceive('generate_coolify_env_variables') + ->andReturn(collect([ + 'COOLIFY_FQDN' => 'preview.example.com', + ])); + + // Call the private method + $method = $reflection->getMethod('generate_nixpacks_env_variables'); + $method->setAccessible(true); + $method->invoke($job); + + // Get the generated env_nixpacks_args + $envArgsProperty = $reflection->getProperty('env_nixpacks_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + // Verify that only valid environment variables are included + expect($envArgs)->toContain('--env PREVIEW_VAR=preview_value'); + expect($envArgs)->toContain('--env COOLIFY_FQDN=preview.example.com'); + + // Verify that null environment variables are filtered out + expect($envArgs)->not->toContain('NULL_PREVIEW_VAR'); +}); + +it('handles all environment variables being null or empty', function () { + // Mock application with nixpacks build pack + $mockApplication = Mockery::mock(Application::class); + $mockApplication->shouldReceive('getAttribute') + ->with('build_pack') + ->andReturn('nixpacks'); + $mockApplication->build_pack = 'nixpacks'; + + // Mock environment variables - all null or empty + $envVar1 = Mockery::mock(EnvironmentVariable::class); + $envVar1->key = 'NULL_VAR'; + $envVar1->real_value = null; + + $envVar2 = Mockery::mock(EnvironmentVariable::class); + $envVar2->key = 'EMPTY_VAR'; + $envVar2->real_value = ''; + + $nixpacksEnvVars = collect([$envVar1, $envVar2]); + + $mockApplication->shouldReceive('getAttribute') + ->with('nixpacks_environment_variables') + ->andReturn($nixpacksEnvVars); + + // Mock application deployment queue + $mockQueue = Mockery::mock(ApplicationDeploymentQueue::class); + $mockQueue->shouldReceive('getAttribute')->with('application_id')->andReturn(1); + $mockQueue->application_id = 1; + + // Mock the job + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new \ReflectionClass(ApplicationDeploymentJob::class); + + // Set private properties + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $mockApplication); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + // Mock generate_coolify_env_variables to return all null/empty values + $job->shouldReceive('generate_coolify_env_variables') + ->andReturn(collect([ + 'COOLIFY_URL' => null, + 'COOLIFY_BRANCH' => '', + ])); + + // Call the private method + $method = $reflection->getMethod('generate_nixpacks_env_variables'); + $method->setAccessible(true); + $method->invoke($job); + + // Get the generated env_nixpacks_args + $envArgsProperty = $reflection->getProperty('env_nixpacks_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + // Verify that the result is empty or contains no environment variables + expect($envArgs)->toBe(''); +}); + +it('preserves environment variables with zero values', function () { + // Mock application with nixpacks build pack + $mockApplication = Mockery::mock(Application::class); + $mockApplication->shouldReceive('getAttribute') + ->with('build_pack') + ->andReturn('nixpacks'); + $mockApplication->build_pack = 'nixpacks'; + + // Mock environment variables with zero values (which should NOT be filtered) + $envVar1 = Mockery::mock(EnvironmentVariable::class); + $envVar1->key = 'ZERO_VALUE'; + $envVar1->real_value = '0'; + + $envVar2 = Mockery::mock(EnvironmentVariable::class); + $envVar2->key = 'FALSE_VALUE'; + $envVar2->real_value = 'false'; + + $nixpacksEnvVars = collect([$envVar1, $envVar2]); + + $mockApplication->shouldReceive('getAttribute') + ->with('nixpacks_environment_variables') + ->andReturn($nixpacksEnvVars); + + // Mock application deployment queue + $mockQueue = Mockery::mock(ApplicationDeploymentQueue::class); + $mockQueue->shouldReceive('getAttribute')->with('application_id')->andReturn(1); + $mockQueue->application_id = 1; + + // Mock the job + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new \ReflectionClass(ApplicationDeploymentJob::class); + + // Set private properties + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $mockApplication); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + // Mock generate_coolify_env_variables + $job->shouldReceive('generate_coolify_env_variables') + ->andReturn(collect([])); + + // Call the private method + $method = $reflection->getMethod('generate_nixpacks_env_variables'); + $method->setAccessible(true); + $method->invoke($job); + + // Get the generated env_nixpacks_args + $envArgsProperty = $reflection->getProperty('env_nixpacks_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + // Verify that zero and false string values are preserved + expect($envArgs)->toContain('--env ZERO_VALUE=0'); + expect($envArgs)->toContain('--env FALSE_VALUE=false'); +}); diff --git a/tests/Unit/ContainerStatusAggregatorTest.php b/tests/Unit/ContainerStatusAggregatorTest.php index 353d6a948..71425a21c 100644 --- a/tests/Unit/ContainerStatusAggregatorTest.php +++ b/tests/Unit/ContainerStatusAggregatorTest.php @@ -126,6 +126,70 @@ expect($result)->toBe('starting:unknown'); }); + test('returns degraded:unhealthy for single degraded container', function () { + $statuses = collect(['degraded:unhealthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('returns degraded:unhealthy when mixing degraded with running healthy', function () { + $statuses = collect(['degraded:unhealthy', 'running:healthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('returns degraded:unhealthy when mixing running healthy with degraded', function () { + $statuses = collect(['running:healthy', 'degraded:unhealthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('returns degraded:unhealthy for multiple degraded containers', function () { + $statuses = collect(['degraded:unhealthy', 'degraded:unhealthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('degraded status overrides all other non-critical states', function () { + $statuses = collect(['degraded:unhealthy', 'running:healthy', 'starting', 'paused']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('returns starting:unknown when mixing starting with running healthy (service not fully ready)', function () { + $statuses = collect(['starting:unknown', 'running:healthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('starting:unknown'); + }); + + test('returns starting:unknown when mixing created with running healthy', function () { + $statuses = collect(['created', 'running:healthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('starting:unknown'); + }); + + test('returns starting:unknown for multiple starting containers with some running', function () { + $statuses = collect(['starting:unknown', 'starting:unknown', 'running:healthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('starting:unknown'); + }); + test('handles parentheses format input (backward compatibility)', function () { $statuses = collect(['running (healthy)', 'running (unhealthy)']); @@ -166,8 +230,16 @@ expect($result)->toBe('degraded:unhealthy'); }); - test('prioritizes running over paused/starting/exited', function () { - $statuses = collect(['running:healthy', 'starting', 'paused']); + test('mixed running and starting returns starting', function () { + $statuses = collect(['running:healthy', 'starting']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('starting:unknown'); + }); + + test('prioritizes running over paused/exited when no starting', function () { + $statuses = collect(['running:healthy', 'paused', 'exited']); $result = $this->aggregator->aggregateFromStrings($statuses); @@ -398,7 +470,23 @@ }); describe('state priority enforcement', function () { - test('restarting has highest priority', function () { + test('degraded from sub-resources has highest priority', function () { + $statuses = collect([ + 'degraded:unhealthy', + 'restarting', + 'running:healthy', + 'dead', + 'paused', + 'starting', + 'exited', + ]); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('restarting has second highest priority', function () { $statuses = collect([ 'restarting', 'running:healthy', @@ -413,7 +501,7 @@ expect($result)->toBe('degraded:unhealthy'); }); - test('crash loop has second highest priority', function () { + test('crash loop has third highest priority', function () { $statuses = collect([ 'exited', 'running:healthy', @@ -426,7 +514,7 @@ expect($result)->toBe('degraded:unhealthy'); }); - test('mixed state (running + exited) has third priority', function () { + test('mixed state (running + exited) has fourth priority', function () { $statuses = collect([ 'running:healthy', 'exited', @@ -439,6 +527,18 @@ expect($result)->toBe('degraded:unhealthy'); }); + test('mixed state (running + starting) has fifth priority', function () { + $statuses = collect([ + 'running:healthy', + 'starting', + 'paused', + ]); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('starting:unknown'); + }); + test('running:unhealthy has priority over running:unknown', function () { $statuses = collect([ 'running:unknown', diff --git a/tests/Unit/Jobs/RestartProxyJobTest.php b/tests/Unit/Jobs/RestartProxyJobTest.php new file mode 100644 index 000000000..422abd940 --- /dev/null +++ b/tests/Unit/Jobs/RestartProxyJobTest.php @@ -0,0 +1,58 @@ +shouldReceive('getSchemalessAttributes')->andReturn([]); + $server->shouldReceive('getAttribute')->with('uuid')->andReturn('test-uuid'); + + $job = new RestartProxyJob($server); + $middleware = $job->middleware(); + + $this->assertCount(1, $middleware); + $this->assertInstanceOf(WithoutOverlapping::class, $middleware[0]); + } + + public function test_job_has_correct_configuration() + { + $server = Mockery::mock(Server::class); + + $job = new RestartProxyJob($server); + + $this->assertEquals(1, $job->tries); + $this->assertEquals(120, $job->timeout); + $this->assertNull($job->activity_id); + } + + public function test_job_stores_server() + { + $server = Mockery::mock(Server::class); + + $job = new RestartProxyJob($server); + + $this->assertSame($server, $job->server); + } +} diff --git a/tests/Unit/LogViewerXssSecurityTest.php b/tests/Unit/LogViewerXssSecurityTest.php new file mode 100644 index 000000000..98c5df3f1 --- /dev/null +++ b/tests/Unit/LogViewerXssSecurityTest.php @@ -0,0 +1,427 @@ +alert("XSS")'; + $escaped = htmlspecialchars($maliciousLog); + + expect($escaped)->toContain('<script>'); + expect($escaped)->not->toContain('">'; + $escaped = htmlspecialchars($maliciousLog); + + expect($escaped)->toContain('<iframe'); + expect($escaped)->toContain('data:'); + expect($escaped)->not->toContain('test
    '; + $escaped = htmlspecialchars($maliciousLog); + + expect($escaped)->toContain('<div'); + expect($escaped)->toContain('style'); + expect($escaped)->not->toContain('
    test
    '; + $escaped = htmlspecialchars($maliciousLog); + + expect($escaped)->toContain('<div'); + expect($escaped)->toContain('x-html'); + expect($escaped)->not->toContain('
    toBe('<>&"''); + }); + + it('preserves legitimate text content', function () { + $legitimateLog = 'INFO: Application started successfully'; + $escaped = htmlspecialchars($legitimateLog); + + expect($escaped)->toBe($legitimateLog); + }); + + it('handles ANSI color codes after escaping', function () { + $logWithAnsi = "\e[31mERROR:\e[0m Something went wrong"; + $escaped = htmlspecialchars($logWithAnsi); + + // ANSI codes should be preserved in escaped form + expect($escaped)->toContain('ERROR'); + expect($escaped)->toContain('Something went wrong'); + }); + + it('escapes complex nested HTML structures', function () { + $maliciousLog = '
    '; + $escaped = htmlspecialchars($maliciousLog); + + expect($escaped)->toContain('<div'); + expect($escaped)->toContain('<img'); + expect($escaped)->toContain('<script>'); + expect($escaped)->not->toContain('not->toContain('not->toContain(''; + $escaped = htmlspecialchars($contentWithHtml); + + // When stored in data attribute and rendered with x-text: + // 1. Server escapes to: <script>alert("XSS")</script> + // 2. Browser decodes the attribute value to: + // 3. x-text renders it as textContent (plain text), NOT innerHTML + // 4. Result: User sees "" as text, script never executes + + expect($escaped)->toContain('<script>'); + expect($escaped)->not->toContain(''; + + // Step 1: Server-side escaping (PHP) + $escaped = htmlspecialchars($rawLog); + expect($escaped)->toBe('<script>alert("XSS")</script>'); + + // Step 2: Stored in data-log-content attribute + //
    + + // Step 3: Client-side getDisplayText() decodes HTML entities + // const decoded = doc.documentElement.textContent; + // Result: '' (as text string) + + // Step 4: x-text renders as textContent (NOT innerHTML) + // Alpine.js sets element.textContent = decoded + // Result: Browser displays '' as visible text + // The script tag is never parsed or executed - it's just text + + // Step 5: Highlighting via CSS class + // If search query matches, 'log-highlight' class is added + // Visual feedback is provided through CSS, not HTML injection + }); + + it('documents search highlighting with CSS classes', function () { + $legitimateLog = '2024-01-01T12:00:00.000Z ERROR: Database connection failed'; + + // Server-side: Escape and store + $escaped = htmlspecialchars($legitimateLog); + expect($escaped)->toBe($legitimateLog); // No special chars + + // Client-side: If user searches for "ERROR" + // 1. splitTextForHighlight() divides the text into parts: + // - Part 1: "2024-01-01T12:00:00.000Z " (highlight: false) + // - Part 2: "ERROR" (highlight: true) <- This part gets highlighted + // - Part 3: ": Database connection failed" (highlight: false) + // 2. Each part is rendered as a with x-text (safe) + // 3. Only Part 2 gets the 'log-highlight' class via :class binding + // 4. CSS provides yellow/warning background color on "ERROR" only + // 5. No HTML injection occurs - just multiple safe text spans + + expect($legitimateLog)->toContain('ERROR'); + }); + + it('verifies no HTML injection occurs during search', function () { + $logWithHtml = 'User input: '; + $escaped = htmlspecialchars($logWithHtml); + + // Even if log contains malicious HTML: + // 1. Server escapes it + // 2. x-text renders as plain text + // 3. Search highlighting uses CSS class, not HTML tags + // 4. User sees the literal text with highlight background + // 5. No script execution possible + + expect($escaped)->toContain('<img'); + expect($escaped)->toContain('onerror'); + expect($escaped)->not->toContain('toContain('