diff --git a/.env.development.example b/.env.development.example index 380f10a44..56c17128c 100644 --- a/.env.development.example +++ b/.env.development.example @@ -53,3 +53,4 @@ DUSK_DRIVER_URL=http://selenium:4444 BUNNY_API_KEY= # For asset uploads BUNNY_STORAGE_API_KEY= +AVATAR_CDN_URL= diff --git a/.env.windows-docker-desktop.example b/.env.windows-docker-desktop.example index b067b4c5c..626d76ff6 100644 --- a/.env.windows-docker-desktop.example +++ b/.env.windows-docker-desktop.example @@ -11,3 +11,4 @@ REDIS_PASSWORD=coolify PUSHER_APP_ID=coolify PUSHER_APP_KEY=coolify PUSHER_APP_SECRET=coolify +AVATAR_CDN_URL= diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml index 595a21e79..b9b8b361f 100644 --- a/.github/workflows/sync-main-to-next.yml +++ b/.github/workflows/sync-main-to-next.yml @@ -1,8 +1,8 @@ name: Sync main to next on: - push: - branches: [main] + schedule: + - cron: '0 3 * * *' workflow_dispatch: permissions: diff --git a/AGENTS.md b/AGENTS.md index 4d78dfd93..f9f8ca563 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,11 @@ ### Authorization - Custom gates: `createAnyResource`, `canAccessTerminal` - Role hierarchy: `Role::MEMBER` (1) < `Role::ADMIN` (2) < `Role::OWNER` (3) with `lt()`/`gt()` comparison methods - Multi-tenancy via Teams — team auto-initializes notification settings on creation +- Authorize every server-side read and mutation where access can vary by user, role, team, or resource. Use policies, gates, or `$this->authorize(...)`; never rely on hidden Blade/Livewire controls such as `@can` for security. +- Scope queries to the current team before returning records. Treat route and model identifiers as untrusted, and prevent users from reading or changing resources owned by another team. +- Apply authorization consistently across Livewire actions, API and web controllers, actions, downloads, exports, search, event listeners, and any other path that exposes or changes protected data. +- Default to denying access when a policy or ownership relationship is missing or ambiguous. Members must not gain access to administrative, credential, security, billing, or instance-wide data merely because they belong to the team. +- Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data. ### Event Broadcasting - Soketi WebSocket server for real-time updates (ports 6001-6002 in dev) @@ -191,6 +196,23 @@ ### Laravel 10 Structure (NOT Laravel 11+ slim structure) - Exception handler: `app/Exceptions/Handler.php` - Service providers in `app/Providers/` +## Livewire conventions + +### Dynamic lists and snapshot errors + +When an add, delete, or conversion leaves controls unresponsive and the browser reports `Snapshot missing on Livewire component`, inspect both component keys and refresh events. Stable keys alone may not fix it. + +- Give every Livewire component rendered in a loop a stable key based on the record ID, UUID, filename, or another immutable identity. Never include a collection count, `$loop->index`, or a reindexed array position in the key. +- Pass the same stable identity to edit/delete actions. A keyed row can survive reordering while a `wire:ignore` or teleported Alpine modal keeps its original `submitAction`; an action such as `removeItem($index)` then targets a stale position after the first deletion. Resolve the current row server-side from an ID, UUID, or stable row hash instead. +- Do not broadcast one refresh event to both a parent list component and children that the parent may insert, remove, or hide during the same operation. This can queue a child update after its snapshot has been removed from the DOM. +- Split refresh responsibilities into targeted events. Refresh the parent for counts and tab visibility, and refresh an existing child list with a separate event. Use `$this->dispatch('event')->to(Component::class)` instead of a page-wide event when possible. +- Before targeting a child list, confirm that it existed before the mutation, still exists afterward, and is on the active tab. A newly inserted child loads current data during `mount()` and does not need an immediate refresh. A removed or hidden child must not receive one. +- A child that deletes itself should finish its own update, then target only the parent to refresh counts. The parent should not send a refresh back to that child when the list became empty. +- Apply the same pattern to file, directory, conversion, and external reload paths such as Compose edits. One remaining broad event can reproduce the race. +- Add regression tests that assert the scoped event names, assert the old broad event is not dispatched, and verify that keys do not depend on counts or positions. Manually repeat add/delete operations while watching the browser console. + +The persistent-storage implementation is the reference pattern: `Project\Service\Storage` handles `storageCountsChanged`, while `Project\Shared\Storages\All` handles `refreshVolumeList`. + ## Key Conventions - Use `php artisan make:*` commands with `--no-interaction` to create files diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 66ceb95f6..12ac56900 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -13,8 +13,9 @@ class StopApplication public string $jobQueue = 'high'; - public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true) + public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainers = true): ?string { + $containerPresent = ! $removeContainers; $servers = collect([$application->destination->server]); if ($application?->additional_servers?->count() > 0) { $servers = $servers->merge($application->additional_servers); @@ -26,6 +27,7 @@ public function handle(Application $application, bool $previewDeployments = fals } if ($server->isSwarm()) { + $containerPresent = false; instant_remote_process(["docker stack rm {$application->uuid}"], $server); continue; @@ -39,13 +41,15 @@ public function handle(Application $application, bool $previewDeployments = fals $timeout = $application->settings->stopGracePeriodSeconds(); foreach ($containersToStop as $containerName) { - instant_remote_process(command: [ - dockerStopCommand($timeout, $containerName, $server), - "docker rm -f $containerName", - ], server: $server, throwError: false); + $commands = [dockerStopCommand($timeout, $containerName, $server)]; + if ($removeContainers) { + $commands[] = "docker rm -f $containerName"; + } + + instant_remote_process(command: $commands, server: $server, throwError: false); } - if ($application->build_pack === 'dockercompose') { + if ($removeContainers && $application->build_pack === 'dockercompose') { $application->deleteConnectedNetworks(); } @@ -57,16 +61,22 @@ public function handle(Application $application, bool $previewDeployments = fals } } - $status = ['status' => 'exited']; + $status = [ + 'status' => 'exited', + 'container_present' => $containerPresent, + ]; if ($resetRestartCount) { $status = array_merge($status, [ 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, + 'restart_limit_reached' => false, ]); } $application->update($status); ServiceStatusChanged::dispatch($application->environment->project->team->id); + + return null; } } diff --git a/app/Actions/Application/StopApplicationPreview.php b/app/Actions/Application/StopApplicationPreview.php new file mode 100644 index 000000000..8bb3a3dc0 --- /dev/null +++ b/app/Actions/Application/StopApplicationPreview.php @@ -0,0 +1,34 @@ +application; + $server = $application->destination->server; + $containers = getCurrentApplicationContainerStatus($server, $application->id, $preview->pull_request_id); + + foreach ($containers->pluck('Names') as $containerName) { + $commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)]; + if ($removeContainer) { + $commands[] = "docker rm -f $containerName"; + } + instant_remote_process($commands, $server, false); + } + + $preview->update(['status' => 'exited']); + if ($resetRestartCount) { + $preview->resetRestartLimit(); + } + + ServiceStatusChanged::dispatch($application->environment->project->team->id); + } +} diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index 4b55b0c1d..cd7e08328 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -28,6 +28,11 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St if (! $server->isFunctional()) { return 'Server is not functional'; } + $database->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); switch ($database->getMorphClass()) { case StandalonePostgresql::class: $activity = StartPostgresql::run($database); diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index a3a7f16ef..d3c6fafc4 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -4,6 +4,7 @@ use App\Actions\Server\CleanupDocker; use App\Events\ServiceStatusChanged; +use App\Models\BaseModel; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -18,7 +19,7 @@ class StopDatabase { use AsAction; - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true) + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true): string { try { $server = $database->destination->server; @@ -26,15 +27,17 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St return 'Server is not functional'; } - $this->stopContainer($database, $database->uuid, 30); + $this->stopContainer($database, $database->uuid, 30, $removeContainer); // Reset restart tracking when database is manually stopped - $database->update([ - 'status' => 'exited', - 'restart_count' => 0, - 'last_restart_at' => null, - 'last_restart_type' => null, - ]); + $database->update(['status' => 'exited']); + if ($resetRestartCount) { + $database->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); + } if ($dockerCleanup) { CleanupDocker::dispatch($server, false, false); @@ -53,12 +56,13 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St } - private function stopContainer($database, string $containerName, int $timeout = 30): void + private function stopContainer(BaseModel $database, string $containerName, int $timeout = 30, bool $removeContainer = true): void { $server = $database->destination->server; - instant_remote_process(command: [ - dockerStopCommand($timeout, $containerName, $server), - "docker rm -f $containerName", - ], server: $server, throwError: false); + $commands = [dockerStopCommand($timeout, $containerName, $server)]; + if ($removeContainer) { + $commands[] = "docker rm -f $containerName"; + } + instant_remote_process(command: $commands, server: $server, throwError: false); } } diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 904885dfc..c69bd1855 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -3,15 +3,19 @@ namespace App\Actions\Docker; use App\Actions\Application\StopApplication; +use App\Actions\Application\StopApplicationPreview; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; +use App\Actions\Service\StopServiceApplication; use App\Actions\Shared\ComplexStatusCheck; use App\Events\ServiceChecked; +use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceDatabase; use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached; use App\Services\ContainerStatusAggregator; +use App\Services\RestartCountTracker; use App\Traits\CalculatesExcludedStatus; use Illuminate\Support\Arr; use Illuminate\Support\Collection; @@ -37,8 +41,12 @@ class GetContainersStatus protected ?Collection $applicationContainerRestartCounts; + protected ?Collection $previewContainerRestartCounts; + protected ?Collection $serviceContainerStatuses; + protected ?Collection $serviceContainerRestartCounts; + public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null) { $this->containers = $containers; @@ -117,6 +125,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti $containerStatus = "$containerStatus:$healthSuffix"; } $labels = Arr::undot(format_docker_labels_to_json($labels)); + if (filter_var(data_get($labels, 'com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) { + continue; + } $applicationId = data_get($labels, 'coolify.applicationId'); if ($applicationId) { $pullRequestId = data_get($labels, 'coolify.pullRequestId'); @@ -133,6 +144,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti } else { $preview->update(['last_online_at' => now()]); } + $key = $applicationId.':'.$pullRequestId; + $this->previewContainerRestartCounts ??= collect(); + $this->previewContainerRestartCounts->push([ + 'key' => $key, + 'count' => (int) data_get($container, 'RestartCount', 0), + ]); } else { // Notify user that this container should not be there. } @@ -140,6 +157,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti $application = $this->applications->where('id', $applicationId)->first(); if ($application) { $foundApplications[] = $application->id; + if ($application->container_present !== true) { + $application->update(['container_present' => true]); + } // Store container status for aggregation if (! isset($this->applicationContainerStatuses)) { $this->applicationContainerStatuses = collect(); @@ -220,23 +240,22 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti // Track restart count for databases (single-container) $restartCount = data_get($container, 'RestartCount', 0); - $previousRestartCount = $database->restart_count ?? 0; - if ($statusFromDb !== $containerStatus) { $updateData = ['status' => $containerStatus]; } else { $updateData = ['last_online_at' => now()]; } - // Update restart tracking if restart count increased - if ($restartCount > $previousRestartCount) { - $updateData['restart_count'] = $restartCount; - $updateData['last_restart_at'] = now(); - $updateData['last_restart_type'] = 'crash'; - } - $database->update($updateData); + if ($restartCount > ($database->restart_count ?? 0)) { + $database->update([ + 'restart_count' => (int) $restartCount, + 'last_restart_at' => now(), + 'last_restart_type' => 'crash', + ]); + } + if ($isPublic) { $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) { if ($this->server->isSwarm()) { @@ -292,6 +311,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti $containerName = data_get($labels, 'com.docker.compose.service'); if ($containerName) { $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); + $this->serviceContainerRestartCounts ??= collect(); + if (! $this->serviceContainerRestartCounts->has($key)) { + $this->serviceContainerRestartCounts->put($key, collect()); + } + $this->serviceContainerRestartCounts->get($key)->put($containerName, (int) data_get($container, 'RestartCount', 0)); } // Mark service as found @@ -335,46 +359,37 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti continue; } - $name = data_get($exitedService, 'name'); - $fqdn = data_get($exitedService, 'fqdn'); - if ($name) { - if ($fqdn) { - $containerName = "$name, available at $fqdn"; - } else { - $containerName = $name; - } - } else { - if ($fqdn) { - $containerName = $fqdn; - } else { - $containerName = null; - } + if ($exitedService instanceof ServiceDatabase) { + $exitedService->update(['status' => 'exited']); + } elseif (! $exitedService->stoppedAfterRestartLimit()) { + $exitedService->update([ + 'status' => 'exited', + 'restart_count' => 0, + 'restart_limit_reached' => false, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); } - $projectUuid = data_get($service, 'environment.project.uuid'); - $serviceUuid = data_get($service, 'uuid'); - $environmentName = data_get($service, 'environment.name'); - - if ($projectUuid && $serviceUuid && $environmentName) { - $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/service/'.$serviceUuid; - } else { - $url = null; - } - // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); - $exitedService->update(['status' => 'exited']); } $notRunningApplications = $this->applications->pluck('id')->diff($foundApplications); foreach ($notRunningApplications as $applicationId) { $application = $this->applications->where('id', $applicationId)->first(); - if (str($application->status)->startsWith('exited')) { - continue; - } // Only protection: If no containers at all, Docker query might have failed if ($this->containers->isEmpty()) { continue; } + if (str($application->status)->startsWith('exited')) { + $application->update([ + 'container_present' => false, + 'restart_limit_reached' => false, + ]); + + continue; + } + // If container was recently restarting (crash loop), keep it as degraded for a grace period // This prevents false "exited" status during the brief moment between container removal and recreation $recentlyRestarted = $application->restart_count > 0 && @@ -388,9 +403,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti // Reset restart count when application exits completely $application->update([ 'status' => 'exited', + 'container_present' => false, 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, + 'restart_limit_reached' => false, ]); } } @@ -433,23 +450,10 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti StopDatabaseProxy::run($database); } - $name = data_get($database, 'name'); - $fqdn = data_get($database, 'fqdn'); - - $containerName = $name; - - $projectUuid = data_get($database, 'environment.project.uuid'); - $environmentName = data_get($database, 'environment.name'); - $databaseUuid = data_get($database, 'uuid'); - - if ($projectUuid && $databaseUuid && $environmentName) { - $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/database/'.$databaseUuid; - } else { - $url = null; - } - // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); } + $this->trackPreviewRestartCounts($previews); + // Aggregate multi-container application statuses if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) { foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { @@ -470,21 +474,21 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) { $previousRestartCount = $application->restart_count ?? 0; + $restartState = (new RestartCountTracker)->evaluate( + previousRestartCount: $previousRestartCount, + observedRestartCount: $maxRestartCount, + maxRestartCount: $application->max_restart_count ?? 0, + ); - if ($maxRestartCount > $previousRestartCount) { - // Restart count increased - this is a crash restart + if ($restartState['restart_count_changed']) { + $hasCrashRestarts = $restartState['restart_count'] > 0; $application->update([ - 'restart_count' => $maxRestartCount, - 'last_restart_at' => now(), - 'last_restart_type' => 'crash', + 'restart_count' => $restartState['restart_count'], + 'last_restart_at' => $hasCrashRestarts ? now() : null, + 'last_restart_type' => $hasCrashRestarts ? 'crash' : null, ]); - - // Check if restart limit has been reached - $maxAllowedRestarts = $application->max_restart_count ?? 0; - if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) { - $restartLimitReached = true; - } } + $restartLimitReached = $restartState['restart_limit_reached']; // Aggregate status after tracking restart counts $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount); @@ -499,9 +503,22 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti }); if ($restartLimitReached) { - $application->refresh(); - StopApplication::dispatch($application, false, true, false); - $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); + $restartLimitClaimed = Application::query() + ->whereKey($application->getKey()) + ->where('restart_limit_reached', false) + ->update(['restart_limit_reached' => true]) === 1; + + if ($restartLimitClaimed) { + $application->refresh(); + StopApplication::dispatch( + application: $application, + previewDeployments: false, + dockerCleanup: false, + resetRestartCount: false, + removeContainers: false, + ); + $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); + } } } } @@ -562,6 +579,16 @@ private function aggregateServiceContainerStatuses($services) continue; } + $restartCount = isset($this->serviceContainerRestartCounts) + ? ($this->serviceContainerRestartCounts->get($key)?->max() ?? 0) + : 0; + if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) { + StopServiceApplication::dispatch($subResource, false, false); + $subResource->team()?->notify(new ApplicationRestartLimitReached($subResource)); + + continue; + } + // Parse docker compose from service to check for excluded containers $dockerComposeRaw = data_get($service, 'docker_compose_raw'); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); @@ -602,4 +629,24 @@ private function aggregateServiceContainerStatuses($services) } } } + + private function trackPreviewRestartCounts(Collection $previews): void + { + if (! isset($this->previewContainerRestartCounts)) { + return; + } + + $this->previewContainerRestartCounts + ->groupBy('key') + ->each(function (Collection $counts, string $key) use ($previews): void { + [$applicationId, $pullRequestId] = explode(':', $key); + $preview = $previews->first(fn (ApplicationPreview $preview): bool => (string) $preview->application_id === $applicationId + && (string) $preview->pull_request_id === $pullRequestId + ); + if ($preview?->trackRestartCount((int) $counts->max('count'))) { + StopApplicationPreview::dispatch($preview, false, false); + $preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview)); + } + }); + } } diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index cec90288e..3a37a7328 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -48,7 +48,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer } $dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments)); $dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels)); - $dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; + $dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; instant_remote_process([ 'docker rm -f coolify-sentinel || true', diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 463a8ad5b..3dc5c98b3 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -24,6 +24,7 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s } $service->saveComposeConfigs(); $service->isConfigurationChanged(save: true); + $service->applications()->get()->each->resetRestartLimit(); $workdir = $service->workdir(); // $commands[] = "cd {$workdir}"; $commands[] = "echo 'Saved configuration files to {$workdir}.'"; diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 5e34c8e6a..52d9edda1 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -49,8 +49,13 @@ public function handle(Service $service, bool $deleteConnectedNetworks = false, $this->stopContainersInParallel($containersToStop, $server); } - $applications->each->update(['status' => 'exited']); - $dbs->each->update(['status' => 'exited']); + $applications->each(function ($application): void { + $application->update(['status' => 'exited']); + $application->resetRestartLimit(); + }); + $dbs->each(function ($database): void { + $database->update(['status' => 'exited']); + }); if ($deleteConnectedNetworks) { $service->deleteConnectedNetworks(); diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php index 184dcb491..1b5347265 100644 --- a/app/Actions/Service/StopServiceApplication.php +++ b/app/Actions/Service/StopServiceApplication.php @@ -13,17 +13,23 @@ class StopServiceApplication public string $jobQueue = 'high'; - public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void + public function handle(ServiceApplication|ServiceDatabase $serviceApplication, bool $resetRestartCount = true, bool $removeContainer = false): void { $service = $serviceApplication->service; $server = $service->destination->server; $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); - instant_remote_process([ - "docker stop {$containerName}", - ], $server); + if ($removeContainer) { + $commands = ["docker rm -f {$containerName}"]; + } else { + $commands = ["docker stop {$containerName}"]; + } + instant_remote_process($commands, $server, throwError: ! $removeContainer); $serviceApplication->update(['status' => 'exited']); + if ($resetRestartCount && $serviceApplication instanceof ServiceApplication) { + $serviceApplication->resetRestartLimit(); + } ServiceStatusChanged::dispatch($service->environment->project->team->id); } } diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php index 123b752c0..004403975 100644 --- a/app/Actions/Service/UpdateServiceApplicationFromApi.php +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -56,7 +56,7 @@ public function execute(ServiceApplication $serviceApplication, Request $request } } - $serviceApplication->fqdn = $parsed['normalized']; + $serviceApplication->setEditableUrls($parsed['normalized']); } if (array_key_exists('noindex_domains', $payload)) { diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 8d4d017c8..8eb010b8a 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -47,9 +47,10 @@ protected function schedule(Schedule $schedule): void ->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop')); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); $this->scheduleInstance->command('cleanup:stucked-resources') - ->daily() + ->dailyAt('03:17') ->onOneServer() - ->withoutOverlapping(60); + ->withoutOverlapping(60) + ->runInBackground(); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index a0e1714ad..487d319e1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -23,6 +23,7 @@ use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -2487,6 +2488,256 @@ public function logs_by_uuid(Request $request) ]); } + #[OA\Patch( + summary: 'Update Preview Domains', + description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.', + path: '/applications/{uuid}/previews/{pull_request_id}', + operationId: 'update-preview-domains-by-pull-request-id', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'pull_request_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'domains', type: 'string', nullable: true, example: 'https://pr.example.com:3000'), + new OA\Property( + property: 'docker_compose_domains', + type: 'array', + nullable: true, + items: new OA\Items(properties: [ + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'domain', type: 'string', nullable: true), + new OA\Property(property: 'redirect', type: 'string', nullable: true, enum: ['www', 'non-www', 'both']), + ], type: 'object'), + ), + new OA\Property(property: 'force_domain_override', type: 'boolean', default: false), + ], + )), + responses: [ + new OA\Response(response: 200, description: 'Preview domains updated.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, ref: '#/components/responses/403'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Domain conflict.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function update_preview_by_pull_request_id(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $pullRequestIdRaw = $request->route('pull_request_id'); + if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + return response()->json(['message' => 'Invalid pull_request_id.'], 422); + } + + $preview = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', (int) $pullRequestIdRaw) + ->first(); + if (! $preview) { + return response()->json(['message' => 'Preview not found.'], 404); + } + + $isCompose = $application->build_pack === BuildPackTypes::DOCKERCOMPOSE->value; + $validationRules = ['force_domain_override' => 'boolean']; + if ($isCompose) { + $validationRules = array_merge($validationRules, [ + 'domains' => 'missing', + 'docker_compose_domains' => 'present|array', + 'docker_compose_domains.*' => 'array:name,domain,redirect', + 'docker_compose_domains.*.name' => 'required|string|distinct', + 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), + 'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both', + ]); + } else { + $validationRules['domains'] = ['present', ...ValidationPatterns::applicationDomainRules()]; + $validationRules['docker_compose_domains'] = 'missing'; + } + + $validator = Validator::make($request->all(), $validationRules); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $dockerComposeDomains = null; + $dockerComposeDomainsResponse = null; + if ($isCompose) { + try { + $compose = Yaml::parse($application->docker_compose_raw ?? ''); + } catch (\Throwable) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'The Docker Compose configuration could not be parsed.'], + ], 422); + } + + $services = data_get($compose, 'services'); + if (! is_array($services) || $services === []) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'The Docker Compose configuration must define at least one service.'], + ], 422); + } + + $composeServices = collect($services) + ->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image'))) + ->keys() + ->map(fn (mixed $name): string => (string) $name) + ->values(); + $requestedServices = collect($request->input('docker_compose_domains'))->pluck('name'); + if ($requestedServices->diff($composeServices)->isNotEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'One or more Docker Compose services are invalid.'], + ], 422); + } + + $existingComposeDomains = json_decode($preview->docker_compose_domains ?? '[]', true) ?: []; + $dockerComposeDomains = $composeServices + ->mapWithKeys(function (string $service) use ($existingComposeDomains): array { + $entry = ['domain' => '']; + $redirect = $existingComposeDomains[$service]['redirect'] ?? null; + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + + return [$service => $entry]; + }) + ->all(); + foreach ($request->input('docker_compose_domains') as $item) { + $entry = ['domain' => ValidationPatterns::normalizeApplicationDomains(data_get($item, 'domain')) ?? '']; + $redirect = array_key_exists('redirect', $item) + ? data_get($item, 'redirect') + : ($existingComposeDomains[data_get($item, 'name')]['redirect'] ?? null); + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + $dockerComposeDomains[data_get($item, 'name')] = $entry; + } + $domains = collect($dockerComposeDomains) + ->pluck('domain') + ->filter() + ->implode(',') ?: null; + } else { + $domains = ValidationPatterns::normalizeApplicationDomains($request->input('domains')); + } + + $submittedUrls = collect(ValidationPatterns::applicationDomainList($domains)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)); + if ($submittedUrls->duplicates()->isNotEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + $isCompose ? 'docker_compose_domains' : 'domains' => 'The same domain cannot be configured more than once.', + ], + ], 422); + } + + $normalized = DomainPortOverrides::normalize($domains, null); + $portlessDomains = $normalized['fqdn']; + if ($isCompose) { + foreach ($dockerComposeDomains as $service => $entry) { + $dockerComposeDomains[$service]['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain'])) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->implode(','); + } + $dockerComposeDomainsResponse = collect($dockerComposeDomains) + ->map(fn (array $entry, string $name): array => ['name' => $name, ...$entry]) + ->values() + ->all(); + } + $urls = collect(ValidationPatterns::applicationDomainList($portlessDomains)); + $conflicts = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId); + if (isset($conflicts['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$isCompose ? 'docker_compose_domains' : 'domains' => $conflicts['error']], + ], 422); + } + if ($conflicts['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $conflicts['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + + $hostCandidates = $urls + ->map(fn (string $url): string => (string) parse_url($url, PHP_URL_HOST)) + ->filter(); + $conflictingPreview = null; + if ($hostCandidates->isNotEmpty()) { + $conflictingPreview = ApplicationPreview::query() + ->whereIn('application_id', Application::ownedByCurrentTeamAPI($teamId) + ->withoutGlobalScope('withRelations') + ->reorder() + ->select('applications.id')) + ->whereKeyNot($preview->id) + ->whereNotNull('fqdn') + ->where(function ($query) use ($hostCandidates): void { + foreach ($hostCandidates as $host) { + $query->orWhere('fqdn', 'like', '%'.$host.'%'); + } + }) + ->get(['uuid', 'pull_request_id', 'fqdn']) + ->first(fn (ApplicationPreview $otherPreview): bool => collect(ValidationPatterns::applicationDomainList($otherPreview->fqdn)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->intersect($urls) + ->isNotEmpty()); + } + + if ($conflictingPreview && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => [[ + 'domain' => collect(ValidationPatterns::applicationDomainList($conflictingPreview->fqdn)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->intersect($urls) + ->first(), + 'resource_name' => 'Preview deployment #'.$conflictingPreview->pull_request_id, + 'resource_uuid' => $conflictingPreview->uuid, + 'resource_type' => 'application', + 'message' => 'Domain is already in use by another preview deployment.', + ]], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + + $preview->domain_port_overrides = $normalized['overrides']; + $preview->fqdn = $portlessDomains; + if ($isCompose) { + $preview->docker_compose_domains = json_encode($dockerComposeDomains); + } + $preview->save(); + + auditLog('api.application.preview_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'pull_request_id' => $preview->pull_request_id, + 'changed_fields' => [$isCompose ? 'docker_compose_domains' : 'domains'], + ]); + + return response()->json([ + 'uuid' => $preview->uuid, + 'pull_request_id' => $preview->pull_request_id, + 'domains' => $preview->fqdn, + 'docker_compose_domains' => $dockerComposeDomainsResponse, + 'domain_port_overrides' => $preview->domain_port_overrides, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete application by UUID.', @@ -2818,6 +3069,7 @@ public function update_by_uuid(Request $request) 'http_basic_auth_username' => 'string', 'http_basic_auth_password' => 'string', 'include_source_commit_in_build' => 'boolean', + 'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validationMessages = [ @@ -2826,10 +3078,10 @@ public function update_by_uuid(Request $request) $validator = Validator::make($request->all(), $validationRules, $validationMessages); // Validate ports_exposes - if ($request->has('ports_exposes')) { + if ($request->filled('ports_exposes')) { $ports = explode(',', $request->ports_exposes); foreach ($ports as $port) { - if (! is_numeric($port)) { + if (! is_numeric($port) || (int) $port < 1 || (int) $port > 65535) { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ @@ -5152,7 +5404,7 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons $this->authorize('delete', $application); $pullRequestIdRaw = $request->route('pull_request_id'); - if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { return response()->json(['message' => 'Invalid pull_request_id.'], 422); } $pullRequestId = (int) $pullRequestIdRaw; diff --git a/app/Http/Controllers/Api/NotificationsController.php b/app/Http/Controllers/Api/NotificationsController.php index f5493d024..1cca69a66 100644 --- a/app/Http/Controllers/Api/NotificationsController.php +++ b/app/Http/Controllers/Api/NotificationsController.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; class NotificationsController extends Controller @@ -45,6 +46,7 @@ private function channelConfig(string $channel): array 'deployment_success_email_notifications' => 'sometimes|boolean', 'deployment_failure_email_notifications' => 'sometimes|boolean', 'status_change_email_notifications' => 'sometimes|boolean', + 'restart_limit_reached_email_notifications' => 'sometimes|boolean', 'backup_success_email_notifications' => 'sometimes|boolean', 'backup_failure_email_notifications' => 'sometimes|boolean', 'scheduled_task_success_email_notifications' => 'sometimes|boolean', @@ -66,6 +68,7 @@ private function channelConfig(string $channel): array 'deployment_success_discord_notifications' => 'sometimes|boolean', 'deployment_failure_discord_notifications' => 'sometimes|boolean', 'status_change_discord_notifications' => 'sometimes|boolean', + 'restart_limit_reached_discord_notifications' => 'sometimes|boolean', 'backup_success_discord_notifications' => 'sometimes|boolean', 'backup_failure_discord_notifications' => 'sometimes|boolean', 'scheduled_task_success_discord_notifications' => 'sometimes|boolean', @@ -88,6 +91,7 @@ private function channelConfig(string $channel): array 'deployment_success_slack_notifications' => 'sometimes|boolean', 'deployment_failure_slack_notifications' => 'sometimes|boolean', 'status_change_slack_notifications' => 'sometimes|boolean', + 'restart_limit_reached_slack_notifications' => 'sometimes|boolean', 'backup_success_slack_notifications' => 'sometimes|boolean', 'backup_failure_slack_notifications' => 'sometimes|boolean', 'scheduled_task_success_slack_notifications' => 'sometimes|boolean', @@ -110,6 +114,7 @@ private function channelConfig(string $channel): array 'deployment_success_telegram_notifications' => 'sometimes|boolean', 'deployment_failure_telegram_notifications' => 'sometimes|boolean', 'status_change_telegram_notifications' => 'sometimes|boolean', + 'restart_limit_reached_telegram_notifications' => 'sometimes|boolean', 'backup_success_telegram_notifications' => 'sometimes|boolean', 'backup_failure_telegram_notifications' => 'sometimes|boolean', 'scheduled_task_success_telegram_notifications' => 'sometimes|boolean', @@ -124,6 +129,7 @@ private function channelConfig(string $channel): array 'telegram_notifications_deployment_success_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_deployment_failure_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_status_change_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_restart_limit_reached_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_backup_success_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_backup_failure_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_scheduled_task_success_thread_id' => 'sometimes|nullable|string|max:255', @@ -146,6 +152,7 @@ private function channelConfig(string $channel): array 'deployment_success_pushover_notifications' => 'sometimes|boolean', 'deployment_failure_pushover_notifications' => 'sometimes|boolean', 'status_change_pushover_notifications' => 'sometimes|boolean', + 'restart_limit_reached_pushover_notifications' => 'sometimes|boolean', 'backup_success_pushover_notifications' => 'sometimes|boolean', 'backup_failure_pushover_notifications' => 'sometimes|boolean', 'scheduled_task_success_pushover_notifications' => 'sometimes|boolean', @@ -167,6 +174,7 @@ private function channelConfig(string $channel): array 'deployment_success_webhook_notifications' => 'sometimes|boolean', 'deployment_failure_webhook_notifications' => 'sometimes|boolean', 'status_change_webhook_notifications' => 'sometimes|boolean', + 'restart_limit_reached_webhook_notifications' => 'sometimes|boolean', 'backup_success_webhook_notifications' => 'sometimes|boolean', 'backup_failure_webhook_notifications' => 'sometimes|boolean', 'scheduled_task_success_webhook_notifications' => 'sometimes|boolean', @@ -249,7 +257,7 @@ private function updateChannel(Request $request, string $channel): JsonResponse $body = $request->json()->all(); $config = $this->channelConfig($channel); - $validator = customApiValidator($body, $config['rules']); + $validator = Validator::make($body, $config['rules']); $extraFields = array_diff(array_keys($body), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index b3685daa4..81b932365 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -138,7 +138,7 @@ private function shouldDispatchUpdate(Server $server, array $data): bool /** * Build a stable hash of container state. * - * Covers [name, state] only — metrics, filesystem_usage_root, and + * Covers [name, state, restart_count] only — metrics, filesystem_usage_root, and * health_status are excluded on purpose. Disk % churns constantly, and * health checks can flap between starting/healthy/unhealthy while the * container lifecycle state remains unchanged. Both would otherwise defeat @@ -153,6 +153,7 @@ private function containerStateHash(array $data): string ->map(fn ($c) => [ 'name' => data_get($c, 'name'), 'state' => data_get($c, 'state'), + 'restart_count' => data_get($c, 'restart_count'), ]) ->sortBy('name') ->values() diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 327fea25e..01a365a0c 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -44,6 +44,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env'; + public const BUILD_TIME_SHELL_ENV_PATH = '/artifacts/build-time-shell.env'; + + public const BUILD_TIME_ENV_LAUNCHER_PATH = '/artifacts/run-with-build-time-env'; + private const BUILD_SCRIPT_PATH = '/artifacts/build.sh'; private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json'; @@ -201,6 +205,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $dockerSecretsSupported = false; + private bool $dockerSecretsAvailable = false; + + private bool $useBuildtimeEnvironmentLauncher = false; + private bool $skip_build = false; private Collection|string $build_secrets; @@ -261,14 +269,7 @@ public function __construct(public int $application_deployment_queue_id) $this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}"; $this->is_debug_enabled = $this->application->settings->is_debug_enabled; - $this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id); - if ($this->application->settings->custom_internal_name && ! $this->application->settings->is_consistent_container_name_enabled) { - if ($this->pull_request_id === 0) { - $this->container_name = $this->application->settings->custom_internal_name; - } else { - $this->container_name = addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id); - } - } + $this->container_name = $this->resolveContainerName(); $this->saved_outputs = collect(); @@ -425,6 +426,11 @@ public function handle(): void private function detectBuildKitCapabilities(): void { + $this->dockerBuildkitSupported = false; + $this->dockerBuildxAvailable = false; + $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = false; + $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; @@ -475,18 +481,19 @@ private function detectBuildKitCapabilities(): void } } - // If build secrets are enabled and BuildKit is available, verify --secret flag support - if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported) { $secretsTest = instant_remote_process( ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], $serverToCheck ); if (trim($secretsTest) === 'supported') { - $this->dockerSecretsSupported = true; - $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); - } else { - $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = true; + if ($this->application->settings->use_build_secrets) { + $this->dockerSecretsSupported = true; + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + } + } elseif ($this->application->settings->use_build_secrets) { $this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments."); } } @@ -494,6 +501,7 @@ private function detectBuildKitCapabilities(): void $this->dockerBuildkitSupported = false; $this->dockerBuildxAvailable = false; $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = false; $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); } } @@ -1638,11 +1646,14 @@ private function generate_buildtime_environment_variables() } foreach ($planVariables as $key => $value) { + $key = (string) $key; + // Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) { continue; } + $key = $this->validatedBuildtimeEnvironmentVariableKey($key, 'the Nixpacks plan'); $escapedValue = escapeBashEnvValue($value); $envs_dict[$key] = $escapedValue; @@ -1830,6 +1841,7 @@ private function generate_buildtime_environment_variables() // Convert dictionary back to collection in KEY=VALUE format $envs = collect([]); foreach ($envs_dict as $key => $value) { + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build-time environment'); $envs->push($key.'='.$value); } @@ -1843,44 +1855,130 @@ private function generate_buildtime_environment_variables() return $envs; } - private function save_buildtime_environment_variables() + private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string { - // Generate build-time environment variables locally - $environment_variables = $this->generate_buildtime_environment_variables(); - - // Save .env file for build phase in /artifacts to prevent it from being copied into Docker images - if ($environment_variables->isNotEmpty()) { - $envs_base64 = base64_encode($environment_variables->implode("\n")); - - $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); - - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'), - ] - ); - - if (isDev()) { - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH), - 'hidden' => true, - ] - ); + try { + if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) { + throw new \InvalidArgumentException('Invalid build-time environment variable key.'); } - } elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) { - // For build packs that source the build-time .env file, create an empty file even if there are no build-time variables - // This ensures the file exists when referenced in build commands - $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH), - ] + return $key; + } catch (\InvalidArgumentException $exception) { + $this->logInvalidBuildtimeEnvironmentVariableKey($key, $origin); + + throw new DeploymentException( + "Invalid environment variable name from {$origin}: ".ValidationPatterns::displayShellEnvironmentVariableKey($key).'. Names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', + previous: $exception, ); } } + private function logInvalidBuildtimeEnvironmentVariableKey(string $key, string $origin): void + { + $displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key); + + $this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr'); + $this->application_deployment_queue->addLogEntry("⚠️ Invalid environment variable name from {$origin}: {$displayKey}", 'stderr'); + $this->application_deployment_queue->addLogEntry('Build-time variable names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', 'stderr'); + $this->application_deployment_queue->addLogEntry('💡 How to fix:', type: 'info'); + + if ($origin === 'the Nixpacks plan') { + $this->application_deployment_queue->addLogEntry(' 1. Open nixpacks.toml and check the [variables] section. Quoted keys can contain characters that are not valid environment variable names.', type: 'info'); + $this->application_deployment_queue->addLogEntry(' 2. Rename the key to a plain name like MY_VARIABLE (no spaces, shell syntax, or command substitutions).', type: 'info'); + $this->logSuggestedShellEnvironmentVariableKey($key); + $this->application_deployment_queue->addLogEntry(' 3. Commit, push, and redeploy.', type: 'info'); + $this->application_deployment_queue->addLogEntry('Docs: https://nixpacks.com/docs/configuration/file', type: 'info'); + } else { + $this->application_deployment_queue->addLogEntry(' Rename the environment variable to use only letters, numbers, and underscores, then redeploy.', type: 'info'); + $this->logSuggestedShellEnvironmentVariableKey($key); + } + + $this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr'); + } + + private function logSuggestedShellEnvironmentVariableKey(string $key): void + { + $suggestedKey = str_replace('.', '_', $key); + if ($suggestedKey === $key || preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $suggestedKey) !== 1) { + return; + } + + $displaySuggestedKey = ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey); + + $this->application_deployment_queue->addLogEntry(" Suggested name: {$displaySuggestedKey}", type: 'info'); + } + + private function save_buildtime_environment_variables() + { + $environment_variables = $this->generate_buildtime_environment_variables(); + [$shell_environment_variables, $dotted_environment_variables] = $environment_variables->partition(function (string $environmentVariable): bool { + [$key] = explode('=', $environmentVariable, 2); + + return preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $key) === 1; + }); + + if ($dotted_environment_variables->isEmpty()) { + $this->useBuildtimeEnvironmentLauncher = false; + + if ($environment_variables->isNotEmpty()) { + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'), + ]); + + if (isDev()) { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH), + 'hidden' => true, + ]); + } + } elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) { + $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH), + ]); + } + + return; + } + + $this->useBuildtimeEnvironmentLauncher = true; + + $launcher = [ + '#!/bin/bash', + 'set -a', + 'source '.self::BUILD_TIME_SHELL_ENV_PATH, + 'set +a', + ]; + + $launcher[] = 'exec env \\'; + foreach ($dotted_environment_variables as $environmentVariable) { + $launcher[] = " {$environmentVariable} \\"; + } + $launcher[] = ' "$@"'; + + $files = [ + self::BUILD_TIME_ENV_PATH => $environment_variables->implode("\n"), + self::BUILD_TIME_SHELL_ENV_PATH => $shell_environment_variables->implode("\n"), + self::BUILD_TIME_ENV_LAUNCHER_PATH => implode("\n", $launcher)."\n", + ]; + + $this->application_deployment_queue->addLogEntry('Creating build-time environment files in /artifacts (outside Docker context).', hidden: true); + + foreach ($files as $path => $contents) { + $contents_base64 = base64_encode($contents); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '$contents_base64' | base64 -d | tee {$path} > /dev/null"), + ]); + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'chmod 700 '.self::BUILD_TIME_ENV_LAUNCHER_PATH), + ]); + } + private function elixir_finetunes() { if ($this->pull_request_id === 0) { @@ -1986,6 +2084,19 @@ private function rolling_update() } } + private function resolveContainerName(): string + { + if (str($this->application->settings->custom_internal_name)->isEmpty()) { + return generateApplicationContainerName($this->application, $this->pull_request_id); + } + + if ($this->pull_request_id === 0) { + return $this->application->settings->custom_internal_name; + } + + return addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id); + } + private function health_check() { try { @@ -2251,12 +2362,8 @@ private function deploy_to_additional_destinations() destination: $destination, no_questions_asked: true, ); - $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: ".route('project.application.deployment.show', [ - 'project_uuid' => data_get($this->application, 'environment.project.uuid'), - 'application_uuid' => data_get($this->application, 'uuid'), - 'deployment_uuid' => $deployment_uuid, - 'environment_uuid' => data_get($this->application, 'environment.uuid'), - ])); + $deployment_url = base_url().'/project/'.data_get($this->application, 'environment.project.uuid').'/environment/'.data_get($this->application, 'environment.uuid').'/application/'.data_get($this->application, 'uuid')."/deployment/{$deployment_uuid}"; + $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: {$deployment_url}"); } } @@ -2274,9 +2381,9 @@ private function set_coolify_variables() $fqdn = $this->preview->fqdn; } if (isset($fqdn)) { - $url = Url::fromString($fqdn); - $fqdn = $url->getHost(); - $url = $url->withHost($fqdn)->withPort(null)->__toString(); + $domains = str($fqdn)->explode(',')->map(fn (string $domain) => trim($domain))->filter(); + $url = $domains->map(fn (string $domain) => Url::fromString($domain)->withPort(null)->__toString())->implode(','); + $fqdn = $domains->map(fn (string $domain) => Url::fromString($domain)->getHost())->implode(','); if ((int) $this->application->compose_parsing_version >= 3) { $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' '; $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' '; @@ -3428,6 +3535,9 @@ private function generate_compose_file() $custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options); if ((bool) $this->application->settings->is_consistent_container_name_enabled) { $docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name]; + if ($this->container_name !== $this->application->uuid) { + unset($docker_compose['services'][$this->container_name]); + } if (count($custom_compose) > 0) { $ipv4 = data_get($custom_compose, 'ip.0'); $ipv6 = data_get($custom_compose, 'ip6.0'); @@ -3644,7 +3754,13 @@ private function build_static_image() */ private function wrap_build_command_with_env_export(string $build_command): string { - return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}"; + if (! $this->useBuildtimeEnvironmentLauncher) { + return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}"; + } + + $escapedBuildCommand = escapeBashEnvValue($build_command); + + return "cd {$this->workdir} && bash ".self::BUILD_TIME_ENV_LAUNCHER_PATH." /bin/bash -c {$escapedBuildCommand}"; } private function build_image() @@ -4027,7 +4143,10 @@ private function stop_running_container(bool $force = false) $this->application_deployment_queue->addLogEntry('Removing old containers.'); if ($this->newVersionIsHealthy || $force) { if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) { - $this->graceful_shutdown_container($this->container_name); + $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); + $this->containerNamesToRemove($containers)->each(function (string $containerName) { + $this->graceful_shutdown_container($containerName); + }); } else { $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); if ($this->pull_request_id === 0) { @@ -4066,6 +4185,16 @@ private function stop_running_container(bool $force = false) } } + private function containerNamesToRemove(Collection $containers): Collection + { + return $containers + ->pluck('Names') + ->push($this->container_name) + ->filter() + ->unique() + ->values(); + } + private function start_by_compose_file() { try { @@ -4154,6 +4283,21 @@ private function generate_build_env_variables() $this->analyzeBuildTimeVariables($variables); } + $requiresDottedEnvironmentSecrets = $this->application->build_pack === 'nixpacks' + && $variables->keys()->contains(fn ($key): bool => str_contains((string) $key, '.')); + + if ($requiresDottedEnvironmentSecrets) { + if (! $this->dockerSecretsAvailable) { + $dottedKeys = $variables->keys() + ->filter(fn ($key): bool => str_contains((string) $key, '.')) + ->implode(', '); + + throw new DeploymentException("Dotted Nixpacks build-time environment variable names require Docker BuildKit secret support: {$dottedKeys}. Rename these keys to use underscores instead of dots, or upgrade Docker on the build server."); + } + + $this->dockerSecretsSupported = true; + } + if ($this->dockerSecretsSupported) { $this->generate_build_secrets($variables); $this->build_args = ''; @@ -4418,7 +4562,7 @@ private function add_build_env_variables_to_dockerfile() private function modify_dockerfile_for_secrets($dockerfile_path) { // Only process if build secrets are enabled and we have secrets to mount - if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) { + if (empty($this->build_secrets)) { return; } @@ -4442,18 +4586,51 @@ private function modify_dockerfile_for_secrets($dockerfile_path) $this->generate_env_variables(); } - $variables = $this->env_args; + $variables = $this->application->build_pack === 'nixpacks' + ? collect($this->nixpacks_plan_json->get('variables')) + : $this->env_args; if ($variables->isEmpty()) { return; } + $dottedKeys = $variables->keys() + ->map(fn ($key): string => (string) $key) + ->filter(fn (string $key): bool => str_contains($key, '.')); + + if ($dottedKeys->isNotEmpty()) { + $originalDockerfile = $dockerfile; + $dockerfile = $dockerfile->map(function (string $line) use ($dottedKeys): ?string { + $trimmedLine = trim($line); + + if (! str_starts_with($trimmedLine, 'ARG ') && ! str_starts_with($trimmedLine, 'ENV ')) { + return $line; + } + + [$instruction, $arguments] = explode(' ', $trimmedLine, 2); + $filteredArguments = collect(preg_split('/\s+/', $arguments)) + ->reject(function (string $argument) use ($dottedKeys): bool { + $key = str($argument)->before('=')->toString(); + + return $dottedKeys->contains($key); + }); + + if ($filteredArguments->isEmpty()) { + return null; + } + + return $instruction.' '.$filteredArguments->implode(' '); + })->filter()->values(); + + $modified = $dockerfile->all() !== $originalDockerfile->values()->all(); + } + // Generate mount strings for all secrets $mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' '); // Add mount for the secrets hash to ensure cache invalidation $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; - $modified = false; + $modified ??= false; $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { $trimmed = ltrim($line); @@ -4950,11 +5127,21 @@ private function handleSuccessfulDeployment(): void // Reset restart count after successful deployment // This is done here (not in Livewire) to avoid race conditions // with GetContainersStatus reading old container restart counts - $this->application->update([ + $restartState = [ 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, - ]); + ]; + + if ($this->pull_request_id === 0) { + $restartState['restart_limit_reached'] = false; + } + + if ($this->pull_request_id === 0) { + $this->application->update($restartState); + } else { + $this->preview?->resetRestartLimit(); + } try { $this->application->markDeploymentConfigurationApplied($this->application_deployment_queue); diff --git a/app/Jobs/CheckDomainDnsJob.php b/app/Jobs/CheckDomainDnsJob.php index 1a7ceaeab..c013da25a 100644 --- a/app/Jobs/CheckDomainDnsJob.php +++ b/app/Jobs/CheckDomainDnsJob.php @@ -4,6 +4,7 @@ use App\Actions\Shared\CheckDomainDns; use App\Models\Application; +use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceApplication; use Illuminate\Bus\Queueable; @@ -23,7 +24,7 @@ class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue public int $timeout = 30; public function __construct( - public Application|ServiceApplication $resource, + public Application|ApplicationPreview|ServiceApplication $resource, public string $statusKey, public string $url, public ?Server $server, diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 054a739bc..e56b93c9e 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -2,6 +2,8 @@ namespace App\Jobs; +use App\Enums\ProxyStatus; +use App\Enums\ProxyTypes; use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; @@ -33,8 +35,13 @@ public function __construct( */ public function handle(): void { + $this->server->refresh(); $this->clearOutdatedInfo(); + if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value || $this->server->proxy->get('status') !== ProxyStatus::RUNNING->value) { + return; + } + // Detect current version (makes SSH call) $currentVersion = getTraefikVersionFromDockerCompose($this->server); @@ -116,7 +123,10 @@ public function handle(): void private function clearOutdatedInfo(): void { - $this->server->update(['traefik_outdated_info' => null]); + $this->server->update([ + 'detected_traefik_version' => null, + 'traefik_outdated_info' => null, + ]); } /** diff --git a/app/Jobs/CheckTraefikVersionJob.php b/app/Jobs/CheckTraefikVersionJob.php index ac94aa23f..0a9eeba00 100644 --- a/app/Jobs/CheckTraefikVersionJob.php +++ b/app/Jobs/CheckTraefikVersionJob.php @@ -19,6 +19,20 @@ class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + Server::query() + ->where(function ($query) { + $query->whereNull('proxy') + ->orWhere('proxy->type', '!=', ProxyTypes::TRAEFIK->value); + }) + ->where(function ($query) { + $query->whereNotNull('detected_traefik_version') + ->orWhereNotNull('traefik_outdated_info'); + }) + ->update([ + 'detected_traefik_version' => null, + 'traefik_outdated_info' => null, + ]); + // Load versions from cached data $traefikVersions = get_traefik_versions(); diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index f1635d6d4..52b4064fe 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -19,6 +19,11 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S public function __construct(public Server $server) {} + private static function helperContainersCommand(): string + { + return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\''; + } + public function handle(): void { try { @@ -36,7 +41,7 @@ public function handle(): void 'active_deployment_uuids' => $activeDeployments, ]); - $containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false); + $containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false); $helperContainers = collect(json_decode($containers)); if ($helperContainers->count() > 0) { diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 0b73ed0cf..b1f2c38b9 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -322,6 +322,7 @@ public function handle(): void 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_postgresql($database); } elseif (str($databaseType)->contains('mongo')) { if ($database === '*') { @@ -343,6 +344,7 @@ public function handle(): void 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mongodb($database); } elseif (str($databaseType)->contains('mysql')) { $this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp'; @@ -357,6 +359,7 @@ public function handle(): void 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mysql($database); } elseif (str($databaseType)->contains('mariadb')) { $this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp'; @@ -371,6 +374,7 @@ public function handle(): void 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mariadb($database); } elseif ($this->database instanceof StandaloneClickhouse) { $this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip"; @@ -382,6 +386,7 @@ public function handle(): void 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_clickhouse($database); } else { throw new \Exception('Unsupported database type'); @@ -480,14 +485,14 @@ public function handle(): void } catch (Throwable $e) { throw $e; } finally { - if ($this->team) { - BackupCreated::dispatch($this->team->id); - } if ($this->backup_log) { $this->backup_log->update([ 'finished_at' => Carbon::now()->toImmutable(), ]); } + if ($this->team) { + BackupCreated::dispatch($this->team->id); + } } } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 9c4a2531a..ef83d1944 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -2,11 +2,14 @@ namespace App\Jobs; +use App\Actions\Application\StopApplication; +use App\Actions\Application\StopApplicationPreview; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; use App\Actions\Server\StartLogDrain; +use App\Actions\Service\StopServiceApplication; use App\Actions\Shared\ComplexStatusCheck; use App\Models\Application; use App\Models\ApplicationPreview; @@ -23,8 +26,10 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\SwarmDocker; +use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached; use App\Notifications\Container\ContainerRestarted; use App\Services\ContainerStatusAggregator; +use App\Services\RestartCountTracker; use App\Traits\CalculatesExcludedStatus; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -95,8 +100,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced public Collection $applicationContainerStatuses; + public Collection $applicationContainerRestartCounts; + public Collection $serviceContainerStatuses; + public Collection $previewContainerRestartCounts; + + public Collection $serviceContainerRestartCounts; + public bool $foundProxy = false; public bool $foundLogDrainContainer = false; @@ -122,7 +133,10 @@ public function __construct(public Server $server, public $data) $this->foundApplicationPreviewsIds = collect(); $this->foundServiceDatabaseIds = collect(); $this->applicationContainerStatuses = collect(); + $this->applicationContainerRestartCounts = collect(); $this->serviceContainerStatuses = collect(); + $this->previewContainerRestartCounts = collect(); + $this->serviceContainerRestartCounts = collect(); $this->allApplicationIds = collect(); $this->allDatabaseUuids = collect(); $this->allTcpProxyUuids = collect(); @@ -140,7 +154,10 @@ public function handle() { // Defensive initialization for Collection properties to handle queue deserialization edge cases $this->serviceContainerStatuses ??= collect(); + $this->previewContainerRestartCounts ??= collect(); + $this->serviceContainerRestartCounts ??= collect(); $this->applicationContainerStatuses ??= collect(); + $this->applicationContainerRestartCounts ??= collect(); $this->foundApplicationIds ??= collect(); $this->foundDatabaseUuids ??= collect(); $this->foundServiceApplicationIds ??= collect(); @@ -231,6 +248,9 @@ public function handle() if (! $coolify_managed) { continue; } + if (filter_var($labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) { + continue; + } $name = data_get($container, 'name'); if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) { @@ -241,6 +261,10 @@ public function handle() $pullRequestId = $labels->get('coolify.pullRequestId', '0'); try { if ($pullRequestId === '0') { + $application = $this->applicationsById->get((string) $applicationId); + if ($application && $application->container_present !== true) { + $application->update(['container_present' => true]); + } if ($this->allApplicationIds->contains($applicationId)) { $this->foundApplicationIds->push($applicationId); } @@ -251,6 +275,13 @@ public function handle() $containerName = $labels->get('com.docker.compose.service'); if ($containerName) { $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus); + $restartCount = data_get($container, 'restart_count'); + if (is_numeric($restartCount)) { + if (! $this->applicationContainerRestartCounts->has($applicationId)) { + $this->applicationContainerRestartCounts->put($applicationId, collect()); + } + $this->applicationContainerRestartCounts->get($applicationId)->put($containerName, (int) $restartCount); + } } } else { $previewKey = $applicationId.':'.$pullRequestId; @@ -258,6 +289,13 @@ public function handle() $this->foundApplicationPreviewsIds->push($previewKey); } $this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus); + $restartCount = data_get($container, 'restart_count'); + if (is_numeric($restartCount)) { + $this->previewContainerRestartCounts->push([ + 'key' => $previewKey, + 'count' => (int) $restartCount, + ]); + } } } catch (\Exception $e) { } @@ -278,6 +316,7 @@ public function handle() $containerName = $labels->get('com.docker.compose.service'); if ($containerName) { $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); + $this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count')); } } elseif ($subType === 'database') { $this->foundServiceDatabaseIds->push($subId); @@ -289,6 +328,7 @@ public function handle() $containerName = $labels->get('com.docker.compose.service'); if ($containerName) { $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); + $this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count')); } } } else { @@ -302,9 +342,9 @@ public function handle() $this->foundDatabaseUuids->push($uuid); // TCP proxy should only be started/managed when database is actually running if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) { - $this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true); + $this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: true); } else { - $this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false); + $this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: false); } } } @@ -317,6 +357,9 @@ public function handle() $this->updateProxyStatus(); + Application::whereIn('id', $this->foundApplicationIds->unique()) + ->update(['container_present' => true]); + $this->updateNotFoundApplicationStatus(); $this->updateNotFoundApplicationPreviewStatus(); $this->updateNotFoundDatabaseStatus(); @@ -324,6 +367,8 @@ public function handle() $this->updateAdditionalServersStatus(); + $this->trackPreviewRestartCounts(); + // Aggregate multi-container application statuses $this->aggregateMultiContainerStatuses(); @@ -349,11 +394,18 @@ private function loadApplications(): Collection 'uuid', 'name', 'status', + 'container_present', 'build_pack', 'docker_compose_raw', + 'environment_id', 'destination_id', 'destination_type', 'last_online_at', + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', ]) ->withCount('additional_servers') ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds)) @@ -372,11 +424,18 @@ private function loadApplications(): Collection 'uuid', 'name', 'status', + 'container_present', 'build_pack', 'docker_compose_raw', + 'environment_id', 'destination_id', 'destination_type', 'last_online_at', + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', ]) ->withCount('additional_servers') ->whereIn('id', $additionalApplicationIds) @@ -402,6 +461,11 @@ private function loadPreviews(): Collection 'pull_request_id', 'status', 'last_online_at', + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', ]) ->whereIn('application_id', $applicationIds) ->get(); @@ -417,7 +481,7 @@ private function loadServices(): Collection 'docker_compose_raw', ]) ->with([ - 'applications:id,service_id,status,last_online_at', + 'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type', 'databases:id,service_id,status,last_online_at,is_public,name', ]) ->get(); @@ -495,6 +559,53 @@ private function aggregateMultiContainerStatuses() continue; } + $maxRestartCount = 0; + $restartCountsAvailable = $this->applicationContainerRestartCounts->has($applicationId); + if ($restartCountsAvailable) { + $maxRestartCount = $this->applicationContainerRestartCounts->get($applicationId)->max() ?? 0; + $restartState = (new RestartCountTracker)->evaluate( + previousRestartCount: $application->restart_count ?? 0, + observedRestartCount: $maxRestartCount, + maxRestartCount: $application->max_restart_count ?? 0, + ); + + if ($restartState['restart_count_changed']) { + $hasCrashRestarts = $restartState['restart_count'] > 0; + $application->update([ + 'restart_count' => $restartState['restart_count'], + 'last_restart_at' => $hasCrashRestarts ? now() : null, + 'last_restart_type' => $hasCrashRestarts ? 'crash' : null, + ]); + } + + if ($restartState['restart_limit_reached']) { + $restartLimitClaimed = Application::query() + ->whereKey($application->getKey()) + ->where('restart_limit_reached', false) + ->update(['restart_limit_reached' => true]) === 1; + + if ($restartLimitClaimed) { + $application->refresh(); + StopApplication::dispatch( + application: $application, + previewDeployments: false, + dockerCleanup: false, + resetRestartCount: false, + removeContainers: false, + ); + $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); + } + } + } + + if ($application->stoppedAfterRestartLimit() && $containerStatuses->every( + fn (string $status): bool => str($status)->contains('exited') + )) { + $application->update(['status' => 'exited']); + + continue; + } + // Parse docker compose to check for excluded containers $dockerComposeRaw = data_get($application, 'docker_compose_raw'); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); @@ -519,7 +630,7 @@ 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, preserveRestarting: true); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true); // Update application status with aggregated result if ($aggregatedStatus && $application->status !== $aggregatedStatus) { @@ -560,6 +671,14 @@ private function aggregateServiceContainerStatuses() continue; } + $restartCount = $this->serviceContainerRestartCounts->get($key)?->max() ?? 0; + if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) { + StopServiceApplication::dispatch($subResource, false, false); + $subResource->team()?->notify(new ApplicationRestartLimitReached($subResource)); + + continue; + } + // Parse docker compose from service to check for excluded containers $dockerComposeRaw = data_get($service, 'docker_compose_raw'); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); @@ -581,10 +700,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, preserveRestarting: true); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $restartCount, preserveRestarting: true); // Update service sub-resource status with aggregated result if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { @@ -627,8 +745,11 @@ private function updateNotFoundApplicationStatus() // Batch update: mark all not-found applications as exited (excluding already exited ones) Application::whereIn('id', $notFoundApplicationIds) - ->where('status', 'not like', 'exited%') - ->update(['status' => 'exited']); + ->update([ + 'status' => 'exited', + 'container_present' => false, + 'restart_limit_reached' => false, + ]); } private function updateNotFoundApplicationPreviewStatus() @@ -687,7 +808,7 @@ private function updateProxyStatus() } } - private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false) + private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, mixed $restartCount = null, bool $tcpProxy = false): void { $database = $this->databasesByUuid->get($databaseUuid); if (! $database) { @@ -697,6 +818,13 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta $database->status = $containerStatus; $database->save(); } + if (is_numeric($restartCount) && $restartCount > ($database->restart_count ?? 0)) { + $database->update([ + 'restart_count' => (int) $restartCount, + 'last_restart_at' => now(), + 'last_restart_type' => 'crash', + ]); + } if (! $this->isCompleteSnapshot()) { return; } @@ -719,6 +847,30 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta } } + private function storeServiceRestartCount(string $key, string $containerName, mixed $restartCount): void + { + if (! is_numeric($restartCount)) { + return; + } + if (! $this->serviceContainerRestartCounts->has($key)) { + $this->serviceContainerRestartCounts->put($key, collect()); + } + $this->serviceContainerRestartCounts->get($key)->put($containerName, (int) $restartCount); + } + + private function trackPreviewRestartCounts(): void + { + $this->previewContainerRestartCounts + ->groupBy('key') + ->each(function (Collection $counts, string $key): void { + $preview = $this->previewsByKey->get($key); + if ($preview?->trackRestartCount((int) $counts->max('count'))) { + StopApplicationPreview::dispatch($preview, false, false); + $preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview)); + } + }); + } + private function updateNotFoundDatabaseStatus() { $notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids); @@ -752,8 +904,9 @@ private function updateNotFoundServiceStatus() // Batch update service applications if ($notFoundServiceApplicationIds->isNotEmpty()) { ServiceApplication::whereIn('id', $notFoundServiceApplicationIds) + ->where('restart_limit_reached', false) ->where('status', '!=', 'exited') - ->update(['status' => 'exited']); + ->update(['status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null]); } // Batch update service databases diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php index 6f49cf30b..ed2d1c454 100644 --- a/app/Jobs/RegenerateSslCertJob.php +++ b/app/Jobs/RegenerateSslCertJob.php @@ -66,7 +66,10 @@ public function handle() caCert: $caCert->ssl_certificate, caKey: $caCert->ssl_private_key, ); - $regenerated->push($certificate); + $resource = $certificate->database; + if ($resource) { + $regenerated->push($resource); + } } catch (\Exception $e) { Log::error('Failed to regenerate SSL certificate: '.$e->getMessage()); } diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 03fa2b510..b0ab4d183 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -43,7 +43,7 @@ public function mount(string $destination_uuid) } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -85,7 +85,7 @@ public function delete() } $this->destination->delete(); - return redirect()->route('destination.index'); + return redirectRoute($this, 'destination.index'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index bf64ee8e9..c6ed818e9 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1507,8 +1507,7 @@ public function getServicesProperty() 'type' => 'one-click-service-'.$serviceKey, 'category' => 'Services', 'resourceType' => 'service', - 'logo' => data_get($service, 'logo'), - ] + array_filter([ + ] + service_logo_urls(data_get($service, 'logo')) + array_filter([ 'amd_only' => data_get($service, 'amd_only') ? true : null, 'arm_only' => data_get($service, 'arm_only') ? true : null, ])); diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 797db8362..8ea4bbc95 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -34,6 +34,9 @@ class Discord extends Component #[Validate(['boolean'])] public bool $statusChangeDiscordNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedDiscordNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessDiscordNotifications = false; @@ -82,17 +85,17 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->discord_enabled = $this->discordEnabled; $this->settings->discord_webhook_url = $this->discordWebhookUrl; $this->settings->deployment_success_discord_notifications = $this->deploymentSuccessDiscordNotifications; $this->settings->deployment_failure_discord_notifications = $this->deploymentFailureDiscordNotifications; $this->settings->status_change_discord_notifications = $this->statusChangeDiscordNotifications; + $this->settings->restart_limit_reached_discord_notifications = $this->restartLimitReachedDiscordNotifications; $this->settings->backup_success_discord_notifications = $this->backupSuccessDiscordNotifications; $this->settings->backup_failure_discord_notifications = $this->backupFailureDiscordNotifications; $this->settings->scheduled_task_success_discord_notifications = $this->scheduledTaskSuccessDiscordNotifications; @@ -118,6 +121,7 @@ public function syncData(bool $toModel = false) $this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications; $this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications; $this->statusChangeDiscordNotifications = $this->settings->status_change_discord_notifications; + $this->restartLimitReachedDiscordNotifications = $this->settings->restart_limit_reached_discord_notifications; $this->backupSuccessDiscordNotifications = $this->settings->backup_success_discord_notifications; $this->backupFailureDiscordNotifications = $this->settings->backup_failure_discord_notifications; $this->scheduledTaskSuccessDiscordNotifications = $this->settings->scheduled_task_success_discord_notifications; @@ -169,6 +173,7 @@ public function instantSaveDiscordEnabled() public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -179,6 +184,7 @@ public function submit() { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -188,6 +194,8 @@ public function submit() public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 3d95668b9..5bd55137b 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -79,6 +79,9 @@ class Email extends Component #[Validate(['boolean'])] public bool $statusChangeEmailNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedEmailNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessEmailNotifications = false; @@ -129,12 +132,11 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); $this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]); - $this->authorize('update', $this->settings); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; @@ -155,6 +157,7 @@ public function syncData(bool $toModel = false) $this->settings->deployment_success_email_notifications = $this->deploymentSuccessEmailNotifications; $this->settings->deployment_failure_email_notifications = $this->deploymentFailureEmailNotifications; $this->settings->status_change_email_notifications = $this->statusChangeEmailNotifications; + $this->settings->restart_limit_reached_email_notifications = $this->restartLimitReachedEmailNotifications; $this->settings->backup_success_email_notifications = $this->backupSuccessEmailNotifications; $this->settings->backup_failure_email_notifications = $this->backupFailureEmailNotifications; $this->settings->scheduled_task_success_email_notifications = $this->scheduledTaskSuccessEmailNotifications; @@ -193,6 +196,7 @@ public function syncData(bool $toModel = false) $this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications; $this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications; $this->statusChangeEmailNotifications = $this->settings->status_change_email_notifications; + $this->restartLimitReachedEmailNotifications = $this->settings->restart_limit_reached_email_notifications; $this->backupSuccessEmailNotifications = $this->settings->backup_success_email_notifications; $this->backupFailureEmailNotifications = $this->settings->backup_failure_email_notifications; $this->scheduledTaskSuccessEmailNotifications = $this->settings->scheduled_task_success_email_notifications; @@ -219,6 +223,8 @@ public function submit() public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); $this->dispatch('success', 'Email notifications settings updated.'); } diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index 3b7c3c6ae..7caacdb91 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -41,6 +41,9 @@ class Pushover extends Component #[Validate(['boolean'])] public bool $statusChangePushoverNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedPushoverNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessPushoverNotifications = false; @@ -86,11 +89,10 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->pushover_enabled = $this->pushoverEnabled; $this->settings->pushover_user_key = $this->pushoverUserKey; $this->settings->pushover_api_token = $this->pushoverApiToken; @@ -98,6 +100,7 @@ public function syncData(bool $toModel = false) $this->settings->deployment_success_pushover_notifications = $this->deploymentSuccessPushoverNotifications; $this->settings->deployment_failure_pushover_notifications = $this->deploymentFailurePushoverNotifications; $this->settings->status_change_pushover_notifications = $this->statusChangePushoverNotifications; + $this->settings->restart_limit_reached_pushover_notifications = $this->restartLimitReachedPushoverNotifications; $this->settings->backup_success_pushover_notifications = $this->backupSuccessPushoverNotifications; $this->settings->backup_failure_pushover_notifications = $this->backupFailurePushoverNotifications; $this->settings->scheduled_task_success_pushover_notifications = $this->scheduledTaskSuccessPushoverNotifications; @@ -125,6 +128,7 @@ public function syncData(bool $toModel = false) $this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications; $this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications; $this->statusChangePushoverNotifications = $this->settings->status_change_pushover_notifications; + $this->restartLimitReachedPushoverNotifications = $this->settings->restart_limit_reached_pushover_notifications; $this->backupSuccessPushoverNotifications = $this->settings->backup_success_pushover_notifications; $this->backupFailurePushoverNotifications = $this->settings->backup_failure_pushover_notifications; $this->scheduledTaskSuccessPushoverNotifications = $this->settings->scheduled_task_success_pushover_notifications; @@ -162,6 +166,7 @@ public function instantSavePushoverEnabled() public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -174,6 +179,7 @@ public function submit() { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -183,6 +189,8 @@ public function submit() public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index 9ee362402..fe84710bd 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -39,6 +39,9 @@ class Slack extends Component #[Validate(['boolean'])] public bool $statusChangeSlackNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedSlackNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessSlackNotifications = false; @@ -84,17 +87,17 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->slack_enabled = $this->slackEnabled; $this->settings->slack_webhook_url = $this->slackWebhookUrl; $this->settings->deployment_success_slack_notifications = $this->deploymentSuccessSlackNotifications; $this->settings->deployment_failure_slack_notifications = $this->deploymentFailureSlackNotifications; $this->settings->status_change_slack_notifications = $this->statusChangeSlackNotifications; + $this->settings->restart_limit_reached_slack_notifications = $this->restartLimitReachedSlackNotifications; $this->settings->backup_success_slack_notifications = $this->backupSuccessSlackNotifications; $this->settings->backup_failure_slack_notifications = $this->backupFailureSlackNotifications; $this->settings->scheduled_task_success_slack_notifications = $this->scheduledTaskSuccessSlackNotifications; @@ -118,6 +121,7 @@ public function syncData(bool $toModel = false) $this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications; $this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications; $this->statusChangeSlackNotifications = $this->settings->status_change_slack_notifications; + $this->restartLimitReachedSlackNotifications = $this->settings->restart_limit_reached_slack_notifications; $this->backupSuccessSlackNotifications = $this->settings->backup_success_slack_notifications; $this->backupFailureSlackNotifications = $this->settings->backup_failure_slack_notifications; $this->scheduledTaskSuccessSlackNotifications = $this->settings->scheduled_task_success_slack_notifications; @@ -153,6 +157,7 @@ public function instantSaveSlackEnabled() public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -165,6 +170,7 @@ public function submit() { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -174,6 +180,8 @@ public function submit() public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index b04d2c73d..51239b862 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -41,6 +41,9 @@ class Telegram extends Component #[Validate(['boolean'])] public bool $statusChangeTelegramNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedTelegramNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessTelegramNotifications = false; @@ -83,6 +86,9 @@ class Telegram extends Component #[Validate(['nullable', 'string'])] public ?string $telegramNotificationsStatusChangeThreadId = null; + #[Validate(['nullable', 'string', 'max:255'])] + public ?string $telegramNotificationsRestartLimitReachedThreadId = null; + #[Validate(['nullable', 'string'])] public ?string $telegramNotificationsBackupSuccessThreadId = null; @@ -128,11 +134,10 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->telegram_enabled = $this->telegramEnabled; $this->settings->telegram_token = $this->telegramToken; $this->settings->telegram_chat_id = $this->telegramChatId; @@ -140,6 +145,7 @@ public function syncData(bool $toModel = false) $this->settings->deployment_success_telegram_notifications = $this->deploymentSuccessTelegramNotifications; $this->settings->deployment_failure_telegram_notifications = $this->deploymentFailureTelegramNotifications; $this->settings->status_change_telegram_notifications = $this->statusChangeTelegramNotifications; + $this->settings->restart_limit_reached_telegram_notifications = $this->restartLimitReachedTelegramNotifications; $this->settings->backup_success_telegram_notifications = $this->backupSuccessTelegramNotifications; $this->settings->backup_failure_telegram_notifications = $this->backupFailureTelegramNotifications; $this->settings->scheduled_task_success_telegram_notifications = $this->scheduledTaskSuccessTelegramNotifications; @@ -155,6 +161,7 @@ public function syncData(bool $toModel = false) $this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId; $this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId; $this->settings->telegram_notifications_status_change_thread_id = $this->telegramNotificationsStatusChangeThreadId; + $this->settings->telegram_notifications_restart_limit_reached_thread_id = $this->telegramNotificationsRestartLimitReachedThreadId; $this->settings->telegram_notifications_backup_success_thread_id = $this->telegramNotificationsBackupSuccessThreadId; $this->settings->telegram_notifications_backup_failure_thread_id = $this->telegramNotificationsBackupFailureThreadId; $this->settings->telegram_notifications_scheduled_task_success_thread_id = $this->telegramNotificationsScheduledTaskSuccessThreadId; @@ -173,6 +180,21 @@ public function syncData(bool $toModel = false) if (auth()->user()->can('update', $this->settings)) { $this->telegramToken = $this->settings->telegram_token; $this->telegramChatId = $this->settings->telegram_chat_id; + $this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id; + $this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id; + $this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id; + $this->telegramNotificationsRestartLimitReachedThreadId = $this->settings->telegram_notifications_restart_limit_reached_thread_id; + $this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id; + $this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id; + $this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id; + $this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id; + $this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id; + $this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id; + $this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id; + $this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id; + $this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id; + $this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id; + $this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id; } else { $this->telegramToken = null; $this->telegramChatId = null; @@ -181,6 +203,7 @@ public function syncData(bool $toModel = false) $this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications; $this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications; $this->statusChangeTelegramNotifications = $this->settings->status_change_telegram_notifications; + $this->restartLimitReachedTelegramNotifications = $this->settings->restart_limit_reached_telegram_notifications; $this->backupSuccessTelegramNotifications = $this->settings->backup_success_telegram_notifications; $this->backupFailureTelegramNotifications = $this->settings->backup_failure_telegram_notifications; $this->scheduledTaskSuccessTelegramNotifications = $this->settings->scheduled_task_success_telegram_notifications; @@ -193,26 +216,13 @@ public function syncData(bool $toModel = false) $this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications; $this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications; - $this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id; - $this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id; - $this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id; - $this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id; - $this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id; - $this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id; - $this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id; - $this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id; - $this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id; - $this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id; - $this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id; - $this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id; - $this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id; - $this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id; } } public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -225,6 +235,7 @@ public function submit() { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -254,6 +265,8 @@ public function instantSaveTelegramEnabled() public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index fcf110778..a3480ada6 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -34,6 +34,9 @@ class Webhook extends Component #[Validate(['boolean'])] public bool $statusChangeWebhookNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedWebhookNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessWebhookNotifications = false; @@ -79,17 +82,17 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->webhook_enabled = $this->webhookEnabled; $this->settings->webhook_url = $this->webhookUrl; $this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications; $this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications; $this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications; + $this->settings->restart_limit_reached_webhook_notifications = $this->restartLimitReachedWebhookNotifications; $this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications; $this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications; $this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications; @@ -113,6 +116,7 @@ public function syncData(bool $toModel = false) $this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications; $this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications; $this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications; + $this->restartLimitReachedWebhookNotifications = $this->settings->restart_limit_reached_webhook_notifications; $this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications; $this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications; $this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications; @@ -147,6 +151,7 @@ public function instantSaveWebhookEnabled() public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -157,6 +162,7 @@ public function submit() { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -166,6 +172,8 @@ public function submit() public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index a20a1231b..04f58dadd 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -47,7 +47,7 @@ public function uploadAvatar(AvatarStorageService $avatarStorage): bool $avatarStorage->store(Auth::user(), $this->avatar); $this->reset('avatar'); - $this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp])); + $this->dispatch('avatar-updated', url: profile_avatar_url(Auth::user()->fresh())); $this->dispatch('success', 'Profile picture updated.'); return true; diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index bf84f385d..45e284c5d 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -99,7 +99,7 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 2f9370871..d7797935f 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -8,6 +8,7 @@ use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Application; use App\Models\Server; +use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -57,7 +58,7 @@ class Domains extends Component public ?string $editingService = null; - /** @var array */ + /** @var array */ public array $domainRows = []; /** When set, the next addSuggestedDomain call for this index skips the DNS block. */ @@ -70,6 +71,14 @@ class Domains extends Component public bool $showDomainConflictModal = false; + public bool $showPortWarningModal = false; + + public bool $forceUseUnknownPort = false; + + public ?int $unrecognizedPort = null; + + public ?string $pendingPortAction = null; + public bool $forceSaveDomains = false; public bool $forceSaveDns = false; @@ -485,32 +494,19 @@ protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOv /** * @param array $stored - * @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool} + * @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool, internal_port: ?int, has_port_override: bool} */ protected function domainRowFromStored(string $url, ?string $service, array $stored): array { $key = $this->domainDnsStatusKey($url, $service); $entry = $stored[$key] ?? null; + $port = $this->effectiveDomainInternalPort($url, $service); - if (is_array($entry) && filled(data_get($entry, 'status'))) { - return [ - 'url' => $url, - 'service' => $service, - 'dns_status' => (string) data_get($entry, 'status', 'pending'), - 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), - 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, - 'checked_at' => data_get($entry, 'checked_at'), - 'check_id' => data_get($entry, 'check_id'), - 'is_suggested' => false, - 'suggested_for' => null, - 'suggestion_label' => null, - 'needs_force_add' => false, - ]; - } - - return [ + $row = [ 'url' => $url, 'service' => $service, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], 'dns_status' => 'pending', 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, @@ -521,6 +517,112 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto 'suggestion_label' => null, 'needs_force_add' => false, ]; + + if (is_array($entry) && filled(data_get($entry, 'status'))) { + $row['dns_status'] = (string) data_get($entry, 'status', 'pending'); + $row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.'); + $row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp; + $row['checked_at'] = data_get($entry, 'checked_at'); + $row['check_id'] = data_get($entry, 'check_id'); + } + + return $row; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + protected function effectiveDomainInternalPort(string $url, ?string $service = null): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $this->application->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + $hasMapEntry = array_key_exists($canonical, $overrides); + + if ($hasMapEntry) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + if ($this->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + + $composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service); + if ($composePort !== null) { + return [ + 'internal_port' => $composePort, + 'has_port_override' => false, + ]; + } + + $exposed = $this->application->ports_exposes_array; + $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 + ? (int) $exposed[0] + : null; + + return [ + 'internal_port' => $defaultPort, + 'has_port_override' => false, + ]; + } + + /** + * @param array{scheme: string, host: string, port: string, path: string} $parts + */ + protected function portFromParts(array $parts): ?int + { + $port = trim((string) ($parts['port'] ?? '')); + if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) { + return null; + } + + return (int) $port; + } + + protected function currentRowPort(string $url): ?int + { + $canonical = DomainPortOverrides::withoutPort($url); + $override = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($override) && (int) $override > 0) { + return (int) $override; + } + + $legacy = DomainUrlParts::split($url)['port'] ?? ''; + + return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; + } + + protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + { + if ($this->forceUseUnknownPort || $port === null) { + return false; + } + if ($currentPort !== null && $port === $currentPort) { + return false; + } + + return $this->application->portRequiresConfirmation($port); + } + + protected function openPortWarning(?int $port, string $action): void + { + $this->unrecognizedPort = $port; + $this->pendingPortAction = $action; + $this->showPortWarningModal = true; } /** @@ -824,6 +926,31 @@ public function confirmDomainUsage(): void $this->addDomain(); } + public function confirmUseUnknownPort(): void + { + $this->authorize('update', $this->application); + $this->forceUseUnknownPort = true; + $this->showPortWarningModal = false; + $action = $this->pendingPortAction; + $this->pendingPortAction = null; + + if ($action === 'update') { + $this->updateDomain(); + + return; + } + + $this->addDomain(); + } + + public function cancelUseUnknownPort(): void + { + $this->showPortWarningModal = false; + $this->forceUseUnknownPort = false; + $this->unrecognizedPort = null; + $this->pendingPortAction = null; + } + /** * Clear pending conflict state when the modal is dismissed without confirmation. * confirmDomainUsage sets forceSaveDomains before closing the modal. @@ -848,7 +975,7 @@ public function addDomain(): void return; } - if ($this->newDomainPartsChanged) { + if ($this->newDomainPartsChanged || filled($this->newDomainParts['host'] ?? null)) { $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); } $this->validateOnly('newDomain'); @@ -867,15 +994,24 @@ public function addDomain(): void ->values() ->all(); $current = $this->currentDomainList($this->newDomainService); + $currentCanonicalDomains = $current->map( + fn (string $url): string => DomainPortOverrides::withoutPort($url) + ); foreach ($newUrls as $url) { - if ($current->contains($url)) { + if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) { $this->addError('newDomain', "Domain {$url} is already configured."); return; } } + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); + + return; + } + $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; if (! $this->saveDomainList($merged, $this->newDomainService)) { @@ -884,6 +1020,7 @@ public function addDomain(): void $this->forceSaveDomains = false; $this->pendingAction = null; + $this->forceUseUnknownPort = false; $serviceForCheck = $this->newDomainService; $this->resetAddDomainForm(); $this->dispatch('close-modal'); @@ -1007,6 +1144,7 @@ protected function checkUrlsDns(array $urls, ?string $service = null): void $skipDns = ! $this->dnsValidationEnabled || ! $server || $this->application->additional_servers->count() > 0; + $indexesToCheck = []; foreach ($this->domainRows as $index => $row) { $url = $row['url'] ?? null; @@ -1109,6 +1247,11 @@ public function startEdit(int $index): void $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $canonical = DomainPortOverrides::withoutPort($this->editingDomain); + $savedPort = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($savedPort)) { + $this->editingDomainParts['port'] = (string) $savedPort; + } $this->editingDomainPartsChanged = false; $this->editingService = $this->domainRows[$index]['service']; $this->resetEditDomainDnsGate(); @@ -1226,7 +1369,7 @@ public function updateDomain(): void return; } - if ($this->editingDomainPartsChanged) { + if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) { $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); } $this->validateOnly('editingDomain'); @@ -1243,13 +1386,29 @@ public function updateDomain(): void $service = $this->editingService; $wasNoindexed = $this->application->isDomainNoindexed($oldUrl); + if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) { + $portOverrides = $this->application->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]); + $this->application->domain_port_overrides = $portOverrides ?: null; + } + $current = $this->currentDomainList($service); - if ($newUrl !== $oldUrl && $current->contains($newUrl)) { + $otherCanonicalDomains = $current + ->reject(fn (string $url): bool => $url === $oldUrl) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)); + if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) { $this->addError('editingDomain', "Domain {$newUrl} is already configured."); return; } + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); + + return; + } + if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) { $dnsFailure = $this->findDnsFailureMessage([$newUrl]); if ($dnsFailure !== null) { @@ -1277,6 +1436,7 @@ public function updateDomain(): void $this->forceSaveDomains = false; $this->pendingAction = null; + $this->forceUseUnknownPort = false; $this->cancelEdit(); $this->dispatch('edit-domain-saved'); $this->dispatch('success', 'Domain updated.'); @@ -1322,6 +1482,28 @@ public function removeDomain(int $index): void } } + public function removeDomainByKey(string $domainKey): void + { + $index = collect($this->domainRows)->search( + fn (array $row): bool => ! ($row['is_suggested'] ?? false) + && hash_equals($domainKey, $this->domainRowKey($row)) + ); + + if ($index === false) { + return; + } + + $this->removeDomain((int) $index); + } + + /** + * @param array{url: string, service?: ?string} $row + */ + private function domainRowKey(array $row): string + { + return hash('sha256', $row['url'].'|'.($row['service'] ?? '')); + } + public function generateDomain(?string $serviceName = null): void { try { @@ -1800,6 +1982,8 @@ protected function saveDomainList( } } + $intendedComposeOverrides = null; + if ($this->isCompose) { if (blank($serviceName)) { $this->dispatch('error', 'A service is required for compose domains.'); @@ -1815,6 +1999,15 @@ protected function saveDomainList( $allDomains = []; } + $previousServiceUrls = $this->currentDomainList($serviceName); + $normalizedPorts = DomainPortOverrides::normalize($domainString, $this->application->domain_port_overrides); + $domainString = $normalizedPorts['fqdn']; + $intendedComposeOverrides = $this->mergeComposeDomainPortOverrides( + $previousServiceUrls, + $domainString, + $normalizedPorts['overrides'] ?? null, + ); + $existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : []; // Preserve stored redirect only — pending Direction dropdown values must not // persist until setServiceRedirect() runs. @@ -1823,6 +2016,7 @@ protected function saveDomainList( ]); $this->application->docker_compose_domains = json_encode($allDomains); + $this->application->domain_port_overrides = $intendedComposeOverrides; $this->application->fqdn = null; } else { $this->application->fqdn = $domainString; @@ -1849,12 +2043,47 @@ protected function saveDomainList( } $this->application->save(); + + if ($this->isCompose && ($this->application->domain_port_overrides ?? null) !== $intendedComposeOverrides) { + $this->application->domain_port_overrides = $intendedComposeOverrides; + $this->application->save(); + } + $this->resetDefaultLabels(); $this->dispatch('configurationChanged'); return true; } + /** + * @param Collection $previousServiceUrls + * @param array|null $incomingOverrides + * @return array|null + */ + protected function mergeComposeDomainPortOverrides( + Collection $previousServiceUrls, + ?string $newDomainString, + ?array $incomingOverrides, + ): ?array { + $merged = $this->application->domain_port_overrides ?? []; + $newCanonical = collect($this->splitDomains($newDomainString)) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->all(); + + foreach ($previousServiceUrls as $url) { + $canonical = DomainPortOverrides::withoutPort($url); + if (! in_array($canonical, $newCanonical, true)) { + unset($merged[$canonical]); + } + } + + foreach ($incomingOverrides ?? [] as $url => $port) { + $merged[$url] = (int) $port; + } + + return $merged ?: null; + } + protected function resetDefaultLabels(): void { try { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 34283cd47..8f0bb2385 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -4,6 +4,7 @@ use App\Actions\Application\GenerateConfig; use App\Jobs\ApplicationDeploymentJob; +use App\Livewire\Project\Service\Storage; use App\Models\Application; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; @@ -320,17 +321,6 @@ public function mount() } } $this->initialDockerComposeLocation = $this->application->docker_compose_location; - if ($this->application->build_pack === 'dockercompose' && ! $this->application->docker_compose_raw) { - // Only load compose file if user has update permission - try { - $this->authorize('update', $this->application); - $this->initLoadingCompose = true; - $this->dispatch('info', 'Loading docker compose file.'); - } catch (AuthorizationException $e) { - // User doesn't have update permission, skip loading compose file - } - } - if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) { $this->dispatch('configurationChanged'); } @@ -340,7 +330,7 @@ public function mount() $this->syncData(); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -530,7 +520,7 @@ public function loadComposeFile($isInit = false, $showToast = true, ?string $res $showToast && $this->dispatch('success', 'Docker compose file loaded.'); $this->dispatch('compose_loaded'); - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); $this->dispatch('refreshEnvs'); } catch (\Throwable $e) { // Refresh model to get restored values from Application::loadComposeFile @@ -607,14 +597,9 @@ public function updatedBuildPack() $this->resetDefaultLabels(false); } if ($this->buildPack === 'dockercompose') { - // Only update if user has permission - try { - $this->authorize('update', $this->application); - $this->fqdn = null; - $this->application->fqdn = null; - $this->application->settings->save(); - } catch (AuthorizationException $e) { - // User doesn't have update permission, just continue without saving + if (blank($this->dockerComposeLocation)) { + $this->dockerComposeLocation = '/docker-compose.yaml'; + $this->application->docker_compose_location = $this->dockerComposeLocation; } } if ($this->buildPack === 'static') { @@ -666,6 +651,8 @@ public function generateNginxConfiguration($type = 'static') public function resetDefaultLabels($manualReset = false) { + $this->authorize('update', $this->application); + try { if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) { return; diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php new file mode 100644 index 000000000..e6e9f5d60 --- /dev/null +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -0,0 +1,617 @@ + 'https', 'host' => '', 'port' => '', 'path' => '']; + + public ?string $newDomainService = null; + + public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public ?int $editingIndex = null; + + public bool $showPortWarningModal = false; + + public bool $forceUseUnknownPort = false; + + public ?int $unrecognizedPort = null; + + public ?string $pendingPortAction = null; + + public function mount(): void + { + $this->refreshDomains(); + if ($this->preview->application->build_pack === 'dockercompose') { + $this->newDomainService = $this->composeServices()[0] ?? null; + } + } + + public function render() + { + return view('livewire.project.application.preview-domains', [ + 'isCompose' => $this->preview->application->build_pack === 'dockercompose', + 'composeServices' => $this->composeServices(), + ]); + } + + public function addDomain(): void + { + $this->authorize('update', $this->preview->application); + if ($this->preview->application->build_pack === 'dockercompose' + && ($this->newDomainService === null || ! in_array($this->newDomainService, $this->composeServices(), true))) { + $this->addError('newDomainService', 'Select a valid Compose service.'); + + return; + } + $domain = $this->validatedDomain($this->newDomainParts, 'newDomainParts.host'); + if ($domain === null) { + return; + } + $canonicalDomain = DomainPortOverrides::withoutPort($domain); + if (collect($this->domainRows)->contains( + fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === $canonicalDomain + && $row['service'] === $this->newDomainService + )) { + $this->addError('newDomainParts.host', 'This domain is already configured.'); + + return; + } + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); + + return; + } + $this->domainRows[] = $this->makeRow($domain, $this->newDomainService); + $index = array_key_last($this->domainRows); + $checkId = new_public_id(); + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + if (! $this->persistDomains()) { + return; + } + $domain = $this->domainRows[$index]['url'] ?? DomainPortOverrides::withoutPort($domain); + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainService = $this->preview->application->build_pack === 'dockercompose' + ? ($this->composeServices()[0] ?? null) + : null; + $this->forceUseUnknownPort = false; + $this->dispatch('close-modal'); + + try { + $server = $this->preview->application->destination?->server; + CheckDomainDnsJob::dispatch( + $this->preview, + $this->statusKey($domain, $this->domainRows[$index]['service']), + $domain, + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $checkId, + $this->preview->application->additional_servers->count() > 0, + ); + $this->dispatch('success', 'Domain added. DNS check started.'); + } catch (\Throwable) { + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['check_id'] = null; + $this->persistDnsStatuses(); + $this->dispatch('error', 'Domain added, but the DNS check could not be started. Try again from the preview domains list.'); + } + } + + public function generateDomain(): void + { + $this->authorize('update', $this->preview->application); + $this->preview->refresh(); + if ($this->preview->application->build_pack === 'dockercompose') { + if ($this->newDomainService === null && $this->domainRows === []) { + $this->preview->generate_preview_fqdn_compose(generateWithoutApplicationDomain: true); + } else { + $service = $this->newDomainService ?? data_get($this->domainRows, '0.service'); + foreach ($this->generateComposeDomains((string) $service) as $domain) { + $alreadyExists = collect($this->domainRows)->contains( + fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === DomainPortOverrides::withoutPort($domain) + && $row['service'] === $service + ); + if (! $alreadyExists) { + $this->domainRows[] = $this->makeRow($domain, $service); + } + } + + if (! $this->persistDomains()) { + return; + } + } + } else { + $this->preview->generate_preview_fqdn(generateWithoutApplicationDomain: true); + } + $this->refreshDomains(); + $this->dispatch('success', 'Domain generated.'); + } + + public function startEdit(int $index): void + { + if (! isset($this->domainRows[$index])) { + return; + } + $this->editingIndex = $index; + $this->editingDomainParts = DomainUrlParts::split($this->domainRows[$index]['url']); + $canonical = DomainPortOverrides::withoutPort($this->domainRows[$index]['url']); + $savedPort = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($savedPort)) { + $this->editingDomainParts['port'] = (string) $savedPort; + } + $this->dispatch('open-preview-domain-edit'); + } + + public function updateDomain(): void + { + $this->authorize('update', $this->preview->application); + if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) { + return; + } + $domain = $this->validatedDomain($this->editingDomainParts, 'editingDomainParts.host'); + if ($domain === null) { + return; + } + $oldUrl = $this->domainRows[$this->editingIndex]['url']; + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); + + return; + } + if (blank(DomainUrlParts::split($domain)['port'] ?? null)) { + $portOverrides = $this->preview->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($domain)]); + $this->preview->domain_port_overrides = $portOverrides ?: null; + } + $this->domainRows[$this->editingIndex]['url'] = $domain; + $this->domainRows[$this->editingIndex]['dns_status'] = 'pending'; + $this->domainRows[$this->editingIndex]['dns_message'] = 'DNS has not been checked yet.'; + $index = $this->editingIndex; + $this->editingIndex = null; + if (! $this->persistDomains()) { + return; + } + $this->forceUseUnknownPort = false; + $this->dispatch('close-preview-domain-edit'); + $this->dispatch('success', 'Domain updated.'); + $this->checkDomainDns($index); + } + + public function confirmUseUnknownPort(): void + { + $this->authorize('update', $this->preview->application); + $this->forceUseUnknownPort = true; + $this->showPortWarningModal = false; + $action = $this->pendingPortAction; + $this->pendingPortAction = null; + + if ($action === 'update') { + $this->updateDomain(); + + return; + } + + $this->addDomain(); + } + + public function cancelUseUnknownPort(): void + { + $this->showPortWarningModal = false; + $this->forceUseUnknownPort = false; + $this->unrecognizedPort = null; + $this->pendingPortAction = null; + } + + public function removeDomain(int $index): void + { + $this->authorize('update', $this->preview->application); + if (! isset($this->domainRows[$index])) { + return; + } + unset($this->domainRows[$index]); + $this->domainRows = array_values($this->domainRows); + if (! $this->persistDomains()) { + return; + } + $this->dispatch('success', 'Domain removed.'); + } + + public function removeDomainByKey(string $domainKey): void + { + $index = collect($this->domainRows)->search( + fn (array $row): bool => hash_equals($domainKey, $this->statusKey($row['url'], $row['service'])) + ); + + if ($index === false) { + return; + } + + $this->removeDomain((int) $index); + } + + public function checkAllDns(): void + { + $this->authorize('update', $this->preview->application); + foreach (array_keys($this->domainRows) as $index) { + $this->applyDnsCheck($index); + } + $this->persistDnsStatuses(); + } + + public function checkDomainDns(int $index): void + { + $this->authorize('update', $this->preview->application); + $this->applyDnsCheck($index); + $this->persistDnsStatuses(); + } + + public function pollDnsChecks(): void + { + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && ($row['service'] ?? null) === ($checkingRow['service'] ?? null)); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + private function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + + private function applyDnsCheck(int $index): void + { + if (! isset($this->domainRows[$index])) { + return; + } + $result = $this->checkUrlDns($this->domainRows[$index]['url'], (string) $index); + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + } + + private function checkUrlDns(string $url, string $key = 'domain'): array + { + $server = $this->preview->application->destination?->server; + + return CheckDomainDns::run( + [$key => $url], + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $this->preview->application->additional_servers->count() > 0, + )[$key]; + } + + private function refreshDomains(): void + { + $this->preview->refresh(); + $statuses = $this->preview->domain_dns_statuses ?? []; + $rows = []; + if ($this->preview->application->build_pack === 'dockercompose') { + foreach (json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: [] as $service => $entry) { + foreach ($this->splitDomains(composeDomainEntryString($entry)) as $url) { + $rows[] = $this->makeRow($url, (string) $service, $statuses); + } + } + } else { + foreach ($this->splitDomains($this->preview->fqdn) as $url) { + $rows[] = $this->makeRow($url, null, $statuses); + } + } + $this->domainRows = $rows; + } + + private function persistDomains(): bool + { + if ($this->preview->application->build_pack === 'dockercompose') { + try { + $composeServices = $this->composeServices(failOnError: true); + } catch (\Throwable) { + $this->refreshDomains(); + $this->dispatch('error', 'Compose configuration could not be parsed. Preview domains were not changed.'); + + return false; + } + $domains = collect($composeServices) + ->mapWithKeys(fn (string $service): array => [$service => ['domain' => '']]) + ->all(); + $validRows = collect($this->domainRows) + ->filter(fn (array $row): bool => in_array($row['service'] ?? null, $composeServices, true)); + foreach ($validRows->groupBy('service') as $service => $rows) { + $domains[$service] = ['domain' => $rows->pluck('url')->implode(',')]; + } + $this->preview->docker_compose_domains = json_encode($domains); + $this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null; + } else { + $this->preview->fqdn = collect($this->domainRows)->pluck('url')->implode(',') ?: null; + } + $normalized = DomainPortOverrides::normalize($this->preview->fqdn, $this->preview->domain_port_overrides); + $this->preview->fqdn = $normalized['fqdn']; + $this->preview->domain_port_overrides = $normalized['overrides']; + if ($this->preview->application->build_pack === 'dockercompose' && is_array($domains ?? null)) { + foreach ($domains as $service => $entry) { + $serviceDomains = $this->splitDomains(composeDomainEntryString($entry)); + $domains[$service]['domain'] = collect($serviceDomains) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->implode(','); + } + $this->preview->docker_compose_domains = json_encode($domains); + } + foreach ($this->domainRows as $index => $row) { + $this->domainRows[$index]['url'] = DomainPortOverrides::withoutPort($row['url']); + } + $this->preview->save(); + $this->persistDnsStatuses(); + $this->refreshDomains(); + $this->dispatch('update_links'); + $this->dispatch('previewDomainsChanged'); + + return true; + } + + private function persistDnsStatuses(): void + { + $statuses = []; + foreach ($this->domainRows as $row) { + $statuses[$this->statusKey($row['url'], $row['service'])] = [ + 'status' => $row['dns_status'], + 'message' => $row['dns_message'], + 'check_id' => $row['check_id'] ?? null, + ]; + } + + DB::transaction(function () use (&$statuses): void { + $preview = ApplicationPreview::query()->lockForUpdate()->findOrFail($this->preview->id); + $storedStatuses = $preview->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $storedStatus = $storedStatuses[$key] ?? null; + if (! is_array($storedStatus)) { + continue; + } + + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatus['check_id'] ?? null; + + if (($storedCheckId !== null && $localCheckId !== $storedCheckId) + || ($status['status'] === 'checking' && ($storedStatus['status'] ?? null) !== 'checking')) { + $statuses[$key] = $storedStatus; + } + } + + $preview->domain_dns_statuses = $statuses ?: null; + $preview->save(); + }); + + $this->preview->domain_dns_statuses = $statuses ?: null; + } + + private function validatedDomain(array $parts, string $errorKey): ?string + { + $domain = DomainUrlParts::compose(...$parts); + $validator = validator(['domain' => $domain], ['domain' => ValidationPatterns::applicationDomainRules()]); + if ($validator->fails()) { + $this->addError($errorKey, $validator->errors()->first('domain')); + + return null; + } + + return ValidationPatterns::normalizeApplicationDomains($domain); + } + + private function makeRow(string $url, ?string $service, array $statuses = []): array + { + $status = $statuses[$this->statusKey($url, $service)] ?? []; + $port = $this->effectiveDomainInternalPort($url, $service); + + return [ + 'url' => $url, + 'service' => $service, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], + 'dns_status' => $status['status'] ?? 'pending', + 'dns_message' => $status['message'] ?? 'DNS has not been checked yet.', + 'check_id' => $status['check_id'] ?? null, + ]; + } + + /** + * @param array{scheme: string, host: string, port: string, path: string} $parts + */ + private function portFromParts(array $parts): ?int + { + $port = trim((string) ($parts['port'] ?? '')); + if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) { + return null; + } + + return (int) $port; + } + + private function currentRowPort(string $url): ?int + { + $canonical = DomainPortOverrides::withoutPort($url); + $override = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($override) && (int) $override > 0) { + return (int) $override; + } + + $legacy = DomainUrlParts::split($url)['port'] ?? ''; + + return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; + } + + private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + { + if ($this->forceUseUnknownPort || $port === null) { + return false; + } + if ($currentPort !== null && $port === $currentPort) { + return false; + } + + return $this->preview->application->portRequiresConfirmation($port); + } + + private function openPortWarning(?int $port, string $action): void + { + $this->unrecognizedPort = $port; + $this->pendingPortAction = $action; + $this->showPortWarningModal = true; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + private function effectiveDomainInternalPort(string $url, ?string $service = null): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $this->preview->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + $hasMapEntry = array_key_exists($canonical, $overrides); + + if ($hasMapEntry) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + if ($this->preview->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + + $composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service); + if ($composePort !== null) { + return [ + 'internal_port' => $composePort, + 'has_port_override' => false, + ]; + } + + $exposed = $this->preview->application->ports_exposes_array; + $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 + ? (int) $exposed[0] + : null; + + return [ + 'internal_port' => $defaultPort, + 'has_port_override' => false, + ]; + } + + private function statusKey(string $url, ?string $service): string + { + return hash('sha256', $url.'|'.($service ?? '')); + } + + private function splitDomains(?string $domains): array + { + return str($domains)->explode(',')->map(fn ($domain) => trim((string) $domain))->filter()->values()->all(); + } + + private function generateComposeDomains(string $service): array + { + $applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []; + $domainString = getComposeServiceDomainString($applicationDomains, $service); + + if (empty($domainString)) { + $domainString = generateUrl( + server: $this->preview->application->destination->server, + random: str($service)->slug().'-'.$this->preview->application->uuid, + ); + } + + return collect($this->splitDomains($domainString))->map(function (string $domain): string { + $generated = $this->preview->generatedPreviewDomain($domain); + if (filled($generated['port'])) { + $overrides = $this->preview->domain_port_overrides ?? []; + $overrides[$generated['url']] = $generated['port']; + $this->preview->domain_port_overrides = $overrides; + } + + return $generated['url']; + })->all(); + } + + private function composeServices(bool $failOnError = false): array + { + try { + $parsedCompose = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id); + $services = data_get($parsedCompose, 'services', []); + if (! is_iterable($services)) { + return []; + } + + $previewSuffix = '-pr-'.$this->preview->pull_request_id; + $serviceNames = []; + foreach ($services as $serviceName => $service) { + if (isDatabaseImage(data_get($service, 'image'))) { + continue; + } + + $serviceName = (string) $serviceName; + if (str_ends_with($serviceName, $previewSuffix)) { + $serviceName = substr($serviceName, 0, -strlen($previewSuffix)); + } + $serviceNames[] = $serviceName; + } + + return array_values(array_unique($serviceNames)); + } catch (\Throwable $exception) { + if ($failOnError) { + throw $exception; + } + + return []; + } + } +} diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index e07a985b4..fa0272bd6 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -7,7 +7,6 @@ use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\ApplicationPreview; -use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; @@ -16,6 +15,8 @@ class Previews extends Component { use AuthorizesRequests; + protected $listeners = ['previewDomainsChanged' => 'refreshPreviewDomains']; + public Application $application; public string $deployment_uuid; @@ -26,16 +27,6 @@ class Previews extends Component public int $rate_limit_remaining; - public $domainConflicts = []; - - public $showDomainConflictModal = false; - - public $forceSaveDomains = false; - - public $pendingPreviewId = null; - - public array $previewFqdns = []; - public array $previewDockerTags = []; public ?int $manualPullRequestId = null; @@ -43,7 +34,6 @@ class Previews extends Component public ?string $manualDockerTag = null; protected $rules = [ - 'previewFqdns.*' => 'string|nullable', 'previewDockerTags.*' => 'string|nullable', 'manualPullRequestId' => 'integer|min:1|nullable', 'manualDockerTag' => 'string|nullable', @@ -53,31 +43,23 @@ public function mount() { $this->pull_requests = collect(); $this->parameters = get_route_parameters(); - $this->syncData(false); + $this->syncDockerTags(); } - private function syncData(bool $toModel = false): void + private function syncDockerTags(): void { - if ($toModel) { - foreach ($this->previewFqdns as $key => $fqdn) { - $preview = $this->application->previews->get($key); - if ($preview) { - $preview->fqdn = $fqdn; - if ($this->application->build_pack === 'dockerimage') { - $preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null; - } - } - } - } else { - $this->previewFqdns = []; - $this->previewDockerTags = []; - foreach ($this->application->previews as $key => $preview) { - $this->previewFqdns[$key] = $preview->fqdn; - $this->previewDockerTags[$key] = $preview->docker_registry_image_tag; - } + $this->previewDockerTags = []; + foreach ($this->application->previews as $key => $preview) { + $this->previewDockerTags[$key] = $preview->docker_registry_image_tag; } } + public function refreshPreviewDomains(): void + { + $this->application->refresh(); + $this->syncDockerTags(); + } + public function load_prs() { try { @@ -92,103 +74,28 @@ public function load_prs() } } - public function confirmDomainUsage() - { - $this->forceSaveDomains = true; - $this->showDomainConflictModal = false; - if ($this->pendingPreviewId) { - $this->save_preview($this->pendingPreviewId); - $this->pendingPreviewId = null; - } - } - public function save_preview($preview_id) { try { $this->authorize('update', $this->application); - $success = true; $preview = $this->application->previews->find($preview_id); if (! $preview) { throw new \Exception('Preview not found'); } - // Find the key for this preview in the collection $previewKey = $this->application->previews->search(function ($item) use ($preview_id) { return $item->id == $preview_id; }); - if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) { - $this->validate([ - "previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(), - ]); - - $fqdn = $this->previewFqdns[$previewKey]; - - if (! empty($fqdn)) { - $fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn); - $this->previewFqdns[$previewKey] = $fqdn; - - if (! validateDNSEntry($fqdn, $this->application->destination->server)) { - $server = $this->application->destination->server; - $target = serverDnsTargetIp($server) ?? $server->ip; - $guidance = dnsMismatchGuidanceMessage($target, $target); - $this->dispatch('error', 'Validating DNS failed.', "{$guidance}

Check this documentation for further help."); - $success = false; - } - - // Check for domain conflicts if not forcing save - if (! $this->forceSaveDomains) { - $result = checkDomainUsage(resource: $this->application, domain: $fqdn); - if ($result['hasConflicts']) { - $this->domainConflicts = $result['conflicts']; - $this->showDomainConflictModal = true; - $this->pendingPreviewId = $preview_id; - - return; - } - } else { - // Reset the force flag after using it - $this->forceSaveDomains = false; - } - } + if ($previewKey === false) { + throw new \Exception('Preview not found'); } - if ($success) { - $this->syncData(true); - $preview->save(); - $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); - } - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function generate_preview($preview_id) - { - try { - $this->authorize('update', $this->application); - - $preview = $this->application->previews->find($preview_id); - if (! $preview) { - $this->dispatch('error', 'Preview not found.'); - - return; - } - if ($this->application->build_pack === 'dockercompose') { - $preview->generate_preview_fqdn_compose(); - $this->application->refresh(); - $this->syncData(false); - $this->dispatch('success', 'Domain generated.'); - - return; - } - - $preview->generate_preview_fqdn(); - $this->application->refresh(); - $this->syncData(false); - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain generated.'); + $this->validateOnly("previewDockerTags.{$previewKey}"); + $preview->docker_registry_image_tag = $this->previewDockerTags[$previewKey] ?? null; + $preview->save(); + $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -211,7 +118,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null, } $found->generate_preview_fqdn_compose(); $this->application->refresh(); - $this->syncData(false); + $this->syncDockerTags(); } else { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); @@ -227,9 +134,9 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null, $found->docker_registry_image_tag = $docker_registry_image_tag; $found->save(); } - $found->generate_preview_fqdn(); + $found->generate_preview_fqdn(generateWithoutApplicationDomain: true); $this->application->refresh(); - $this->syncData(false); + $this->syncDockerTags(); $this->dispatch('update_links'); $this->dispatch('success', 'Preview added.'); } diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php deleted file mode 100644 index 0fdcf4615..000000000 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ /dev/null @@ -1,165 +0,0 @@ -domain = data_get($this->service, 'domain'); - } - - public function render() - { - return view('livewire.project.application.previews-compose'); - } - - public function save() - { - try { - $this->authorize('update', $this->preview->application); - $this->validate([ - 'domain' => ValidationPatterns::applicationDomainRules(), - ]); - - $this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain); - $this->persistPreviewDomain($this->domain); - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain saved.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function generate() - { - try { - $this->authorize('update', $this->preview->application); - - $applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []; - $domain_string = getComposeServiceDomainString($applicationDomains, (string) $this->serviceName); - - // If no domain is set in the main application, generate a default domain - if (empty($domain_string)) { - $server = $this->preview->application->destination->server; - $template = $this->preview->application->preview_url_template; - $random = new_public_id(); - - // Generate a unique domain like main app services do - $generated_fqdn = generateUrl(server: $server, random: $random); - - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; - } else { - foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) { - throw new \InvalidArgumentException($error); - } - - // Use the existing domain from the main application - // Handle multiple domains separated by commas - $domain_list = ValidationPatterns::applicationDomainList($domain_string); - $preview_fqdns = []; - $template = $this->preview->application->preview_url_template; - $random = new_public_id(); - - foreach ($domain_list as $single_domain) { - $single_domain = trim($single_domain); - if (empty($single_domain)) { - continue; - } - - $url = Url::fromString($single_domain); - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdns[] = "$schema://$preview_fqdn{$port}"; - } - - $preview_fqdn = implode(',', $preview_fqdns); - } - - $this->domain = $preview_fqdn; - $this->persistPreviewDomain($this->domain); - - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain generated.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - private function persistPreviewDomain(?string $domain): void - { - $docker_compose_domains = json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []; - $serviceNames = $this->previewServiceNames($docker_compose_domains); - $storageKey = findComposeServiceName((string) $this->serviceName, $serviceNames) - ?? (string) $this->serviceName; - - $docker_compose_domains = putComposeServiceDomain( - $docker_compose_domains, - $storageKey, - $domain, - $serviceNames, - ); - $docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames); - - $this->serviceName = $storageKey; - $this->preview->docker_compose_domains = json_encode($docker_compose_domains); - $this->preview->save(); - } - - /** - * @param array $previewDomains - * @return list - */ - private function previewServiceNames(array $previewDomains): array - { - $parsedServices = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id); - $fromCompose = collect(data_get($parsedServices, 'services', [])) - ->keys() - ->map(function ($serviceName) { - return str((string) $serviceName) - ->replaceLast('-pr-'.$this->preview->pull_request_id, '') - ->toString(); - }) - ->all(); - - $domainKeys = collect(array_keys($previewDomains)) - ->merge(array_keys(json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [])) - ->map(fn ($name) => (string) $name); - $unmapped = $domainKeys - ->reject(fn (string $key) => findComposeServiceName($key, $fromCompose) !== null) - ->all(); - - return collect($fromCompose) - ->merge(preferredComposeServiceNamesFromDomainKeys( - $fromCompose === [] ? $domainKeys->all() : $unmapped - )) - ->unique() - ->values() - ->all(); - } -} diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 29f798d59..60a795573 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -65,7 +65,7 @@ public function updatedGitCommitSha() $this->gitCommitSha = trim($this->gitCommitSha); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Application/Swarm.php b/app/Livewire/Project/Application/Swarm.php index 661578fb3..ac867e69a 100644 --- a/app/Livewire/Project/Application/Swarm.php +++ b/app/Livewire/Project/Application/Swarm.php @@ -31,7 +31,7 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 2c04f5ba9..28f7e0255 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -128,7 +128,7 @@ public function refreshStatus(): void $this->status = $database->status; } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->backup->enabled = $this->backupEnabled; @@ -212,14 +212,14 @@ public function delete($password, $selectedActions = []) if ($this->backup->database->getMorphClass() === ServiceDatabase::class) { $serviceDatabase = $this->backup->database; - return redirect()->route('project.service.database.backups', [ + return redirectRoute($this, 'project.service.database.backups', [ 'project_uuid' => $this->parameters['project_uuid'], 'environment_uuid' => $this->parameters['environment_uuid'], 'service_uuid' => $serviceDatabase->service->uuid, 'stack_service_uuid' => $serviceDatabase->uuid, ]); } else { - return redirect()->route('project.database.backup.index', [ + return redirectRoute($this, 'project.database.backup.index', [ 'project_uuid' => $this->parameters['project_uuid'], 'environment_uuid' => $this->parameters['environment_uuid'], 'database_uuid' => $this->parameters['database_uuid'], diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 73877a945..2786a45c3 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -6,7 +6,6 @@ use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class BackupExecutions extends Component @@ -37,12 +36,12 @@ class BackupExecutions extends Component public $delete_backup_sftp = false; - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); + $teamId = currentTeam()->id; return [ - "echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions', + "echo-private:team.{$teamId},BackupCreated" => 'refreshBackupExecutions', ]; } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index ad5e45b3f..1d8354a4f 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -121,7 +121,7 @@ protected function messages(): array ); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -199,6 +199,7 @@ public function instantSave() } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 2f5b84484..a8bde2f00 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -115,7 +115,7 @@ protected function messages(): array ); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -191,6 +191,7 @@ public function instantSave() } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 943f22702..f2f8fa387 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -35,6 +35,12 @@ public function getListeners() public function activityFinished() { + if (auth()->user()->cannot('update', $this->database)) { + $this->dispatch('refresh'); + + return; + } + try { // Only set started_at if database is actually running if ($this->database->isRunning()) { diff --git a/app/Livewire/Project/Database/Health.php b/app/Livewire/Project/Database/Health.php index 8943e6316..07373bdba 100644 --- a/app/Livewire/Project/Database/Health.php +++ b/app/Livewire/Project/Database/Health.php @@ -34,7 +34,7 @@ public function mount(): void $this->syncData(); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Database/InitScript.php b/app/Livewire/Project/Database/InitScript.php index 7074c235d..eba1c4d8f 100644 --- a/app/Livewire/Project/Database/InitScript.php +++ b/app/Livewire/Project/Database/InitScript.php @@ -22,6 +22,9 @@ class InitScript extends Component #[Locked] public int $index; + #[Locked] + public string $originalFilename; + #[Validate(['nullable', 'string'])] public ?string $filename = null; @@ -33,6 +36,7 @@ public function mount() try { $this->index = data_get($this->script, 'index'); $this->filename = data_get($this->script, 'filename'); + $this->originalFilename = (string) data_get($this->script, 'filename'); $this->content = data_get($this->script, 'content'); } catch (Exception $e) { return handleError($e, $this); @@ -47,7 +51,7 @@ public function submit() $this->script['index'] = $this->index; $this->script['content'] = $this->content; $this->script['filename'] = $this->filename; - $this->dispatch('save_init_script', $this->script); + $this->dispatch('save_init_script', $this->script, $this->originalFilename); } catch (Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index b2d9bce91..0398362bb 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -118,7 +118,7 @@ protected function messages(): array ); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -196,6 +196,7 @@ public function instantSave() } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 61280a34b..4d2dd9d8c 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -136,7 +136,7 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -244,6 +244,7 @@ public function instantSave() } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index f68ba82c7..d3545564e 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -128,7 +128,7 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -237,6 +237,7 @@ public function instantSave() } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 1adfe2ea7..ce7fc01ec 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -136,7 +136,7 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -244,6 +244,7 @@ public function instantSave() } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 051fb515d..3d0406956 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -149,7 +149,7 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -240,6 +240,7 @@ public function instantSave(?bool $isPublic = null) } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -247,16 +248,16 @@ public function instantSave(?bool $isPublic = null) } } - public function save_init_script($script) + public function save_init_script($script, string $originalFilename) { $this->authorize('update', $this->database); $initScripts = collect($this->initScripts ?? []); $existingScript = $initScripts->firstWhere('filename', $script['filename']); - $oldScript = $initScripts->firstWhere('index', $script['index']); + $oldScript = $initScripts->firstWhere('filename', $originalFilename); - if ($existingScript && $existingScript['index'] !== $script['index']) { + if ($existingScript && $script['filename'] !== $originalFilename) { $this->dispatch('error', 'A script with this filename already exists.'); return; @@ -285,11 +286,10 @@ public function save_init_script($script) } } - $index = $initScripts->search(function ($item) use ($script) { - return $item['index'] === $script['index']; - }); + $index = $initScripts->search(fn ($item) => $item['filename'] === $originalFilename); if ($index !== false) { + $script['index'] = $oldScript['index']; $initScripts[$index] = $script; } else { $initScripts->push($script); diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index d431b1506..7c6313c8d 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -127,7 +127,7 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -235,6 +235,7 @@ public function instantSave() } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Edit.php b/app/Livewire/Project/Edit.php index 91b0444f5..0d42c71e9 100644 --- a/app/Livewire/Project/Edit.php +++ b/app/Livewire/Project/Edit.php @@ -77,7 +77,7 @@ public function mount(string $project_uuid) } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/EnvironmentEdit.php b/app/Livewire/Project/EnvironmentEdit.php index 9b9a3670d..35db3167d 100644 --- a/app/Livewire/Project/EnvironmentEdit.php +++ b/app/Livewire/Project/EnvironmentEdit.php @@ -48,7 +48,7 @@ public function mount(string $project_uuid, string $environment_uuid) } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 2b472a1a2..f43b7542e 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -53,10 +53,7 @@ public function render(): View 'uuid' => $project->uuid, 'name' => $project->name, 'description' => $project->description, - 'iconUrl' => $project->icon_path ? route('project.icon', [ - 'project_uuid' => $project->uuid, - 'v' => $project->updated_at->timestamp, - ]) : null, + 'iconUrl' => $project->icon_path ? project_icon_url($project) : null, 'href' => $project->navigateTo(), 'environmentCount' => $project->environments->count(), 'resourceCount' => $resourceCount, diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 4cffff800..ba03041e2 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -111,38 +111,14 @@ public function loadServices() $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services); $services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) { - $default_logo = 'svgs/default.webp'; - $logo = data_get($service, 'logo'); - - if (is_string($logo) && str_starts_with($logo, 'svg/')) { - $normalizedLogo = 'svgs/'.str($logo)->after('svg/'); - if (file_exists(public_path($normalizedLogo))) { - $logo = $normalizedLogo; - } - } - - $hasLogo = is_string($logo) - && basename($logo) !== basename($default_logo) - && file_exists(public_path($logo)); - - if (! $hasLogo) { - $logo = $default_logo; - } - - $local_logo_path = public_path($logo); $serviceKey = (string) $key; return [ 'id' => $serviceKey, 'name' => str($serviceKey)->headline(), 'docsSlug' => str($serviceKey)->lower()->value(), - 'has_logo' => $hasLogo, - 'logo' => asset($logo), - 'logo_github_url' => file_exists($local_logo_path) - ? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo - : asset($default_logo), 'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null, - ] + (array) $service; + ] + service_logo_urls(data_get($service, 'logo')) + (array) $service; })->all(); // Extract unique categories from services diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index 93633246a..7c375d0e0 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -187,6 +187,11 @@ private function toSearchableArray(Collection $items, string $type, string $type 'fqdn' => $item->fqdn ?? null, 'description' => $item->description ?? null, 'status' => $item->status ?? '', + 'restartLimitReached' => method_exists($item, 'stoppedAfterRestartLimit') && $item->stoppedAfterRestartLimit(), + 'restartCount' => method_exists($item, 'stoppedAfterRestartLimit') && $item->stoppedAfterRestartLimit() + ? max($item->restart_count ?? 0, $item->max_restart_count ?? 0) + : ($item->restart_count ?? 0), + 'maxRestartCount' => $item->max_restart_count ?? 0, 'server_status' => $item->server_status ?? null, 'hrefLink' => $item->hrefLink ?? '', 'destination' => [ diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php new file mode 100644 index 000000000..87f24fb1d --- /dev/null +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -0,0 +1,124 @@ +id; + + return [ + 'modalClosed' => 'closeExecutionModal', + "echo-private:team.{$teamId},BackupCreated" => '$refresh', + ]; + } + + public function mount(Service $service): void + { + abort_unless($service->environment?->project?->team_id === currentTeam()->id, 404); + $this->service = $service; + $this->authorize('view', $this->service); + } + + public function openExecution(string $executionUuid): void + { + $this->selectedExecution = $this->executions()->firstWhere('uuid', $executionUuid); + abort_unless($this->selectedExecution, 404); + $this->executionModalOpen = true; + } + + public function closeExecutionModal(): void + { + $this->executionModalOpen = false; + $this->selectedExecution = null; + } + + public function render(): View + { + return view('livewire.project.service.backup-executions', [ + 'executions' => $this->executions(), + ]); + } + + private function executions(): Collection + { + $databaseScheduleIds = ScheduledDatabaseBackup::query() + ->where('database_type', (new ServiceDatabase)->getMorphClass()) + ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) + ->pluck('id'); + + $databaseExecutions = ScheduledDatabaseBackupExecution::query() + ->with('scheduledDatabaseBackup.database') + ->whereIn('scheduled_database_backup_id', $databaseScheduleIds) + ->latest() + ->limit(100) + ->get() + ->map(fn (ScheduledDatabaseBackupExecution $execution): array => [ + 'id' => 'database:'.$execution->id, + 'uuid' => $execution->uuid, + 'target' => $execution->scheduledDatabaseBackup->database->human_name ?: $execution->scheduledDatabaseBackup->database->name, + 'type' => 'Database', + 'schedule' => $execution->scheduledDatabaseBackup->frequency, + 'status' => $execution->status, + 'started_at' => $execution->created_at, + 'size' => $execution->size, + 'message' => $execution->message, + 'filename' => $execution->filename, + 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted + ? route('download.backup', $execution->id) + : null, + ]); + + $volumeSchedules = ScheduledVolumeBackup::query() + ->with('backupable.resource') + ->forService($this->service) + ->get() + ->keyBy('id'); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->whereIn('scheduled_volume_backup_id', $volumeSchedules->keys()) + ->latest() + ->limit(100) + ->get() + ->map(function (ScheduledVolumeBackupExecution $execution) use ($volumeSchedules): array { + $schedule = $volumeSchedules->get($execution->scheduled_volume_backup_id); + + return [ + 'id' => 'storage:'.$execution->id, + 'uuid' => $execution->uuid, + 'target' => $schedule->targetName(), + 'type' => $schedule->targetType(), + 'schedule' => $schedule->frequency, + 'status' => $execution->status, + 'started_at' => $execution->created_at, + 'size' => $execution->size, + 'message' => $execution->message, + 'filename' => $execution->filename, + 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted + ? route('download.volume-backup', $execution->id) + : null, + ]; + }); + + return $databaseExecutions->concat($volumeExecutions)->sortByDesc('started_at')->values(); + } +} diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php index 90907abc6..8535584dd 100644 --- a/app/Livewire/Project/Service/DatabaseBackups.php +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -22,8 +22,6 @@ class DatabaseBackups extends Component public array $query; - public bool $isImportSupported = false; - public ?ScheduledDatabaseBackup $backup = null; public string $section = 'index'; @@ -32,7 +30,7 @@ class DatabaseBackups extends Component protected $listeners = ['refreshScheduledBackups' => '$refresh']; - public function mount() + public function mount(): mixed { try { $this->parameters = array_filter( @@ -67,10 +65,13 @@ public function mount() return redirect()->route('project.service.index', $this->parameters); } - // Check if import is supported for this database type - $dbType = $this->serviceDatabase->databaseType(); - $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; - $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); + if (! request()->route('backup_uuid')) { + return redirect()->route('project.service.volume-backups.index', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]); + } if (request()->route('backup_uuid')) { $this->backup = $this->serviceDatabase->scheduledBackups() @@ -85,6 +86,14 @@ public function mount() 'project.service.database.backup.danger' => 'danger', default => 'general', }; + + $routeParameters = [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]; + + return redirect()->route('project.service.volume-backups.index', $routeParameters); } } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index d932e7649..d5254e093 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -9,6 +9,7 @@ use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; +use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -306,30 +307,15 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr { $entry = $stored[$url] ?? null; $displayName = $app->human_name ?: $app->name; + $port = $this->effectiveDomainInternalPort($url, $app); - if (is_array($entry) && filled(data_get($entry, 'status'))) { - return [ - 'service_application_id' => $app->id, - 'service_name' => $displayName, - 'service_image' => $app->image, - 'url' => $url, - 'dns_status' => (string) data_get($entry, 'status', 'pending'), - 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), - 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, - 'checked_at' => data_get($entry, 'checked_at'), - 'check_id' => data_get($entry, 'check_id'), - 'is_suggested' => false, - 'suggested_for' => null, - 'suggestion_label' => null, - 'needs_force_add' => false, - ]; - } - - return [ + $row = [ 'service_application_id' => $app->id, 'service_name' => $displayName, 'service_image' => $app->image, 'url' => $url, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], 'dns_status' => 'pending', 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, @@ -340,6 +326,48 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr 'suggestion_label' => null, 'needs_force_add' => false, ]; + + if (is_array($entry) && filled(data_get($entry, 'status'))) { + $row['dns_status'] = (string) data_get($entry, 'status', 'pending'); + $row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.'); + $row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp; + $row['checked_at'] = data_get($entry, 'checked_at'); + $row['check_id'] = data_get($entry, 'check_id'); + } + + return $row; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + protected function effectiveDomainInternalPort(string $url, ServiceApplication $app): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $app->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + + if (array_key_exists($canonical, $overrides)) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null && $legacyPort > 0) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + $requiredPort = $app->getRequiredPort(); + + return [ + 'internal_port' => ($requiredPort !== null && $requiredPort > 0) ? $requiredPort : null, + 'has_port_override' => false, + ]; } /** @@ -990,8 +1018,11 @@ public function addDomain(): void ->all() : []; $current = collect($this->splitDomains($app->fqdn)); + $currentCanonicalDomains = $current->map( + fn (string $url): string => DomainPortOverrides::withoutPort($url) + ); foreach ($newUrls as $url) { - if ($current->contains($url)) { + if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) { $this->addError('newDomain', "Domain {$url} is already configured for this service."); return; @@ -1111,6 +1142,12 @@ public function startEdit(int $index): void $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $app = $this->findServiceApp((int) $this->domainRows[$index]['service_application_id']); + $canonical = DomainPortOverrides::withoutPort($this->editingDomain); + $savedPort = ($app?->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($savedPort)) { + $this->editingDomainParts['port'] = (string) $savedPort; + } $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id']; $this->editDomainDnsFailed = false; @@ -1144,7 +1181,7 @@ public function updateDomain(): void return; } - if ($this->editingDomainPartsChanged) { + if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) { $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); } $this->validateOnly('editingDomain'); @@ -1166,7 +1203,17 @@ public function updateDomain(): void $current = collect($this->splitDomains($app->fqdn)); $wasNoindexed = $app->isDomainNoindexed($oldUrl); - if ($newUrl !== $oldUrl && $current->contains($newUrl)) { + if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) { + $portOverrides = $app->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]); + $app->domain_port_overrides = $portOverrides ?: null; + } + + $otherCanonicalDomains = $current + ->reject(fn (string $url): bool => $url === $oldUrl) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)); + if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) { $this->addError('editingDomain', "Domain {$newUrl} is already configured for this service."); return; @@ -1243,6 +1290,28 @@ public function removeDomain(int $index): void } } + public function removeDomainByKey(string $domainKey): void + { + $index = collect($this->domainRows)->search( + fn (array $row): bool => ! ($row['is_suggested'] ?? false) + && hash_equals($domainKey, $this->domainRowKey($row)) + ); + + if ($index === false) { + return; + } + + $this->removeDomain((int) $index); + } + + /** + * @param array{url: string, service_application_id: int|string} $row + */ + private function domainRowKey(array $row): string + { + return hash('sha256', $row['url'].'|'.$row['service_application_id']); + } + public function addSuggestedDomain(int $index): void { try { @@ -1376,8 +1445,9 @@ protected function saveDomainListForApp( if (! $this->forceRemovePort) { $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null && $domainString) { + $previousFqdn = $app->getOriginal('fqdn'); foreach ($this->splitDomains($domainString) as $fqdn) { - if (ServiceApplication::extractPortFromUrl($fqdn) === null) { + if ($app->portRequiresConfirmation($fqdn, $requiredPort, is_string($previousFqdn) ? $previousFqdn : null)) { $this->requiredPort = $requiredPort; $this->showPortWarningModal = true; $app->refresh(); @@ -1425,6 +1495,7 @@ protected function checkUrlsDns(array $urls, ?int $serviceApplicationId = null): $urlSet = array_fill_keys($urls, true); $server = $this->service->server; $skipDns = ! $this->dnsValidationEnabled || ! $server; + $indexesToCheck = []; foreach ($this->domainRows as $index => $row) { $url = $row['url'] ?? null; diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php index 46a8ecdc8..2feafe41a 100644 --- a/app/Livewire/Project/Service/EditCompose.php +++ b/app/Livewire/Project/Service/EditCompose.php @@ -78,7 +78,7 @@ public function saveEditedCompose() try { $this->authorize('update', $this->service); $this->dispatch('saveCompose', $this->dockerComposeRaw); - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 96fe6a62c..f09989153 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -46,18 +46,18 @@ public function mount() $this->syncData(); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); // Sync to model - $this->application->fqdn = $this->fqdn; + $this->application->setEditableUrls($this->fqdn); $this->application->save(); } else { // Sync from model - $this->fqdn = $this->application->fqdn; + $this->fqdn = $this->application->url; } } @@ -84,6 +84,10 @@ public function cancelRemovePort() public function submit() { try { + $persistedApplication = $this->application->fresh(); + $previousEditableUrls = $persistedApplication->url; + $previousFqdn = $persistedApplication->fqdn; + $previousPortOverrides = $persistedApplication->domain_port_overrides; $this->authorize('update', $this->application); $this->validate(); @@ -93,7 +97,7 @@ public function submit() $this->dispatch('warning', __('warning.sslipdomain')); } // Sync to model for domain conflict check (without validation) - $this->application->fqdn = $this->fqdn; + $this->application->setEditableUrls($this->fqdn); // Check for domain conflicts if not forcing save if (! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $this->application); @@ -113,29 +117,21 @@ public function submit() $requiredPort = $this->application->getRequiredPort(); if ($requiredPort !== null) { - // Check if all FQDNs have a port - $fqdns = str($this->fqdn)->trim()->explode(','); - $missingPort = false; - - foreach ($fqdns as $fqdn) { - $fqdn = trim($fqdn); - if (empty($fqdn)) { + foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) { + $fqdn = trim((string) $fqdn); + if ($fqdn === '') { continue; } - $port = ServiceApplication::extractPortFromUrl($fqdn); - if ($port === null) { - $missingPort = true; - break; + if ($this->application->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + $this->application->fqdn = $previousFqdn; + $this->application->domain_port_overrides = $previousPortOverrides; + + return; } } - - if ($missingPort) { - $this->requiredPort = $requiredPort; - $this->showPortWarningModal = true; - - return; - } } } else { // Reset the force flag after using it diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 84a0daec8..cd209f6ae 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -120,7 +120,7 @@ public function refreshBackupStatus(): void : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { if ($this->fileStorage->is_too_large) { @@ -160,7 +160,7 @@ public function convertToDirectory() } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } } @@ -179,7 +179,7 @@ public function loadStorageOnServer() } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } } @@ -207,7 +207,7 @@ public function convertToFile() } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } } @@ -242,7 +242,7 @@ public function delete($password, $selectedActions = []) } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } return true; @@ -308,10 +308,10 @@ public function render() { return view('livewire.project.service.file-storage', [ 'directoryDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'], + ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permanently deleted from the server.'], ], 'fileDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'], + ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted from the server.'], ], 'hostFileDeletionCheckboxes' => [ ['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'], diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 34bb46ff1..0e7fed960 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -5,8 +5,11 @@ use App\Actions\Docker\GetContainersStatus; use App\Actions\Service\StartService; use App\Actions\Service\StopService; +use App\Actions\Service\StopServiceApplication; use App\Enums\ProcessStatus; use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; @@ -169,6 +172,29 @@ public function restart() } } + public function removeSelectedResourceContainer(): void + { + $resource = $this->selectedResource(); + if (! $resource) { + return; + } + + $this->authorize('update', $resource); + StopServiceApplication::run($resource, true, true); + $this->dispatch('success', 'Container removed.'); + } + + private function selectedResource(): ServiceApplication|ServiceDatabase|null + { + $uuid = data_get($this->parameters, 'stack_service_uuid'); + if (! $uuid) { + return null; + } + + return $this->service->applications()->whereUuid($uuid)->first() + ?? $this->service->databases()->whereUuid($uuid)->first(); + } + public function pullAndRestartEvent() { try { diff --git a/app/Livewire/Project/Service/ImportBackup.php b/app/Livewire/Project/Service/ImportBackup.php new file mode 100644 index 000000000..29e8d9f35 --- /dev/null +++ b/app/Livewire/Project/Service/ImportBackup.php @@ -0,0 +1,80 @@ +parameters = get_route_parameters(); + $project = currentTeam()->projects()->whereUuid($this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->whereUuid($this->parameters['environment_uuid'])->firstOrFail(); + $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); + $this->authorize('update', $this->service); + + $this->databases = $this->service->databases + ->filter(fn (ServiceDatabase $database): bool => $this->supportsImport($database)) + ->values(); + + $databaseUuid = request()->route('stack_service_uuid'); + if ($databaseUuid) { + $selectedDatabase = $this->databases->firstWhere('uuid', $databaseUuid); + abort_unless($selectedDatabase instanceof ServiceDatabase, 404); + $this->authorize('update', $selectedDatabase); + $this->selectedDatabase = $selectedDatabase; + $this->selectedDatabaseUuid = $selectedDatabase->uuid; + + if (request()->routeIs('project.service.database.import')) { + return redirect()->route('project.service.import-backup.database', $this->parameters); + } + } elseif ($this->databases->count() === 1) { + return redirect()->route('project.service.import-backup.database', [ + ...$this->parameters, + 'stack_service_uuid' => $this->databases->first()->uuid, + ]); + } + + return null; + } + + public function updatedSelectedDatabaseUuid(): mixed + { + $database = $this->databases->firstWhere('uuid', $this->selectedDatabaseUuid); + abort_unless($database instanceof ServiceDatabase, 404); + $this->authorize('update', $database); + + return redirect()->route('project.service.import-backup.database', [ + ...$this->parameters, + 'stack_service_uuid' => $database->uuid, + ]); + } + + public function render(): View + { + return view('livewire.project.service.import-backup'); + } + + private function supportsImport(ServiceDatabase $database): bool + { + return str($database->databaseType())->contains(['mysql', 'mariadb', 'postgres', 'mongo']); + } +} diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index d93ed7c02..7980e0705 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -59,8 +59,6 @@ class Index extends Component public bool $isLogDrainEnabled = false; - public bool $isImportSupported = false; - // Application-specific properties public $docker_cleanup = true; @@ -101,10 +99,27 @@ class Index extends Component 'isStripprefixEnabled' => 'nullable|boolean', ]; - public function mount() + public function mount(?ServiceApplication $serviceApplication = null) { try { $this->services = collect([]); + if ($serviceApplication) { + $this->service = $serviceApplication->service; + $this->authorize('view', $this->service); + $this->parameters = [ + 'project_uuid' => $this->service->environment->project->uuid, + 'environment_uuid' => $this->service->environment->uuid, + 'service_uuid' => $this->service->uuid, + 'stack_service_uuid' => $serviceApplication->uuid, + ]; + $this->query = request()->query(); + $this->serviceApplication = $serviceApplication; + $this->resourceType = 'application'; + $this->initializeApplicationProperties(); + $this->s3s = currentTeam()->s3s; + + return; + } $this->parameters = get_route_parameters(); $this->query = request()->query(); $this->currentRoute = request()->route()->getName(); @@ -153,10 +168,6 @@ private function initializeDatabaseProperties(): void $this->refreshFileStorages(); $this->syncDatabaseData(false); - // Check if import is supported for this database type - $dbType = $this->serviceDatabase->databaseType(); - $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; - $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); } private function syncDatabaseData(bool $toModel = false): void @@ -356,7 +367,7 @@ private function syncApplicationData(bool $toModel = false): void if ($toModel) { $this->serviceApplication->human_name = $this->humanName; $this->serviceApplication->description = $this->description; - $this->serviceApplication->fqdn = $this->fqdn; + $this->serviceApplication->setEditableUrls($this->fqdn); $this->serviceApplication->image = $this->image; $this->serviceApplication->exclude_from_status = $this->excludeFromStatus; $this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled; @@ -365,7 +376,7 @@ private function syncApplicationData(bool $toModel = false): void } else { $this->humanName = $this->serviceApplication->human_name; $this->description = $this->serviceApplication->description; - $this->fqdn = $this->serviceApplication->fqdn; + $this->fqdn = $this->serviceApplication->url; $this->image = $this->serviceApplication->image; $this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false); $this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false); @@ -428,7 +439,7 @@ public function deleteApplication($password, $selectedActions = []) $this->serviceApplication->delete(); $this->dispatch('success', 'Application deleted.'); - return redirect()->route('project.service.configuration', $this->parameters); + return redirectRoute($this, 'project.service.configuration', $this->parameters); } catch (\Throwable $e) { return handleError($e, $this); } @@ -462,7 +473,7 @@ public function convertToDatabase() $serviceApplication->delete(); }); - return redirect()->route('project.service.configuration', $redirectParams); + return redirectRoute($this, 'project.service.configuration', $redirectParams); } catch (\Throwable $e) { return handleError($e, $this); } @@ -491,6 +502,10 @@ public function cancelRemovePort() public function submitApplication() { try { + $persistedApplication = $this->serviceApplication->fresh(); + $previousEditableUrls = $persistedApplication->url; + $previousFqdn = $persistedApplication->fqdn; + $previousPortOverrides = $persistedApplication->domain_port_overrides; $this->authorize('update', $this->serviceApplication); $this->validate([ 'fqdn' => ValidationPatterns::applicationDomainRules(), @@ -520,28 +535,21 @@ public function submitApplication() $requiredPort = $this->serviceApplication->getRequiredPort(); if ($requiredPort !== null) { - $fqdns = str($this->fqdn)->trim()->explode(','); - $missingPort = false; - - foreach ($fqdns as $fqdn) { - $fqdn = trim($fqdn); - if (empty($fqdn)) { + foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) { + $fqdn = trim((string) $fqdn); + if ($fqdn === '') { continue; } - $port = ServiceApplication::extractPortFromUrl($fqdn); - if ($port === null) { - $missingPort = true; - break; + if ($this->serviceApplication->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + $this->serviceApplication->fqdn = $previousFqdn; + $this->serviceApplication->domain_port_overrides = $previousPortOverrides; + + return; } } - - if ($missingPort) { - $this->requiredPort = $requiredPort; - $this->showPortWarningModal = true; - - return; - } } } else { $this->forceRemovePort = false; diff --git a/app/Livewire/Project/Service/Status.php b/app/Livewire/Project/Service/Status.php index 192d7ca80..27919f1ae 100644 --- a/app/Livewire/Project/Service/Status.php +++ b/app/Livewire/Project/Service/Status.php @@ -10,6 +10,13 @@ class Status extends Component { public Service $service; + public ?string $selectedResourceUuid = null; + + public function mount(): void + { + $this->selectedResourceUuid = request()->route('stack_service_uuid'); + } + public function getListeners(): array { $teamId = auth()->user()->currentTeam()->id; @@ -27,6 +34,11 @@ public function refreshStatus(): void public function render(): View { - return view('livewire.project.service.status'); + $selectedResource = $this->selectedResourceUuid + ? $this->service->applications->firstWhere('uuid', $this->selectedResourceUuid) + ?? $this->service->databases->firstWhere('uuid', $this->selectedResourceUuid) + : null; + + return view('livewire.project.service.status', compact('selectedResource')); } } diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index ce278522b..adb19a313 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Service; +use App\Livewire\Project\Shared\Storages\All as StorageList; use App\Models\Application; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -51,7 +52,7 @@ public function getListeners() return [ "echo-private:team.{$teamId},FileStorageChanged" => 'refreshStoragesFromEvent', - 'refreshStorages', + 'storageCountsChanged' => 'refreshStorages', 'addNewVolume', ]; } @@ -87,11 +88,17 @@ public function refreshStoragesFromEvent() public function refreshStorages() { + $hadVolumes = $this->cachedVolumeCount > 0; + // Avoid loading full volume models onto this parent (child All owns that snapshot). $this->resource->unsetRelation('persistentStorages'); $this->loadVolumeCount(); $this->loadFileStorageMetaCounts(); $this->loadFileStorageForActiveTab(); + + if ($this->activeTab === 'volumes' && $hadVolumes && $this->cachedVolumeCount > 0) { + $this->dispatch('refreshVolumeList')->to(StorageList::class); + } } public function setActiveTab(string $tab): void @@ -223,7 +230,6 @@ public function submitPersistentVolume() $this->dispatch('configurationChanged'); $this->dispatch('success', 'Volume added successfully'); $this->dispatch('closeStorageModal', 'volume'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -258,7 +264,6 @@ public function submitFileStorage() $this->dispatch('configurationChanged'); $this->dispatch('success', 'File mount added successfully'); $this->dispatch('closeStorageModal', 'file'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -293,7 +298,6 @@ public function submitHostFileStorage() $this->dispatch('configurationChanged'); $this->dispatch('success', 'Host file mount added successfully'); $this->dispatch('closeStorageModal', 'host-file'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -332,7 +336,6 @@ public function submitFileStorageDirectory() $this->dispatch('configurationChanged'); $this->dispatch('success', 'Directory mount added successfully'); $this->dispatch('closeStorageModal', 'directory'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index 49da9e21f..e856d1373 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -2,11 +2,14 @@ namespace App\Livewire\Project\Service\VolumeBackup; +use App\Jobs\DatabaseBackupJob; +use App\Jobs\VolumeBackupJob; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledVolumeBackup; use App\Models\Service; use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -20,14 +23,70 @@ class Index extends Component public string $search = ''; - protected $listeners = ['refreshVolumeBackups' => '$refresh']; + public bool $scheduleModalOpen = false; - public function mount(): void + public ?ScheduledDatabaseBackup $selectedDatabaseBackup = null; + + public ?ScheduledVolumeBackup $selectedVolumeBackup = null; + + public ?Collection $s3s = null; + + public function getListeners(): array { - $this->service = $this->findService(); + $teamId = currentTeam()->id; + + return [ + 'refreshVolumeBackups' => '$refresh', + 'modalClosed' => 'closeScheduleModal', + "echo-private:team.{$teamId},BackupCreated" => '$refresh', + ]; + } + + public function mount(?Service $service = null): void + { + $this->service = $service ?? $this->findService(); $this->authorize('view', $this->service); $this->parameters = get_route_parameters(); $this->search = request()->string('search')->toString(); + + } + + public function openSchedule(string $backupUuid): void + { + $this->loadSelectedSchedule($backupUuid); + $this->s3s = currentTeam()->s3s; + $this->scheduleModalOpen = true; + } + + public function closeScheduleModal(): void + { + $this->scheduleModalOpen = false; + $this->selectedDatabaseBackup = null; + $this->selectedVolumeBackup = null; + } + + public function backupNow(string $type, string $backupUuid): void + { + try { + if ($type === 'database') { + $this->loadSelectedSchedule($backupUuid); + abort_unless($this->selectedDatabaseBackup, 404); + $this->authorize('manageBackups', $this->selectedDatabaseBackup->database); + DatabaseBackupJob::dispatch($this->selectedDatabaseBackup); + } else { + abort_unless($type === 'storage', 404); + $this->loadSelectedSchedule($backupUuid); + abort_unless($this->selectedVolumeBackup, 404); + $this->authorize('update', $this->selectedVolumeBackup->targetResource()); + VolumeBackupJob::dispatch($this->selectedVolumeBackup); + } + + $this->selectedDatabaseBackup = null; + $this->selectedVolumeBackup = null; + $this->dispatch('success', 'Backup queued.'); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function render(): View @@ -68,4 +127,24 @@ private function findService(): Service ->where('uuid', request()->route('service_uuid')) ->firstOrFail(); } + + private function loadSelectedSchedule(string $backupUuid): void + { + $this->selectedDatabaseBackup = ScheduledDatabaseBackup::query() + ->with('database') + ->whereUuid($backupUuid) + ->where('database_type', (new ServiceDatabase)->getMorphClass()) + ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) + ->first(); + + if ($this->selectedDatabaseBackup) { + return; + } + + $this->selectedVolumeBackup = ScheduledVolumeBackup::query() + ->with('backupable.resource') + ->whereUuid($backupUuid) + ->forService($this->service) + ->firstOrFail(); + } } diff --git a/app/Livewire/Project/Service/VolumeBackup/Show.php b/app/Livewire/Project/Service/VolumeBackup/Show.php index eec60497f..10abeb3bf 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Show.php +++ b/app/Livewire/Project/Service/VolumeBackup/Show.php @@ -20,7 +20,7 @@ class Show extends Component public string $section = 'general'; - public function mount(): void + public function mount(): mixed { $project = currentTeam()->projects()->where('uuid', request()->route('project_uuid'))->firstOrFail(); $environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail(); @@ -43,6 +43,10 @@ public function mount(): void 'project.service.volume-backups.danger' => 'danger', default => 'general', }; + + $routeParameters = collect($this->parameters)->except('backup_uuid')->all(); + + return redirect()->route('project.service.volume-backups.index', $routeParameters); } public function render(): View diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 7f0d3b173..d2420a029 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -106,14 +106,13 @@ public function delete($password, $selectedActions = []) try { $this->authorize('delete', $this->resource); - $this->resource->delete(); DeleteResourceJob::dispatch( $this->resource, $this->delete_volumes, $this->delete_connected_networks, $this->delete_configurations, $this->docker_cleanup - ); + )->afterResponse(); return redirectRoute($this, 'project.resource.index', [ 'project_uuid' => $this->projectUuid, diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index ea8394c1b..47080cd86 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -818,19 +818,21 @@ private function formatEnvironmentVariables($variables) { $isMember = auth()->user()?->isMember(); - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } + return $variables + ->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key)) + ->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } + if ($item->is_shown_once) { + return "$item->key=(Locked Secret, delete and add again to change)"; + } + if ($item->is_multiline) { + return "$item->key=(Multiline environment variable, edit in normal view)"; + } - return "$item->key=$item->value"; - })->join("\n"); + return "$item->key=$item->value"; + })->join("\n"); } public function switch() @@ -908,8 +910,7 @@ private function handleBulkSubmit() $deletedCount = $this->deleteRemovedVariables(false, $variables); if ($deletedCount > 0) { $changesMade = true; - } elseif ($deletedCount === 0 && $this->resource->environment_variables()->whereNotIn('key', array_keys($variables))->exists()) { - // If we tried to delete but couldn't (due to Docker Compose), mark as error + } elseif ($deletedCount < 0) { $errorOccurred = true; } @@ -926,8 +927,7 @@ private function handleBulkSubmit() $deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables); if ($deletedPreviewCount > 0) { $changesMade = true; - } elseif ($deletedPreviewCount === 0 && $this->resource->environment_variables_preview()->whereNotIn('key', array_keys($previewVariables))->exists()) { - // If we tried to delete but couldn't (due to Docker Compose), mark as error + } elseif ($deletedPreviewCount < 0) { $errorOccurred = true; } @@ -988,6 +988,12 @@ private function deleteRemovedVariables($isPreview, $variables) // Get all environment variables that will be deleted $variablesToDelete = $this->resource->$method()->whereNotIn('key', array_keys($variables))->get(); + // Generated Compose variables are managed by Coolify and must survive a bulk + // replacement even when they are omitted from the pasted environment file. + $variablesToDelete = $variablesToDelete->reject( + fn (EnvironmentVariable $environmentVariable): bool => $this->isProtectedEnvironmentVariable($environmentVariable->key) + ); + // If there are no variables to delete, return 0 if ($variablesToDelete->isEmpty()) { return 0; @@ -1001,13 +1007,13 @@ private function deleteRemovedVariables($isPreview, $variables) if ($isUsed) { $this->dispatch('error', "Cannot delete environment variable '{$envVar->key}'

Please remove it from the Docker Compose file first."); - return 0; + return -1; } } } // If we get here, no variables are used in Docker Compose, so we can delete them - $this->resource->$method()->whereNotIn('key', array_keys($variables))->delete(); + $this->resource->$method()->whereKey($variablesToDelete->modelKeys())->delete(); return $variablesToDelete->count(); } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index cd86d8670..8350cffa4 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -145,6 +145,8 @@ public function refresh() */ public function loadValues(): void { + $this->authorize('update', $this->env); + if ($this->valuesLoaded) { return; } @@ -162,7 +164,7 @@ public function loadValues(): void $this->valuesLoaded = true; } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key); diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 67a040ef7..e1e541371 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -17,12 +17,15 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Process; use Livewire\Attributes\Locked; use Livewire\Component; class GetLogs extends Component { + use AuthorizesRequests; + public const MAX_LOG_LINES = 50000; public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024; @@ -82,6 +85,10 @@ public function mount() public function instantSave() { if (! is_null($this->resource)) { + if (auth()->user()->cannot('update', $this->resource)) { + return; + } + if ($this->resource->getMorphClass() === Application::class) { $this->resource->settings->is_include_timestamps = $this->showTimeStamps; $this->resource->settings->save(); diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 6a128a142..70633fe03 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -86,7 +86,7 @@ public function mount() } } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index 61bc6b0fb..717007bc0 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -102,7 +102,7 @@ public function submit() } } - public function saveScheduledTask() + private function saveScheduledTask(): void { try { $task = new ScheduledTask; @@ -128,7 +128,7 @@ public function saveScheduledTask() $this->dispatch('refreshTasks'); $this->dispatch('success', 'Scheduled task added.'); } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); } } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 11df00153..30d102462 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -87,7 +87,7 @@ public function mount() } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -169,9 +169,9 @@ public function delete() $this->task->delete(); if ($this->type === 'application') { - return redirect()->route('project.application.scheduled-tasks.show', $this->parameters); + return redirectRoute($this, 'project.application.scheduled-tasks.show', $this->parameters); } else { - return redirect()->route('project.service.scheduled-tasks.show', $this->parameters); + return redirectRoute($this, 'project.service.scheduled-tasks.show', $this->parameters); } } catch (\Exception $e) { return handleError($e); diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 583c2788a..fcd7752a0 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\Storages; +use App\Livewire\Project\Service\Storage as StorageComponent; use App\Models\Application; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -44,7 +45,7 @@ class All extends Component public bool $deleteDockerVolume = false; - protected $listeners = ['refreshStorages' => 'refreshList', 'refreshVolumeBackups' => 'refreshList']; + protected $listeners = ['refreshVolumeList' => 'refreshList', 'refreshVolumeBackups' => 'refreshList']; public function mount(): void { @@ -163,7 +164,7 @@ public function delete(int $storageId, $password = '', $selectedActions = []) $storage->delete(); $this->refreshList(); - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(StorageComponent::class); $this->dispatch('configurationChanged'); return true; diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 7e1e2dec1..c70ebc57f 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\Storages; +use App\Livewire\Project\Service\Storage as StorageComponent; use App\Models\Application; use App\Models\LocalPersistentVolume; use App\Models\ScheduledVolumeBackup; @@ -192,7 +193,7 @@ public function delete($password, $selectedActions = []) } $this->storage->delete(); - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(StorageComponent::class); $this->dispatch('configurationChanged'); return true; diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index b6d448e90..0d26d1d66 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -28,6 +28,8 @@ public function getListeners() public function loadScripts() { + $this->authorize('viewAny', CloudInitScript::class); + CloudInitScript::ownedByCurrentTeam() ->whereNull('uuid') ->get() diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index ba2655b43..2c31d2203 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -94,6 +94,7 @@ private function validateToken(string $provider, string $token): bool public function addToken() { + $this->authorize('create', CloudProviderToken::class); $this->validate(); try { diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index 664119bad..1b8f26ff2 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -76,7 +76,9 @@ private function syncData(bool $toModel = false): void // Sync FROM model (on load/refresh) $this->name = $this->private_key->name; $this->description = $this->private_key->description; - $this->privateKeyValue = $this->private_key->private_key; + $this->privateKeyValue = auth()->user()->can('update', $this->private_key) + ? $this->private_key->private_key + : ''; $this->isGitRelated = $this->private_key->is_git_related; } } diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index a94881b12..895ce34e7 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -42,10 +42,9 @@ public function mount(string $server_uuid) } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); $this->server->settings->concurrent_builds = $this->concurrentBuilds; $this->server->settings->dynamic_timeout = $this->dynamicTimeout; @@ -67,6 +66,7 @@ public function syncData(bool $toModel = false) public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { @@ -81,6 +81,7 @@ public function submit() $this->serverDiskUsageCheckFrequency = $this->server->settings->getOriginal('server_disk_usage_check_frequency'); throw new \Exception('Invalid Cron / Human expression for Disk Usage Check Frequency.'); } + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 12d111d21..40dd92d87 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -97,10 +97,9 @@ public function mount(string $server_uuid) } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); $this->server->settings->force_docker_cleanup = $this->forceDockerCleanup; $this->server->settings->docker_cleanup_frequency = $this->dockerCleanupFrequency; @@ -122,6 +121,7 @@ public function syncData(bool $toModel = false) public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { @@ -147,6 +147,7 @@ public function submit() $this->dockerCleanupFrequency = $this->server->settings->getOriginal('docker_cleanup_frequency'); throw new \Exception('Invalid Cron / Human expression for Docker Cleanup Frequency.'); } + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 3af0a2261..5ce657f00 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -52,7 +52,7 @@ public function mount(string $server_uuid) } } - public function syncDataNewRelic(bool $toModel = false) + private function syncDataNewRelic(bool $toModel = false): void { if ($toModel) { $this->server->settings->is_logdrain_newrelic_enabled = $this->isLogDrainNewRelicEnabled; @@ -65,7 +65,7 @@ public function syncDataNewRelic(bool $toModel = false) } } - public function syncDataAxiom(bool $toModel = false) + private function syncDataAxiom(bool $toModel = false): void { if ($toModel) { $this->server->settings->is_logdrain_axiom_enabled = $this->isLogDrainAxiomEnabled; @@ -78,7 +78,7 @@ public function syncDataAxiom(bool $toModel = false) } } - public function syncDataCustom(bool $toModel = false) + private function syncDataCustom(bool $toModel = false): void { if ($toModel) { $this->server->settings->is_logdrain_custom_enabled = $this->isLogDrainCustomEnabled; @@ -91,7 +91,7 @@ public function syncDataCustom(bool $toModel = false) } } - public function syncData(bool $toModel = false, ?string $type = null) + private function syncData(bool $toModel = false, ?string $type = null): void { if ($toModel) { $this->customValidation(); diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 68cb52a92..296fd4da5 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -106,6 +106,8 @@ public function changeProxy() try { $this->authorize('update', $this->server); $this->server->proxy = null; + $this->server->detected_traefik_version = null; + $this->server->traefik_outdated_info = null; $this->server->save(); $this->dispatch('reloadWindow'); diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php index b4b99a3e7..999482dcf 100644 --- a/app/Livewire/Server/Security/TerminalAccess.php +++ b/app/Livewire/Server/Security/TerminalAccess.php @@ -62,10 +62,9 @@ public function toggleTerminal($password, $selectedActions = []) } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); // No other fields to sync for terminal access } else { diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index a69eb3f80..2d4742eb6 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -54,10 +54,9 @@ public function mount() $this->syncData(); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); $this->server->settings->is_metrics_enabled = $this->isMetricsEnabled; $this->server->settings->sentinel_token = $this->sentinelToken; @@ -145,6 +144,7 @@ public function regenerateSentinelToken() public function submit() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.'); } catch (\Throwable $e) { @@ -155,6 +155,7 @@ public function submit() public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->restartSentinel(); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 017beb371..38bbe24e7 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -230,12 +230,10 @@ public function timezones(): array ->toArray(); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - - $this->authorize('update', $this->server); $foundServer = Server::where('ip', $this->ip) ->where('id', '!=', $this->server->id) ->first(); @@ -363,6 +361,7 @@ public function validateServer($install = true) public function checkLocalhostConnection() { try { + $this->authorize('update', $this->server); $this->syncData(true); ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); if ($uptime) { @@ -479,6 +478,7 @@ public function regenerateSentinelToken() public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -694,6 +694,7 @@ public function refreshServerMetadata(): void public function submit() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server settings updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Swarm.php b/app/Livewire/Server/Swarm.php index e3e441ea0..af785a8c2 100644 --- a/app/Livewire/Server/Swarm.php +++ b/app/Livewire/Server/Swarm.php @@ -29,10 +29,9 @@ public function mount(string $server_uuid) } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->server->settings->is_swarm_manager = $this->isSwarmManager; $this->server->settings->is_swarm_worker = $this->isSwarmWorker; $this->server->settings->save(); @@ -45,6 +44,7 @@ public function syncData(bool $toModel = false) public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Swarm settings updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index c39f868ba..db62bff2d 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -53,6 +53,8 @@ class ValidateAndInstall extends Component public function init(int $data = 0) { + $this->authorize('update', $this->server); + if (! $this->server->canBeValidated()) { $this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.'; $this->server->update([ @@ -160,6 +162,8 @@ public function validateConnection() public function validateOS() { + $this->authorize('update', $this->server); + $this->supported_os_type = $this->server->validateOS(); if (! $this->supported_os_type) { $this->error = 'Server OS type is not supported. Please install Docker manually before continuing: documentation.'; @@ -174,6 +178,8 @@ public function validateOS() public function validatePrerequisites() { + $this->authorize('update', $this->server); + $validationResult = $this->server->validatePrerequisites(); $this->prerequisites_installed = $validationResult['success']; if (! $validationResult['success']) { @@ -212,6 +218,8 @@ public function validatePrerequisites() public function validateDockerEngine() { + $this->authorize('update', $this->server); + $this->docker_installed = $this->server->validateDockerEngine(); $this->docker_compose_installed = $this->server->validateDockerCompose(); if (! $this->docker_installed || ! $this->docker_compose_installed) { @@ -248,6 +256,8 @@ public function validateDockerEngine() public function validateDockerVersion() { + $this->authorize('update', $this->server); + if ($this->server->isSwarm()) { $swarmInstalled = $this->server->validateDockerSwarm(); if ($swarmInstalled) { diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 9bca0db2e..4b5857db5 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -74,7 +74,7 @@ public function mount() $this->testEmailAddress = auth()->user()->email; } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 2570c3a1b..2dadd7366 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -122,13 +122,6 @@ public function updatedHtmlUrl(): void } } - public function boot() - { - if ($this->github_app) { - $this->github_app->makeVisible(['client_secret', 'webhook_secret']); - } - } - /** * Sync data between component properties and model * @@ -170,8 +163,9 @@ private function syncData(bool $toModel = false): void $this->appId = $this->github_app->app_id; $this->installationId = $this->github_app->installation_id; $this->clientId = $this->github_app->client_id; - $this->clientSecret = $this->github_app->client_secret; - $this->webhookSecret = $this->github_app->webhook_secret; + $canUpdate = auth()->user()->can('update', $this->github_app); + $this->clientSecret = $canUpdate ? $this->github_app->client_secret : null; + $this->webhookSecret = $canUpdate ? $this->github_app->webhook_secret : null; $this->isSystemWide = $this->github_app->is_system_wide; $this->privateKeyId = $this->github_app->private_key_id; $this->contents = $this->github_app->contents; @@ -231,7 +225,7 @@ public function checkPermissions() syncGithubAppName($this->github_app); GithubAppPermissionJob::dispatchSync($this->github_app); - $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->github_app->refresh(); $this->syncData(false); $this->isConnected = $this->github_app->isConnected(); $this->name = str($this->github_app->name)->kebab(); @@ -305,7 +299,7 @@ public function mount() try { $github_app_uuid = request()->github_app_uuid; $this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail(); - $this->github_app->makeVisible(['client_secret', 'webhook_secret']); + $this->authorize('view', $this->github_app); $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); $this->applications = $this->github_app->applications; @@ -420,7 +414,6 @@ public function submit() try { $this->authorize('update', $this->github_app); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->organization = normalizeGithubOrganization($this->organization); $this->apiUrl = filled($this->apiUrl) ? $this->apiUrl @@ -442,7 +435,6 @@ public function createGithubAppManually() { $this->authorize('update', $this->github_app); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->github_app->app_id = 1234567890; $this->github_app->installation_id = 1234567890; $this->github_app->save(); @@ -457,8 +449,6 @@ public function instantSave() try { $this->authorize('update', $this->github_app); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); - $this->syncData(true); $this->github_app->save(); $this->isConnected = $this->github_app->isConnected(); @@ -475,7 +465,6 @@ public function delete() if ($this->github_app->applications->isNotEmpty()) { $this->dispatch('error', 'This source is being used by an application. Please delete all applications first.'); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); return; } @@ -484,7 +473,7 @@ public function delete() // @can and canGate checks against a deleted model (null team_id TypeError). $this->github_app = null; - return redirect()->route('source.all'); + return redirectRoute($this, 'source.all'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index dd0284582..29374105b 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -338,7 +338,7 @@ public function delete() // @can and canGate checks against a deleted model (null team_id TypeError). $this->gitlab_app = null; - return redirect()->route('source.all'); + return redirectRoute($this, 'source.all'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index 89782d686..17abd19e5 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -43,7 +43,7 @@ public function delete() $this->storage->delete(); - return redirect()->route('storage.index'); + return redirectRoute($this, 'storage.index'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Models/Application.php b/app/Models/Application.php index af95cd4db..7f7ce5ea1 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -7,6 +7,8 @@ use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ConfigurationDiff; use App\Services\DeploymentConfiguration\ConfigurationDiffer; +use App\Support\DomainPortOverrides; +use App\Support\DomainUrlParts; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; @@ -135,6 +137,7 @@ class Application extends BaseModel 'description', 'fqdn', 'noindex_domains', + 'domain_port_overrides', 'git_repository', 'git_branch', 'git_commit_sha', @@ -213,6 +216,8 @@ class Application extends BaseModel 'last_online_at', 'restart_count', 'max_restart_count', + 'restart_limit_reached', + 'container_present', 'last_restart_at', 'last_restart_type', 'uuid', @@ -244,6 +249,7 @@ class Application extends BaseModel 'docker_compose_raw', 'custom_labels', 'domain_dns_statuses', + 'domain_port_overrides', ]; protected function casts(): array @@ -256,8 +262,11 @@ protected function casts(): array 'manual_webhook_secret_gitea' => 'encrypted', 'noindex_domains' => 'array', 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', 'restart_count' => 'integer', 'max_restart_count' => 'integer', + 'restart_limit_reached' => 'boolean', + 'container_present' => 'boolean', 'last_restart_at' => 'datetime', ]; } @@ -282,6 +291,9 @@ protected static function booted() if ($application->fqdn === '') { $application->fqdn = null; } + $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); + $application->fqdn = $normalized['fqdn']; + $application->domain_port_overrides = $normalized['overrides']; $payload['fqdn'] = $application->fqdn; $application->syncNoindexDomains(); } @@ -605,36 +617,8 @@ public function link() public function stoppedAfterRestartLimit(): bool { return str($this->status)->startsWith('exited') - && ($this->restart_count ?? 0) > 0 - && ($this->max_restart_count ?? 0) > 0 - && $this->restart_count >= $this->max_restart_count - && $this->last_restart_type === 'crash'; - } - - public function taskLink($task_uuid) - { - if (data_get($this, 'environment.project.uuid')) { - $route = route('project.application.scheduled-tasks', [ - 'project_uuid' => data_get($this, 'environment.project.uuid'), - 'environment_uuid' => data_get($this, 'environment.uuid'), - 'application_uuid' => data_get($this, 'uuid'), - 'task_uuid' => $task_uuid, - ]); - $settings = instanceSettings(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - return $url->__toString(); - } - - return $route; - } - - return null; + && $this->container_present === true + && $this->restart_limit_reached === true; } public function settings() @@ -730,7 +714,7 @@ public function gitCommits(): Attribute ); } - public function gitCommitLink($link): string + public function gitCommitLink($link): ?string { if (! is_null(data_get($this, 'source.html_url')) && ! is_null(data_get($this, 'git_repository')) && ! is_null(data_get($this, 'git_branch'))) { if (str($this->source->html_url)->contains('bitbucket')) { @@ -747,6 +731,10 @@ public function gitCommitLink($link): string $git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH); } + if (! filter_var($git_repository, FILTER_VALIDATE_URL)) { + return null; + } + $url = Url::fromString(Str::replaceEnd('.git', '', $git_repository)); $url = $url->withUserInfo(''); $commitPath = str($git_repository)->contains('bitbucket') ? 'commits' : 'commit'; @@ -972,6 +960,46 @@ public function main_port() return $this->settings->is_static ? [80] : $this->ports_exposes_array; } + /** + * Ports the container is expected to listen on: Ports Exposes plus ports already used by application domains. + * + * @return list + */ + public function availableInternalPorts(): array + { + $ports = collect($this->settings?->is_static ? [80] : $this->ports_exposes_array) + ->filter(fn (mixed $port): bool => is_numeric($port) && (int) $port > 0) + ->map(fn (mixed $port): int => (int) $port); + + foreach ($this->domain_port_overrides ?? [] as $port) { + if (is_numeric($port) && (int) $port > 0) { + $ports->push((int) $port); + } + } + + foreach (explode(',', (string) $this->fqdn) as $url) { + $url = trim($url); + if ($url === '') { + continue; + } + $legacyPort = DomainUrlParts::split($url)['port'] ?? ''; + if ($legacyPort !== '' && is_numeric($legacyPort) && (int) $legacyPort > 0) { + $ports->push((int) $legacyPort); + } + } + + return $ports->unique()->sort()->values()->all(); + } + + public function portRequiresConfirmation(?int $port): bool + { + if ($port === null || $port <= 0) { + return false; + } + + return ! in_array($port, $this->availableInternalPorts(), true); + } + public function detectPortFromEnvironment(?bool $isPreview = false): ?int { $envVars = $isPreview diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 090524275..bffbdab62 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -2,14 +2,16 @@ namespace App\Models; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; +use App\Traits\HasRestartLimit; use Illuminate\Database\Eloquent\SoftDeletes; use RuntimeException; use Spatie\Url\Url; class ApplicationPreview extends BaseModel { - use SoftDeletes; + use HasRestartLimit, SoftDeletes; protected $fillable = [ 'uuid', @@ -23,10 +25,18 @@ class ApplicationPreview extends BaseModel 'docker_compose_domains', 'docker_registry_image_tag', 'last_online_at', + 'domain_dns_statuses', + 'domain_port_overrides', + ]; + + protected $hidden = [ + 'domain_port_overrides', ]; protected $casts = [ 'pull_request_id' => 'integer', + 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', ]; protected static function booted(): void @@ -82,6 +92,14 @@ protected static function booted(): void if ($preview->isDirty('status')) { $preview->last_online_at = now(); } + if ($preview->isDirty('fqdn')) { + if ($preview->fqdn === '') { + $preview->fqdn = null; + } + $normalized = DomainPortOverrides::normalize($preview->fqdn, $preview->domain_port_overrides); + $preview->fqdn = $normalized['fqdn']; + $preview->domain_port_overrides = $normalized['overrides']; + } }); } @@ -100,39 +118,42 @@ public function application() return $this->belongsTo(Application::class); } + public function restartLimitMaximum(): int + { + return $this->application->max_restart_count ?? $this->max_restart_count ?? 0; + } + public function persistentStorages() { return $this->morphMany(LocalPersistentVolume::class, 'resource'); } - public function generate_preview_fqdn() + public function generate_preview_fqdn(bool $generateWithoutApplicationDomain = false) { - if ($this->application->fqdn) { - if (str($this->application->fqdn)->contains(',')) { - $url = Url::fromString(str($this->application->fqdn)->explode(',')[0]); - } else { - $url = Url::fromString($this->application->fqdn); - } - $template = $this->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - $this->fqdn = $preview_fqdn; + $applicationFqdn = $this->application->fqdn; + if (! $applicationFqdn && $generateWithoutApplicationDomain) { + $applicationFqdn = generateUrl( + server: $this->application->destination->server, + random: $this->application->uuid, + ); + } + + if ($applicationFqdn) { + $sourceDomain = str($applicationFqdn)->contains(',') + ? str($applicationFqdn)->explode(',')[0] + : $applicationFqdn; + $generated = $this->generatedPreviewDomain((string) $sourceDomain); + $this->fqdn = $generated['url']; + $this->domain_port_overrides = filled($generated['port']) + ? [$generated['url'] => $generated['port']] + : null; $this->save(); } return $this; } - public function generate_preview_fqdn_compose() + public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDomain = false) { $applicationDomains = json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []; $previewDomains = json_decode(data_get($this, 'docker_compose_domains') ?: '[]', true) ?: []; @@ -171,11 +192,19 @@ public function generate_preview_fqdn_compose() ->all(); $docker_compose_domains = []; + $previewPortOverrides = []; foreach ($serviceNames as $service_name) { $domain_string = getComposeServiceDomainString($applicationDomains, $service_name); - // If domain string is empty or null, don't auto-generate domain - // Only generate domains when main app already has domains set + if (empty($domain_string)) { + if ($generateWithoutApplicationDomain) { + $domain_string = generateUrl( + server: $this->application->destination->server, + random: str($service_name)->slug().'-'.$this->application->uuid, + ); + } + } + if (empty($domain_string)) { $docker_compose_domains = putComposeServiceDomain( $docker_compose_domains, @@ -195,20 +224,11 @@ public function generate_preview_fqdn_compose() continue; } - $url = Url::fromString($domain); - $template = $this->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - $preview_domains[] = $preview_fqdn; + $generated = $this->generatedPreviewDomain((string) $domain); + $preview_domains[] = $generated['url']; + if (filled($generated['port'])) { + $previewPortOverrides[$generated['url']] = $generated['port']; + } } $docker_compose_domains = putComposeServiceDomain( @@ -232,10 +252,36 @@ public function generate_preview_fqdn_compose() ->implode(','); $this->fqdn = ! empty($allDomains) ? $allDomains : null; + $this->domain_port_overrides = $previewPortOverrides ?: null; $this->save(); } + /** + * @return array{url: string, port: ?int} + */ + public function generatedPreviewDomain(string $sourceDomain): array + { + $url = Url::fromString($sourceDomain); + $template = $this->application->preview_url_template; + $host = $url->getHost(); + $schema = $url->getScheme(); + $urlPath = $url->getPath(); + $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; + $random = new_public_id(); + $previewFqdn = str_replace('{{random}}', $random, $template); + $previewFqdn = str_replace('{{domain}}', $host, $previewFqdn); + $previewFqdn = str_replace('{{pr_id}}', (string) $this->pull_request_id, $previewFqdn); + $previewUrl = "{$schema}://{$previewFqdn}{$path}"; + $sourceCanonical = DomainPortOverrides::withoutPort($sourceDomain); + $port = $url->getPort() ?? ($this->application->domain_port_overrides[$sourceCanonical] ?? null); + + return [ + 'url' => $previewUrl, + 'port' => $port !== null ? (int) $port : null, + ]; + } + /** * Original compose service names for this preview (PR suffix stripped), excluding database images. * diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index 135c921f6..48d5b5d29 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -20,6 +20,7 @@ class DiscordNotificationSettings extends Model 'deployment_success_discord_notifications', 'deployment_failure_discord_notifications', 'status_change_discord_notifications', + 'restart_limit_reached_discord_notifications', 'backup_success_discord_notifications', 'backup_failure_discord_notifications', 'scheduled_task_success_discord_notifications', @@ -45,6 +46,7 @@ class DiscordNotificationSettings extends Model 'deployment_success_discord_notifications' => 'boolean', 'deployment_failure_discord_notifications' => 'boolean', 'status_change_discord_notifications' => 'boolean', + 'restart_limit_reached_discord_notifications' => 'boolean', 'backup_success_discord_notifications' => 'boolean', 'backup_failure_discord_notifications' => 'boolean', 'scheduled_task_success_discord_notifications' => 'boolean', diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 814d05339..3b04b482a 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -31,6 +31,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications', 'deployment_failure_email_notifications', 'status_change_email_notifications', + 'restart_limit_reached_email_notifications', 'backup_success_email_notifications', 'backup_failure_email_notifications', 'scheduled_task_success_email_notifications', @@ -73,6 +74,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications' => 'boolean', 'deployment_failure_email_notifications' => 'boolean', 'status_change_email_notifications' => 'boolean', + 'restart_limit_reached_email_notifications' => 'boolean', 'backup_success_email_notifications' => 'boolean', 'backup_failure_email_notifications' => 'boolean', 'scheduled_task_success_email_notifications' => 'boolean', diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index dd0d81cc0..2ab669314 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -21,6 +21,7 @@ class PushoverNotificationSettings extends Model 'deployment_success_pushover_notifications', 'deployment_failure_pushover_notifications', 'status_change_pushover_notifications', + 'restart_limit_reached_pushover_notifications', 'backup_success_pushover_notifications', 'backup_failure_pushover_notifications', 'scheduled_task_success_pushover_notifications', @@ -47,6 +48,7 @@ class PushoverNotificationSettings extends Model 'deployment_success_pushover_notifications' => 'boolean', 'deployment_failure_pushover_notifications' => 'boolean', 'status_change_pushover_notifications' => 'boolean', + 'restart_limit_reached_pushover_notifications' => 'boolean', 'backup_success_pushover_notifications' => 'boolean', 'backup_failure_pushover_notifications' => 'boolean', 'scheduled_task_success_pushover_notifications' => 'boolean', diff --git a/app/Models/Server.php b/app/Models/Server.php index f7a4bf20c..dccbed15e 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1812,6 +1812,8 @@ public function changeProxy(string $proxyType, bool $async = true) $this->proxy->set('last_saved_proxy_configuration', null); $this->proxy->set('last_saved_settings', null); $this->proxy->set('last_applied_settings', null); + $this->detected_traefik_version = null; + $this->traefik_outdated_info = null; $this->save(); if ($this->proxySet()) { if ($async) { diff --git a/app/Models/Service.php b/app/Models/Service.php index 0da97b301..1e6a33ad6 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -4,6 +4,7 @@ use App\Enums\ProcessStatus; use App\Services\ContainerStatusAggregator; +use App\Support\DomainPortOverrides; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -14,7 +15,6 @@ use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; use Spatie\Activitylog\Models\Activity; -use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; #[OA\Schema( @@ -92,11 +92,18 @@ protected static function booted() public function isConfigurationChanged(bool $save = false) { - $domains = $this->applications()->get()->pluck('fqdn')->sort()->toArray(); + $applications = $this->applications()->get(); + $domains = $applications->pluck('fqdn')->sort()->toArray(); $domains = implode(',', $domains); - $noindexDomains = $this->applications()->get()->pluck('noindex_domains')->flatten()->filter()->sort()->implode(','); + $noindexDomains = $applications->pluck('noindex_domains')->flatten()->filter()->sort()->implode(','); + $domainPortOverrides = $applications + ->mapWithKeys(fn (ServiceApplication $application): array => [ + $application->id => DomainPortOverrides::sorted($application->domain_port_overrides), + ]) + ->sortKeys() + ->all(); - $applicationImages = $this->applications()->get()->pluck('image')->sort(); + $applicationImages = $applications->pluck('image')->sort(); $databaseImages = $this->databases()->get()->pluck('image')->sort(); $images = $applicationImages->merge($databaseImages); $images = implode(',', $images->toArray()); @@ -105,7 +112,7 @@ public function isConfigurationChanged(bool $save = false) $databaseStorages = $this->databases()->get()->pluck('persistentStorages')->flatten()->sortBy('id'); $storages = $applicationStorages->merge($databaseStorages)->implode('updated_at'); - $newConfigHash = $images.$domains.$images.$storages.$noindexDomains; + $newConfigHash = $images.$domains.$images.$storages.$noindexDomains.json_encode($domainPortOverrides); $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); @@ -1455,32 +1462,6 @@ public function link() return null; } - public function taskLink($task_uuid) - { - if (data_get($this, 'environment.project.uuid')) { - $route = route('project.service.scheduled-tasks', [ - 'project_uuid' => data_get($this, 'environment.project.uuid'), - 'environment_uuid' => data_get($this, 'environment.uuid'), - 'service_uuid' => data_get($this, 'uuid'), - 'task_uuid' => $task_uuid, - ]); - $settings = InstanceSettings::get(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - return $url->__toString(); - } - - return $route; - } - - return null; - } - public function documentation() { $services = get_service_templates(); @@ -1496,7 +1477,7 @@ public function getRequiredPort(): ?int { try { $services = get_service_templates(); - $serviceName = str($this->name)->beforeLast('-')->value(); + $serviceName = $this->service_type ?: str($this->name)->beforeLast('-')->value(); $service = data_get($services, $serviceName, []); $port = data_get($service, 'port'); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 9763fa894..cf0faef5b 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -2,7 +2,10 @@ namespace App\Models; +use App\Support\DomainPortOverrides; +use App\Support\DomainUrlParts; use App\Traits\HasNoindexDomains; +use App\Traits\HasRestartLimit; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -10,7 +13,9 @@ class ServiceApplication extends BaseModel { - use HasFactory, HasNoindexDomains, SoftDeletes; + use HasFactory, HasNoindexDomains, HasRestartLimit, SoftDeletes; + + protected $appends = ['url']; protected $fillable = [ 'service_id', @@ -21,6 +26,7 @@ class ServiceApplication extends BaseModel 'noindex_domains', 'redirect', 'domain_dns_statuses', + 'domain_port_overrides', 'ports', 'exposes', 'status', @@ -43,6 +49,7 @@ class ServiceApplication extends BaseModel */ protected $hidden = [ 'domain_dns_statuses', + 'domain_port_overrides', ]; protected $attributes = [ @@ -53,6 +60,7 @@ protected function casts(): array { return [ 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', 'noindex_domains' => 'array', 'is_force_https_enabled' => 'boolean', ]; @@ -70,6 +78,7 @@ protected static function booted() $service->last_online_at = now(); } if ($service->isDirty('fqdn')) { + $service->normalizeDomainPortOverrides(); $service->syncNoindexDomains(); } }); @@ -191,6 +200,45 @@ public function fqdns(): Attribute ); } + /** + * Return the public URLs with their persisted internal port overrides. + */ + protected function url(): Attribute + { + return Attribute::make( + get: function (): ?string { + if (blank($this->fqdn)) { + return null; + } + + $overrides = $this->domain_port_overrides ?? []; + + return collect(explode(',', $this->fqdn)) + ->map(function (string $url) use ($overrides): string { + $url = trim($url); + $canonical = DomainPortOverrides::withoutPort($url); + $port = $overrides[$canonical] ?? null; + + if ($port === null) { + return $canonical; + } + + $parts = DomainUrlParts::split($canonical); + + return DomainUrlParts::compose($parts['scheme'], $parts['host'], (string) $port, $parts['path']); + }) + ->implode(','); + }, + ); + } + + public function setEditableUrls(?string $urls): void + { + $normalized = DomainPortOverrides::normalize($urls, null); + $this->fqdn = $normalized['fqdn']; + $this->domain_port_overrides = $normalized['overrides']; + } + /** * Extract port number from a given FQDN URL. * Returns null if no port is specified. @@ -212,6 +260,58 @@ public static function extractPortFromUrl(string $url): ?int } } + /** + * True when saving this URL should confirm that it does not use the required template port. + */ + public function portRequiresConfirmation(string $fqdn, ?int $requiredPort, ?string $previousFqdn = null): bool + { + if ($requiredPort === null) { + return false; + } + + $fqdn = trim($fqdn); + if ($fqdn === '') { + return false; + } + + $canonical = DomainPortOverrides::withoutPort($fqdn); + $explicit = self::extractPortFromUrl($fqdn); + + if ($explicit === $requiredPort) { + return false; + } + + if ($explicit === null) { + $previous = collect(explode(',', (string) $previousFqdn)) + ->filter(); + $previousUrl = $previous->first( + fn (string $url): bool => DomainPortOverrides::withoutPort(trim($url)) === $canonical + ); + + if (is_string($previousUrl) && self::extractPortFromUrl($previousUrl) !== null) { + return true; + } + + return $previousUrl === null; + } + + $existingOverride = $this->domain_port_overrides[$canonical] ?? null; + + return (int) $existingOverride !== $explicit; + } + + public static function withoutPort(string $url): string + { + return DomainPortOverrides::withoutPort($url); + } + + protected function normalizeDomainPortOverrides(): void + { + $normalized = DomainPortOverrides::normalize($this->fqdn, $this->domain_port_overrides); + $this->fqdn = $normalized['fqdn']; + $this->domain_port_overrides = $normalized['overrides']; + } + /** * Check if all FQDNs have a port specified. */ @@ -276,6 +376,7 @@ public function getRequiredPort(): ?int // Extract SERVICE_URL and SERVICE_FQDN variables DIRECTLY DECLARED in this service's environment // (not variables that are merely referenced with ${VAR} syntax) $portFound = null; + $declaresHttpUrl = false; foreach ($environment as $key => $value) { if (is_int($key) && is_string($value)) { // List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value" @@ -284,6 +385,7 @@ public function getRequiredPort(): ?int // Only process direct declarations if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $declaresHttpUrl = true; // Parse to check if it has a port suffix $parsed = parseServiceEnvironmentVariable($envVarName->value()); if ($parsed['has_port'] && $parsed['port']) { @@ -298,6 +400,7 @@ public function getRequiredPort(): ?int // Only process direct declarations if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $declaresHttpUrl = true; // Parse to check if it has a port suffix $parsed = parseServiceEnvironmentVariable($envVarName->value()); if ($parsed['has_port'] && $parsed['port']) { @@ -314,8 +417,12 @@ public function getRequiredPort(): ?int return $portFound; } - // No port-specific variables found for this service, return null - // (DO NOT fall back to service-level port, as that applies to all services) + // HTTP-facing compose services that only declare SERVICE_URL/FQDN (no _PORT + // suffix), such as WordPress, inherit the one-click template `# port:`. + if ($declaresHttpUrl) { + return $this->service->getRequiredPort(); + } + return null; } catch (\Throwable $e) { return null; diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index 62603685e..648869baf 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -20,6 +20,7 @@ class SlackNotificationSettings extends Model 'deployment_success_slack_notifications', 'deployment_failure_slack_notifications', 'status_change_slack_notifications', + 'restart_limit_reached_slack_notifications', 'backup_success_slack_notifications', 'backup_failure_slack_notifications', 'scheduled_task_success_slack_notifications', @@ -44,6 +45,7 @@ class SlackNotificationSettings extends Model 'deployment_success_slack_notifications' => 'boolean', 'deployment_failure_slack_notifications' => 'boolean', 'status_change_slack_notifications' => 'boolean', + 'restart_limit_reached_slack_notifications' => 'boolean', 'backup_success_slack_notifications' => 'boolean', 'backup_failure_slack_notifications' => 'boolean', 'scheduled_task_success_slack_notifications' => 'boolean', diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 604a245fc..e7e0a8c10 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -43,14 +43,20 @@ protected static function boot() } $server = $newStandaloneDocker->server; - $safeNetwork = escapeshellarg($newStandaloneDocker->network); instant_remote_process([ - "docker network inspect {$safeNetwork} >/dev/null 2>&1 || docker network create --driver overlay --attachable {$safeNetwork} >/dev/null", + $newStandaloneDocker->networkCreateCommand(), ], $server, false); ConnectProxyToNetworksJob::dispatchSync($server); }); } + public function networkCreateCommand(): string + { + $safeNetwork = escapeshellarg($this->network); + + return "docker network inspect {$safeNetwork} >/dev/null 2>&1 || docker network create --attachable {$safeNetwork} >/dev/null"; + } + public function setNetworkAttribute(string $value): void { if (! ValidationPatterns::isValidDockerNetwork($value)) { diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 8c644f9bc..3376e239d 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -21,6 +21,7 @@ class TelegramNotificationSettings extends Model 'deployment_success_telegram_notifications', 'deployment_failure_telegram_notifications', 'status_change_telegram_notifications', + 'restart_limit_reached_telegram_notifications', 'backup_success_telegram_notifications', 'backup_failure_telegram_notifications', 'scheduled_task_success_telegram_notifications', @@ -36,6 +37,7 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_deployment_success_thread_id', 'telegram_notifications_deployment_failure_thread_id', 'telegram_notifications_status_change_thread_id', + 'telegram_notifications_restart_limit_reached_thread_id', 'telegram_notifications_backup_success_thread_id', 'telegram_notifications_backup_failure_thread_id', 'telegram_notifications_scheduled_task_success_thread_id', @@ -55,6 +57,7 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_deployment_success_thread_id', 'telegram_notifications_deployment_failure_thread_id', 'telegram_notifications_status_change_thread_id', + 'telegram_notifications_restart_limit_reached_thread_id', 'telegram_notifications_backup_success_thread_id', 'telegram_notifications_backup_failure_thread_id', 'telegram_notifications_scheduled_task_success_thread_id', @@ -76,6 +79,7 @@ class TelegramNotificationSettings extends Model 'deployment_success_telegram_notifications' => 'boolean', 'deployment_failure_telegram_notifications' => 'boolean', 'status_change_telegram_notifications' => 'boolean', + 'restart_limit_reached_telegram_notifications' => 'boolean', 'backup_success_telegram_notifications' => 'boolean', 'backup_failure_telegram_notifications' => 'boolean', 'scheduled_task_success_telegram_notifications' => 'boolean', @@ -90,6 +94,7 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_deployment_success_thread_id' => 'encrypted', 'telegram_notifications_deployment_failure_thread_id' => 'encrypted', 'telegram_notifications_status_change_thread_id' => 'encrypted', + 'telegram_notifications_restart_limit_reached_thread_id' => 'encrypted', 'telegram_notifications_backup_success_thread_id' => 'encrypted', 'telegram_notifications_backup_failure_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_success_thread_id' => 'encrypted', diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php index c6a81b50a..7ffd20a8c 100644 --- a/app/Models/WebhookNotificationSettings.php +++ b/app/Models/WebhookNotificationSettings.php @@ -20,6 +20,7 @@ class WebhookNotificationSettings extends Model 'deployment_success_webhook_notifications', 'deployment_failure_webhook_notifications', 'status_change_webhook_notifications', + 'restart_limit_reached_webhook_notifications', 'backup_success_webhook_notifications', 'backup_failure_webhook_notifications', 'scheduled_task_success_webhook_notifications', @@ -46,6 +47,7 @@ protected function casts(): array 'deployment_success_webhook_notifications' => 'boolean', 'deployment_failure_webhook_notifications' => 'boolean', 'status_change_webhook_notifications' => 'boolean', + 'restart_limit_reached_webhook_notifications' => 'boolean', 'backup_success_webhook_notifications' => 'boolean', 'backup_failure_webhook_notifications' => 'boolean', 'scheduled_task_success_webhook_notifications' => 'boolean', diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index c5567a3a6..01f58af7e 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -21,7 +21,7 @@ public function __construct(public PersonalAccessToken $token) $this->onQueue('high'); $this->tokenName = $token->name; $this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? ''; - $this->manageUrl = route('security.api-tokens'); + $this->manageUrl = base_url().'/security/api-tokens'; } public function via(object $notifiable): array diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php index 635dfdbdc..de9de1f98 100644 --- a/app/Notifications/Application/RestartLimitReached.php +++ b/app/Notifications/Application/RestartLimitReached.php @@ -3,6 +3,10 @@ namespace App\Notifications\Application; use App\Models\Application; +use App\Models\ApplicationPreview; +use App\Models\BaseModel; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; @@ -27,26 +31,45 @@ class RestartLimitReached extends CustomEmailNotification public int $max_restart_count; - public function __construct(public Application $resource) + public function __construct(public BaseModel $resource) { $this->onQueue('high'); $this->afterCommit(); - $this->resource_name = data_get($resource, 'name'); - $this->project_uuid = data_get($resource, 'environment.project.uuid'); - $this->environment_uuid = data_get($resource, 'environment.uuid'); - $this->environment_name = data_get($resource, 'environment.name'); + $environment = data_get($resource, 'environment') + ?? data_get($resource, 'application.environment') + ?? data_get($resource, 'service.environment'); + $this->resource_name = $resource instanceof ApplicationPreview + ? data_get($resource, 'application.name').' PR #'.$resource->pull_request_id + : data_get($resource, 'name'); + $this->project_uuid = data_get($environment, 'project.uuid'); + $this->environment_uuid = data_get($environment, 'uuid'); + $this->environment_name = data_get($environment, 'name'); $this->fqdn = data_get($resource, 'fqdn', null); $this->restart_count = $resource->restart_count; - $this->max_restart_count = $resource->max_restart_count; + $this->max_restart_count = method_exists($resource, 'restartLimitMaximum') + ? $resource->restartLimitMaximum() + : $resource->max_restart_count; if (str($this->fqdn)->explode(',')->count() > 1) { $this->fqdn = str($this->fqdn)->explode(',')->first(); } - $this->resource_url = $this->resource->link() ?? base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->resource->uuid}"; + $this->resource_url = $this->resolveResourceUrl($resource); + } + + private function resolveResourceUrl(BaseModel $resource): string + { + [$type, $uuid] = match (true) { + $resource instanceof Application => ['application', $resource->uuid], + $resource instanceof ApplicationPreview => ['application', $resource->application->uuid], + $resource instanceof ServiceApplication, $resource instanceof ServiceDatabase => ['service', $resource->service->uuid], + default => ['database', $resource->uuid], + }; + + return base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/{$type}/{$uuid}"; } public function via(object $notifiable): array { - return $notifiable->getEnabledChannels('status_change'); + return $notifiable->getEnabledChannels('restart_limit_reached'); } public function toMail(): MailMessage @@ -68,7 +91,7 @@ public function toDiscord(): DiscordMessage { return new DiscordMessage( title: ':warning: Restart limit reached', - description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Application in Coolify]({$this->resource_url})", + description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Resource in Coolify]({$this->resource_url})", color: DiscordMessage::errorColor(), isCritical: true, ); @@ -82,7 +105,7 @@ public function toTelegram(): array 'message' => $message, 'buttons' => [ [ - 'text' => 'Open Application in Coolify', + 'text' => 'Open Resource in Coolify', 'url' => $this->resource_url, ], ], @@ -99,7 +122,7 @@ public function toPushover(): PushoverMessage message: $message, buttons: [ [ - 'text' => 'Open Application in Coolify', + 'text' => 'Open Resource in Coolify', 'url' => $this->resource_url, ], ], @@ -110,10 +133,13 @@ public function toSlack(): SlackMessage { $title = 'Restart limit reached'; $description = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})"; + $environment = data_get($this->resource, 'environment') + ?? data_get($this->resource, 'application.environment') + ?? data_get($this->resource, 'service.environment'); - $description .= "\n\n*Project:* ".data_get($this->resource, 'environment.project.name'); + $description .= "\n\n*Project:* ".data_get($environment, 'project.name'); $description .= "\n*Environment:* {$this->environment_name}"; - $description .= "\n*Application URL:* {$this->resource_url}"; + $description .= "\n*Resource URL:* {$this->resource_url}"; return new SlackMessage( title: $title, @@ -130,6 +156,8 @@ public function toWebhook(): array 'event' => 'restart_limit_reached', 'application_name' => $this->resource_name, 'application_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource_name, + 'resource_uuid' => $this->resource->uuid, 'restart_count' => $this->restart_count, 'max_restart_count' => $this->max_restart_count, 'url' => $this->resource_url, diff --git a/app/Notifications/Channels/TelegramChannel.php b/app/Notifications/Channels/TelegramChannel.php index c2fa3ff10..a52feda08 100644 --- a/app/Notifications/Channels/TelegramChannel.php +++ b/app/Notifications/Channels/TelegramChannel.php @@ -3,6 +3,22 @@ namespace App\Notifications\Channels; use App\Jobs\SendMessageToTelegramJob; +use App\Notifications\Application\DeploymentFailed; +use App\Notifications\Application\DeploymentSuccess; +use App\Notifications\Application\RestartLimitReached; +use App\Notifications\Application\StatusChanged; +use App\Notifications\Container\ContainerRestarted; +use App\Notifications\Database\BackupFailed; +use App\Notifications\Database\BackupSuccess; +use App\Notifications\ScheduledTask\TaskFailed; +use App\Notifications\ScheduledTask\TaskSuccess; +use App\Notifications\Server\DockerCleanupFailed; +use App\Notifications\Server\DockerCleanupSuccess; +use App\Notifications\Server\HighDiskUsage; +use App\Notifications\Server\Reachable; +use App\Notifications\Server\ServerPatchCheck; +use App\Notifications\Server\TraefikVersionOutdated; +use App\Notifications\Server\Unreachable; class TelegramChannel { @@ -17,25 +33,25 @@ public function send($notifiable, $notification): void $chatId = $settings->telegram_chat_id; $threadId = match (get_class($notification)) { - \App\Notifications\Application\DeploymentSuccess::class => $settings->telegram_notifications_deployment_success_thread_id, - \App\Notifications\Application\DeploymentFailed::class => $settings->telegram_notifications_deployment_failure_thread_id, - \App\Notifications\Application\StatusChanged::class, - \App\Notifications\Container\ContainerRestarted::class, - \App\Notifications\Container\ContainerStopped::class => $settings->telegram_notifications_status_change_thread_id, + DeploymentSuccess::class => $settings->telegram_notifications_deployment_success_thread_id, + DeploymentFailed::class => $settings->telegram_notifications_deployment_failure_thread_id, + StatusChanged::class, + ContainerRestarted::class => $settings->telegram_notifications_status_change_thread_id, + RestartLimitReached::class => $settings->telegram_notifications_restart_limit_reached_thread_id, - \App\Notifications\Database\BackupSuccess::class => $settings->telegram_notifications_backup_success_thread_id, - \App\Notifications\Database\BackupFailed::class => $settings->telegram_notifications_backup_failure_thread_id, + BackupSuccess::class => $settings->telegram_notifications_backup_success_thread_id, + BackupFailed::class => $settings->telegram_notifications_backup_failure_thread_id, - \App\Notifications\ScheduledTask\TaskSuccess::class => $settings->telegram_notifications_scheduled_task_success_thread_id, - \App\Notifications\ScheduledTask\TaskFailed::class => $settings->telegram_notifications_scheduled_task_failure_thread_id, - - \App\Notifications\Server\DockerCleanupSuccess::class => $settings->telegram_notifications_docker_cleanup_success_thread_id, - \App\Notifications\Server\DockerCleanupFailed::class => $settings->telegram_notifications_docker_cleanup_failure_thread_id, - \App\Notifications\Server\HighDiskUsage::class => $settings->telegram_notifications_server_disk_usage_thread_id, - \App\Notifications\Server\Unreachable::class => $settings->telegram_notifications_server_unreachable_thread_id, - \App\Notifications\Server\Reachable::class => $settings->telegram_notifications_server_reachable_thread_id, - \App\Notifications\Server\ServerPatchCheck::class => $settings->telegram_notifications_server_patch_thread_id, + TaskSuccess::class => $settings->telegram_notifications_scheduled_task_success_thread_id, + TaskFailed::class => $settings->telegram_notifications_scheduled_task_failure_thread_id, + DockerCleanupSuccess::class => $settings->telegram_notifications_docker_cleanup_success_thread_id, + DockerCleanupFailed::class => $settings->telegram_notifications_docker_cleanup_failure_thread_id, + HighDiskUsage::class => $settings->telegram_notifications_server_disk_usage_thread_id, + Unreachable::class => $settings->telegram_notifications_server_unreachable_thread_id, + Reachable::class => $settings->telegram_notifications_server_reachable_thread_id, + ServerPatchCheck::class => $settings->telegram_notifications_server_patch_thread_id, + TraefikVersionOutdated::class => $settings->telegram_notifications_traefik_outdated_thread_id, default => null, }; diff --git a/app/Notifications/Container/ContainerStopped.php b/app/Notifications/Container/ContainerStopped.php deleted file mode 100644 index f518cd2fd..000000000 --- a/app/Notifications/Container/ContainerStopped.php +++ /dev/null @@ -1,123 +0,0 @@ -onQueue('high'); - } - - public function via(object $notifiable): array - { - return $notifiable->getEnabledChannels('status_change'); - } - - public function toMail(): MailMessage - { - $mail = new MailMessage; - $mail->subject("Coolify: A resource has been stopped unexpectedly on {$this->server->name}"); - $mail->view('emails.container-stopped', [ - 'containerName' => $this->name, - 'serverName' => $this->server->name, - 'url' => $this->url, - ]); - - return $mail; - } - - public function toDiscord(): DiscordMessage - { - $message = new DiscordMessage( - title: ':cross_mark: Resource stopped', - description: "{$this->name} has been stopped unexpectedly on {$this->server->name}.", - color: DiscordMessage::errorColor(), - ); - - if ($this->url) { - $message->addField('Resource', '[Link]('.$this->url.')'); - } - - return $message; - } - - public function toTelegram(): array - { - $message = "Coolify: A resource ($this->name) has been stopped unexpectedly on {$this->server->name}"; - $payload = [ - 'message' => $message, - ]; - if ($this->url) { - $payload['buttons'] = [ - [ - [ - 'text' => 'Open Application in Coolify', - 'url' => $this->url, - ], - ], - ]; - } - - return $payload; - } - - public function toPushover(): PushoverMessage - { - $buttons = []; - if ($this->url) { - $buttons[] = [ - 'text' => 'Open Application in Coolify', - 'url' => $this->url, - ]; - } - - return new PushoverMessage( - title: 'Resource stopped', - level: 'error', - message: "A resource ({$this->name}) has been stopped unexpectedly on {$this->server->name}", - buttons: $buttons, - ); - } - - public function toSlack(): SlackMessage - { - $title = 'Resource stopped'; - $description = "A resource ({$this->name}) has been stopped unexpectedly on {$this->server->name}"; - - if ($this->url) { - $description .= "\n*Resource URL:* {$this->url}"; - } - - return new SlackMessage( - title: $title, - description: $description, - color: SlackMessage::errorColor() - ); - } - - public function toWebhook(): array - { - $data = [ - 'success' => false, - 'message' => 'Resource stopped unexpectedly', - 'event' => 'container_stopped', - 'container_name' => $this->name, - 'server_name' => $this->server->name, - 'server_uuid' => $this->server->uuid, - ]; - - if ($this->url) { - $data['url'] = $this->url; - } - - return $data; - } -} diff --git a/app/Notifications/ScheduledTask/TaskFailed.php b/app/Notifications/ScheduledTask/TaskFailed.php index bd060112a..2ca3874ba 100644 --- a/app/Notifications/ScheduledTask/TaskFailed.php +++ b/app/Notifications/ScheduledTask/TaskFailed.php @@ -2,6 +2,7 @@ namespace App\Notifications\ScheduledTask; +use App\Models\Application; use App\Models\ScheduledTask; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; @@ -16,10 +17,10 @@ class TaskFailed extends CustomEmailNotification public function __construct(public ScheduledTask $task, public string $output) { $this->onQueue('high'); - if ($task->application) { - $this->url = $task->application->taskLink($task->uuid); - } elseif ($task->service) { - $this->url = $task->service->taskLink($task->uuid); + $resource = $task->application ?? $task->service; + if ($resource) { + $type = $resource instanceof Application ? 'application' : 'service'; + $this->url = base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$task->uuid}"; } } diff --git a/app/Notifications/ScheduledTask/TaskSuccess.php b/app/Notifications/ScheduledTask/TaskSuccess.php index 58c959bd8..2978eaed3 100644 --- a/app/Notifications/ScheduledTask/TaskSuccess.php +++ b/app/Notifications/ScheduledTask/TaskSuccess.php @@ -2,6 +2,7 @@ namespace App\Notifications\ScheduledTask; +use App\Models\Application; use App\Models\ScheduledTask; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; @@ -16,10 +17,10 @@ class TaskSuccess extends CustomEmailNotification public function __construct(public ScheduledTask $task, public string $output) { $this->onQueue('high'); - if ($task->application) { - $this->url = $task->application->taskLink($task->uuid); - } elseif ($task->service) { - $this->url = $task->service->taskLink($task->uuid); + $resource = $task->application ?? $task->service; + if ($resource) { + $type = $resource instanceof Application ? 'application' : 'service'; + $this->url = base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$task->uuid}"; } } diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 73c7a665d..8d2a5e395 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -7,7 +7,6 @@ use App\Notifications\Dto\SlackMessage; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Collection; -use Spatie\Url\Url; class SslExpirationNotification extends CustomEmailNotification { @@ -19,39 +18,9 @@ public function __construct(array|Collection $resources) { $this->onQueue('high'); $this->resources = collect($resources); - - // Collect URLs for each resource - $this->resources->each(function ($resource) { - if (data_get($resource, 'environment.project.uuid')) { - $routeName = match ($resource->type()) { - 'application' => 'project.application.configuration', - 'database' => 'project.database.configuration', - 'service' => 'project.service.configuration', - default => null - }; - - if ($routeName) { - $route = route($routeName, [ - 'project_uuid' => data_get($resource, 'environment.project.uuid'), - 'environment_uuid' => data_get($resource, 'environment.uuid'), - $resource->type().'_uuid' => data_get($resource, 'uuid'), - ]); - - $settings = instanceSettings(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - $this->urls[$resource->name] = $url->__toString(); - } else { - $this->urls[$resource->name] = $route; - } - } - } - }); + $this->urls = $this->resources->mapWithKeys(fn ($resource) => [ + $resource->name => base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/database/{$resource->uuid}", + ])->all(); } public function via(object $notifiable): array diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php index 8859a9980..3a59ad58f 100644 --- a/app/Services/ContainerStatusAggregator.php +++ b/app/Services/ContainerStatusAggregator.php @@ -18,14 +18,13 @@ * State Priority (highest to lowest): * 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 + * 3. Mixed (running + exited) → degraded:unhealthy + * 4. Mixed (running + starting) → starting:unknown + * 5. Running → running:healthy/unhealthy/unknown + * 6. Dead/Removing → degraded:unhealthy + * 7. Paused → paused:unknown + * 8. Starting/Created → starting:unknown + * 9. Exited → exited * * The $preserveRestarting parameter controls whether "restarting" containers should be * reported as "restarting:unknown" (true) or "degraded:unhealthy" (false, default). @@ -228,23 +227,18 @@ private function resolveStatus( return $preserveRestarting ? 'restarting:unknown' : 'degraded:unhealthy'; } - // Priority 3: Crash loop detection (exited with restart count > 0) - if ($hasExited && $maxRestartCount > 0) { - return 'degraded:unhealthy'; - } - - // Priority 4: Mixed state (some running, some exited = degraded) + // Priority 3: Mixed state (some running, some exited = degraded) if ($hasRunning && $hasExited) { return 'degraded:unhealthy'; } - // Priority 5: Mixed state (some running, some starting = still starting) + // Priority 4: 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) + // Priority 5: Running containers (check health status) if ($hasRunning) { if ($hasUnhealthy) { return 'running:unhealthy'; @@ -255,22 +249,22 @@ private function resolveStatus( } } - // Priority 7: Dead or removing containers + // Priority 6: Dead or removing containers if ($hasDead) { return 'degraded:unhealthy'; } - // Priority 8: Paused containers + // Priority 7: Paused containers if ($hasPaused) { return 'paused:unknown'; } - // Priority 9: Starting/created containers + // Priority 8: Starting/created containers if ($hasStarting) { return 'starting:unknown'; } - // Priority 10: All containers exited (no restart count = truly stopped) + // Priority 9: All containers exited return 'exited'; } } diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 386bdd5bb..e3ba77163 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -7,6 +7,7 @@ use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText; +use App\Support\DomainPortOverrides; use Illuminate\Support\Arr; class ApplicationConfigurationSnapshot @@ -194,6 +195,7 @@ private function domainItems(): array { return [ $this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'), + $this->item('domain_port_overrides', 'Domain port overrides', DomainPortOverrides::sorted($this->application->domain_port_overrides), 'redeploy'), $this->item('noindex_domains', 'Search engine indexing', $this->application->noindexDomains()->all(), 'redeploy'), $this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'), $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'), diff --git a/app/Services/RestartCountTracker.php b/app/Services/RestartCountTracker.php new file mode 100644 index 000000000..e67deacb3 --- /dev/null +++ b/app/Services/RestartCountTracker.php @@ -0,0 +1,31 @@ + $previousRestartCount; + $restartCountChanged = $newGeneration || $restartCountIncreased; + + $restartLimitReached = $maxRestartCount > 0 + && $observedRestartCount >= $maxRestartCount; + + return [ + 'restart_count' => $restartCountChanged ? $observedRestartCount : $previousRestartCount, + 'restart_count_changed' => $restartCountChanged, + 'restart_limit_reached' => $restartLimitReached, + 'new_generation' => $newGeneration, + ]; + } +} diff --git a/app/Support/DomainPortOverrides.php b/app/Support/DomainPortOverrides.php new file mode 100644 index 000000000..320540ad6 --- /dev/null +++ b/app/Support/DomainPortOverrides.php @@ -0,0 +1,91 @@ +|null $overrides + * @return array + */ + public static function sorted(?array $overrides): array + { + return collect($overrides ?? [])->sortKeys()->all(); + } + + public static function withoutPort(string $url): string + { + $parts = DomainUrlParts::split($url); + + return DomainUrlParts::compose($parts['scheme'], $parts['host'], path: $parts['path']); + } + + /** + * @param array|null $existing + * @return array{fqdn: ?string, overrides: ?array} + */ + public static function normalize(?string $fqdn, ?array $existing): array + { + if (blank($fqdn)) { + return ['fqdn' => null, 'overrides' => null]; + } + + $existingOverrides = $existing ?? []; + $normalizedDomains = collect(explode(',', $fqdn)) + ->map(fn (string $domain): string => trim($domain)) + ->filter() + ->map(function (string $domain) use ($existingOverrides): array { + $portlessDomain = self::withoutPort($domain); + $parts = DomainUrlParts::split($domain); + $port = $parts['port'] !== '' + ? (int) $parts['port'] + : ($existingOverrides[$portlessDomain] ?? null); + + return ['domain' => $portlessDomain, 'port' => $port]; + }) + ->keyBy('domain') + ->values(); + + $effectiveOverrides = $normalizedDomains + ->filter(fn (array $domain): bool => filled($domain['port'])) + ->mapWithKeys(fn (array $domain): array => [$domain['domain'] => (int) $domain['port']]); + + $normalizedDomains = $normalizedDomains->map(function (array $domain) use ($effectiveOverrides): array { + if (filled($domain['port'])) { + return $domain; + } + + $counterpart = self::wwwCounterpart($domain['domain']); + $domain['port'] = $counterpart === null ? null : $effectiveOverrides->get($counterpart); + + return $domain; + }); + + $normalizedFqdn = $normalizedDomains->pluck('domain')->implode(','); + $overrides = $normalizedDomains + ->filter(fn (array $domain): bool => filled($domain['port'])) + ->mapWithKeys(fn (array $domain): array => [$domain['domain'] => (int) $domain['port']]) + ->all(); + + return [ + 'fqdn' => $normalizedFqdn === '' ? null : $normalizedFqdn, + 'overrides' => $overrides ?: null, + ]; + } + + private static function wwwCounterpart(string $url): ?string + { + $parts = DomainUrlParts::split($url); + $host = $parts['host']; + + if ($host === '') { + return null; + } + + $counterpartHost = str_starts_with(strtolower($host), 'www.') + ? substr($host, 4) + : 'www.'.$host; + + return DomainUrlParts::compose($parts['scheme'], $counterpartHost, path: $parts['path']); + } +} diff --git a/app/Support/ServiceComposeUrl.php b/app/Support/ServiceComposeUrl.php index cdeb75e58..5d3ded154 100644 --- a/app/Support/ServiceComposeUrl.php +++ b/app/Support/ServiceComposeUrl.php @@ -25,15 +25,7 @@ public static function validateUrlString(?string $urlValue, bool $forceDomainOve ->map(fn ($url) => trim((string) $url)) ->filter(); - foreach ($urls as $url) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { - $errors[] = "Invalid URL: {$url}"; - } - $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; - if (! in_array(strtolower($scheme), ['http', 'https'], true)) { - $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; - } - } + $errors = ValidationPatterns::validateApplicationDomains($urls->implode(',')); $duplicates = $urls->duplicates()->unique()->values(); if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 41b27f9ff..4656406fc 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -108,6 +108,11 @@ class ValidationPatterns */ public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u'; + /** + * Pattern for environment variable keys written to shell-sourced files. + */ + public const SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_]*\z/u'; + /** * Characters that are valid in some URL positions but unsafe for values * that are later reused in shell assignment contexts. @@ -192,6 +197,43 @@ public static function isValidEnvironmentVariableKey(string $value): bool return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1; } + /** + * Make an environment variable key safe to show in deployment logs. + * + * Control characters are escaped and long values are truncated so an + * unexpected key cannot corrupt or overflow the deployment log output. + */ + public static function displayShellEnvironmentVariableKey(string $value, int $maxLength = 80): string + { + $printable = str($value) + ->replace(["\0", "\r", "\n", "\t"], ['\\0', '\\r', '\\n', '\\t']) + ->value(); + + $printable = preg_replace_callback( + '/[\x00-\x1F\x7F]/', + fn (array $matches): string => sprintf('\\x%02X', ord($matches[0])), + $printable, + ); + + if ($printable === '') { + return '(empty)'; + } + + return str($printable)->limit($maxLength)->value(); + } + + /** + * Validate an environment variable key before writing it to a shell-sourced file. + */ + public static function validatedShellEnvironmentVariableKey(string $value): string + { + if (preg_match(self::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) !== 1) { + throw new \InvalidArgumentException('Invalid environment variable name '.self::displayShellEnvironmentVariableKey($value).'. Names must start with a letter or underscore and contain only letters, numbers, and underscores.'); + } + + return $value; + } + /** * Check if a string is a valid S3 bucket name. */ @@ -570,8 +612,23 @@ public static function validateApplicationDomains(mixed $value): array continue; } - if (blank(parse_url($url, PHP_URL_HOST))) { + $host = parse_url($url, PHP_URL_HOST); + if (blank($host)) { $errors[] = "Invalid URL: {$url}"; + + continue; + } + + $port = parse_url($url, PHP_URL_PORT); + if ($port !== null && ($port < 1 || $port > 65535)) { + $errors[] = "Invalid port for URL: {$url}. The port must be between 1 and 65535."; + + continue; + } + + $unwrappedHost = trim((string) $host, '[]'); + if (! str_contains($unwrappedHost, '.') && filter_var($unwrappedHost, FILTER_VALIDATE_IP) === false) { + $errors[] = "Invalid URL: {$url}. The hostname must be a fully qualified domain name."; } } diff --git a/app/Traits/HasNoindexDomains.php b/app/Traits/HasNoindexDomains.php index c3ba8a7d8..f3858cc61 100644 --- a/app/Traits/HasNoindexDomains.php +++ b/app/Traits/HasNoindexDomains.php @@ -2,6 +2,7 @@ namespace App\Traits; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use Illuminate\Support\Collection; @@ -19,7 +20,7 @@ public function noindexDomains(): Collection { return collect($this->noindex_domains ?? []) ->filter(fn ($domain) => is_string($domain) && filled($domain)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)) + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)) ->unique() ->values(); } @@ -27,7 +28,7 @@ public function noindexDomains(): Collection public function isDomainNoindexed(string $domain): bool { return $this->noindexDomains()->contains( - ValidationPatterns::normalizeApplicationDomainUrl($domain) + $this->normalizeNoindexDomain($domain) ); } @@ -35,7 +36,7 @@ public function setNoindexDomains(iterable $domains): void { $this->noindex_domains = collect($domains) ->filter(fn ($domain) => is_string($domain) && filled($domain)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)) + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)) ->intersect($this->currentDomains()) ->unique() ->values() @@ -57,7 +58,23 @@ public function syncNoindexDomains(): void private function currentDomains(): Collection { - return collect(ValidationPatterns::applicationDomainList($this->fqdn)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)); + $domains = collect(ValidationPatterns::applicationDomainList($this->fqdn)); + $composeDomains = json_decode((string) ($this->getAttributes()['docker_compose_domains'] ?? null), true); + + if (is_array($composeDomains)) { + foreach ($composeDomains as $entry) { + $domains->push(...ValidationPatterns::applicationDomainList(composeDomainEntryString($entry))); + } + } + + return $domains + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)); + } + + private function normalizeNoindexDomain(string $domain): string + { + return DomainPortOverrides::withoutPort( + ValidationPatterns::normalizeApplicationDomainUrl($domain) + ); } } diff --git a/app/Traits/HasRestartLimit.php b/app/Traits/HasRestartLimit.php new file mode 100644 index 000000000..edb461cdc --- /dev/null +++ b/app/Traits/HasRestartLimit.php @@ -0,0 +1,73 @@ +mergeFillable(['restart_count', 'max_restart_count', 'restart_limit_reached', 'last_restart_at', 'last_restart_type']); + $this->mergeCasts([ + 'restart_count' => 'integer', + 'max_restart_count' => 'integer', + 'restart_limit_reached' => 'boolean', + 'last_restart_at' => 'datetime', + 'last_restart_type' => 'string', + ]); + } + + public function stoppedAfterRestartLimit(): bool + { + return str($this->status)->startsWith('exited') && $this->restart_limit_reached === true; + } + + public function trackRestartCount(int $observedRestartCount): bool + { + $state = (new RestartCountTracker)->evaluate( + previousRestartCount: $this->restart_count ?? 0, + observedRestartCount: $observedRestartCount, + maxRestartCount: $this->restartLimitMaximum(), + ); + + if ($state['restart_count_changed']) { + $hasCrashRestarts = $state['restart_count'] > 0; + $this->update([ + 'restart_count' => $state['restart_count'], + 'last_restart_at' => $hasCrashRestarts ? now() : null, + 'last_restart_type' => $hasCrashRestarts ? 'crash' : null, + ]); + } + + if (! $state['restart_limit_reached']) { + return false; + } + + $claimed = $this->newQuery() + ->whereKey($this->getKey()) + ->where('restart_limit_reached', false) + ->update(['restart_limit_reached' => true]) === 1; + + if ($claimed) { + $this->restart_limit_reached = true; + } + + return $claimed; + } + + public function resetRestartLimit(): void + { + $this->update([ + 'restart_count' => 0, + 'restart_limit_reached' => false, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); + } + + public function restartLimitMaximum(): int + { + return $this->max_restart_count ?? 0; + } +} diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 00300d26a..84232a8d7 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -530,7 +530,7 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool ->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain)); } -function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null) +function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, array $domainPortOverrides = []) { $labels = collect([]); if ($serviceLabels) { @@ -554,7 +554,8 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, if ($schema === 'https' && ! $is_force_https_enabled) { $siteAddress = "http://{$host}, https://{$host}"; } - $port = $url->getPort(); + $portlessDomain = ServiceApplication::withoutPort($domain); + $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); $handle = 'handle_path'; if (! $is_stripprefix_enabled) { $handle = 'handle'; @@ -600,7 +601,40 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, return $labels->sort(); } -function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true) +function firstDockerComposeServicePort(mixed $service): ?int +{ + $portDefinitions = collect(data_get($service, 'expose', [])) + ->merge(data_get($service, 'ports', [])); + + foreach ($portDefinitions as $definition) { + $port = is_array($definition) + ? data_get($definition, 'target') + : str((string) $definition)->before('/')->afterLast(':')->value(); + + if (is_numeric($port) && (int) $port >= 1 && (int) $port <= 65535) { + return (int) $port; + } + } + + return null; +} + +function dockerComposeServicePort(?string $compose, ?string $serviceName): ?int +{ + if (blank($compose) || blank($serviceName)) { + return null; + } + + try { + $services = data_get(Yaml::parse($compose), 'services', []); + } catch (Throwable) { + return null; + } + + return firstDockerComposeServicePort(is_array($services) ? ($services[$serviceName] ?? null) : null); +} + +function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -655,7 +689,8 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $host = $url->getHost(); $path = $url->getPath(); $schema = $url->getScheme(); - $port = $url->getPort(); + $portlessDomain = ServiceApplication::withoutPort($domain); + $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); if (is_null($port) && ! is_null($onlyPort)) { $port = $onlyPort; } @@ -898,6 +933,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $application->domain_port_overrides ?? [], )); break; case ProxyTypes::CADDY->value: @@ -914,6 +950,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $application->domain_port_overrides ?? [], )); break; } @@ -931,6 +968,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $application->domain_port_overrides ?? [], )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -945,6 +983,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $application->domain_port_overrides ?? [], )); } } @@ -972,6 +1011,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); break; case ProxyTypes::CADDY->value: @@ -987,6 +1027,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); break; } @@ -1003,6 +1044,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -1016,6 +1058,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); } } diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index 4e4ad73e6..28ff41b3d 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -443,6 +443,26 @@ function getComposeServiceDomainString(array|Collection $domains, string $servic return $matches[0]['domain']; } +/** + * Determine whether a compose service already has a domain-map entry, including + * an explicitly empty entry left when a user removes its generated domain. + * + * @param array|Collection $domains + */ +function hasComposeServiceDomainEntry(array|Collection $domains, string $serviceName): bool +{ + $normalized = normalizeComposeServiceName($serviceName); + + foreach (collect($domains)->keys() as $key) { + $key = (string) $key; + if ($key === $serviceName || normalizeComposeServiceName($key) === $normalized) { + return true; + } + } + + return false; +} + function composeDomainEntryString(mixed $entry): ?string { if (is_object($entry)) { diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index b47e57047..03ebf5af7 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -525,8 +525,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $originalServiceName = findComposeServiceName($normalizedServiceName, array_keys($services)); if ($originalServiceName !== null) { $domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []; - $domainExists = getComposeServiceDomainString($domains, $originalServiceName); - if (is_null($domainExists)) { + if (! hasComposeServiceDomainEntry($domains, $originalServiceName)) { $serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value(); $domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid"); if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) { @@ -648,12 +647,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Only add domain if the service exists if ($composeServiceName !== null) { $domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []; - $domainExists = getComposeServiceDomainString($domains, $composeServiceName); - // Update domain using URL with port if applicable $domainValue = $port ? $urlWithPort : $url; - if (is_null($domainExists)) { + if (! hasComposeServiceDomainEntry($domains, $composeServiceName)) { $resource->docker_compose_domains = json_encode(putComposeServiceDomain( $domains, $composeServiceName, @@ -1265,24 +1262,16 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $fqdns = collect([]); } } else { - $fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) { - $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId); - $url = Url::fromString($fqdn); - $template = $resource->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - - return $preview_fqdn; - }); + $generatedDomains = $fqdns->map( + fn ($fqdn) => $preview->generatedPreviewDomain((string) $fqdn) + ); + $fqdns = $generatedDomains->pluck('url'); + $preview->fqdn = $fqdns->implode(','); + $preview->domain_port_overrides = $generatedDomains + ->filter(fn (array $generated): bool => filled($generated['port'])) + ->mapWithKeys(fn (array $generated): array => [$generated['url'] => $generated['port']]) + ->all(); + $preview->save(); } } } @@ -1359,6 +1348,14 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true) ? $composeRedirect : 'both'; + $previewForPorts = $isPullRequest + ? ($resource->previews()->find($preview_id) ?? ApplicationPreview::where('application_id', $resource->id)->where('pull_request_id', $pullRequestId)->first()) + : null; + $domainPortOverrides = $isPullRequest + ? ($previewForPorts?->domain_port_overrides ?? []) + : ($originalResource->domain_port_overrides ?? []); + $exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array; + $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -1374,8 +1371,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; case ProxyTypes::CADDY->value: @@ -1389,9 +1388,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; } @@ -1405,8 +1406,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $labelNetwork, @@ -1418,9 +1421,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); } } @@ -1849,11 +1854,7 @@ function serviceParser(Service $resource): Collection // Only save fqdn to ServiceApplication, not ServiceDatabase if ($isServiceApplication && is_null($savedService->fqdn)) { // Save URL (with scheme) to database, not FQDN - if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) { - $savedService->fqdn = $urlWithPort; - } else { - $savedService->fqdn = $urlWithPort; - } + $savedService->fqdn = $url; $savedService->save(); } @@ -2636,6 +2637,9 @@ function serviceParser(Service $resource): Collection $redirectDirection = in_array(data_get($originalResource, 'redirect'), ['www', 'non-www', 'both'], true) ? data_get($originalResource, 'redirect') : 'both'; + $onlyPort = $originalResource instanceof ServiceApplication + ? ($originalResource->getRequiredPort() ?? $predefinedPort) + : $predefinedPort; if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -2651,6 +2655,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2666,7 +2672,9 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2682,6 +2690,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2695,7 +2705,9 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 07fdeb086..96257a632 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -1,5 +1,30 @@ asset($defaultLogo), + 'logo_cdn_url' => asset($defaultLogo), + 'logo_default_url' => asset($defaultLogo), + ]; + } + + if (str_starts_with($logo, 'svg/')) { + $logo = 'svgs/'.str($logo)->after('svg/'); + } + + $logo = ltrim($logo, '/'); + + return [ + 'logo' => asset($logo), + 'logo_cdn_url' => 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo, + 'logo_default_url' => asset($defaultLogo), + ]; +} + use App\Models\Application; use App\Models\Service; use App\Models\ServiceApplication; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index c3bd4a223..f9bf44dff 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -12,6 +12,8 @@ use App\Models\InstanceSettings; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; +use App\Models\Project; +use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -815,6 +817,54 @@ function firstDomainFromList(?string $fqdns): string { return trim((string) str($fqdns ?? '')->explode(',')->first()); } +function profile_avatar_url(User $user): string +{ + if ($user->avatar_storage_type === 's3') { + $url = s3_image_url($user->avatar_s3_storage_id, $user->avatar_path, $user->updated_at->timestamp); + if ($url) { + return $url; + } + } + + return route('profile.avatar', ['v' => $user->updated_at->timestamp]); +} + +function project_icon_url(Project $project): string +{ + if ($project->icon_storage_type === 's3') { + $url = s3_image_url($project->icon_s3_storage_id, $project->icon_path, $project->updated_at->timestamp); + if ($url) { + return $url; + } + } + + return route('project.icon', [ + 'project_uuid' => $project->uuid, + 'v' => $project->updated_at->timestamp, + ]); +} + +function s3_image_url(?int $storageId, ?string $path, int $version): ?string +{ + if (! $storageId || blank($path)) { + return null; + } + + $storage = S3Storage::query() + ->whereKey($storageId) + ->whereTeamId(0) + ->where('is_usable', true) + ->first(); + + if (! $storage) { + return null; + } + + $baseUrl = config('constants.coolify.avatar_cdn_url') ?: $storage->awsUrl(); + + return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version; +} + /** * If fqdn is set, return it, otherwise return public ip. */ @@ -3053,6 +3103,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $redirectDirection = in_array(data_get($savedService, 'redirect'), ['www', 'non-www', 'both'], true) ? data_get($savedService, 'redirect') : 'both'; + $domainPortOverrides = $savedService instanceof ServiceApplication + ? ($savedService->domain_port_overrides ?? []) + : []; + $onlyPort = $savedService instanceof ServiceApplication + ? ($savedService->getRequiredPort() ?? $predefinedPort) + : $predefinedPort; if ($shouldGenerateLabelsExactly) { switch ($resource->server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -3065,8 +3121,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; case ProxyTypes::CADDY->value: @@ -3080,8 +3138,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, + predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; } @@ -3095,8 +3156,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $resource->destination->network, @@ -3108,8 +3171,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, + predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); } } @@ -3808,6 +3874,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $fqdns = str($fqdns)->explode(','); if ($pull_request_id !== 0) { $preview = $resource->previews()->find($preview_id); + if (! $preview) { + try { + $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pull_request_id); + } catch (ModelNotFoundException) { + throw new RuntimeException('Preview not found.'); + } + } $docker_compose_domains = json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []; if (count($docker_compose_domains) > 0) { $found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName); @@ -3817,22 +3890,20 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $fqdns = collect([]); } } else { - $fqdns = $fqdns->map(function ($fqdn) use ($pull_request_id, $resource) { - $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pull_request_id); - $url = Url::fromString($fqdn); - $template = $resource->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - - return $preview_fqdn; - }); + $generatedDomains = $fqdns->map( + fn ($fqdn) => $preview->generatedPreviewDomain((string) $fqdn) + ); + $fqdns = $generatedDomains->pluck('url'); + $preview->fqdn = $fqdns->implode(','); + $generatedOverrides = $generatedDomains + ->filter(fn (array $generated): bool => filled($generated['port'])) + ->mapWithKeys(fn (array $generated): array => [$generated['url'] => $generated['port']]) + ->all(); + $preview->domain_port_overrides = array_replace( + $preview->domain_port_overrides ?? [], + $generatedOverrides, + ); + $preview->save(); } } $noindexDomains = $pull_request_id !== 0 ? $fqdns : $resource->noindexDomains(); @@ -3841,6 +3912,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true) ? $composeRedirect : 'both'; + $domainPortOverrides = $pull_request_id === 0 + ? ($resource->domain_port_overrides ?? []) + : ($preview?->domain_port_overrides ?? []); + $exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array; + $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -3854,8 +3930,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); break; @@ -3870,8 +3948,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); break; @@ -3887,8 +3967,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); $serviceLabels = $serviceLabels->merge( @@ -3901,8 +3983,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); } diff --git a/composer.json b/composer.json index 871c6f010..416b5e8f6 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "laravel/mcp": "^0.6.7", "laravel/nightwatch": "^1.28.6", "laravel/pail": "^1.2.7", - "laravel/prompts": "^0.3.22|^0.3.22|^0.3.22", + "laravel/prompts": "^0.3.22", "laravel/sanctum": "^4.3.3", "laravel/socialite": "^5.29.0", "laravel/tinker": "^2.11.1", diff --git a/composer.lock b/composer.lock index c2c42ba71..e5718b31b 100644 --- a/composer.lock +++ b/composer.lock @@ -3644,16 +3644,16 @@ }, { "name": "livewire/livewire", - "version": "v3.8.3", + "version": "v3.8.7", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "ab9c2ac9305008aa9ab0f1beecec8ed6c3a591b2" + "reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/ab9c2ac9305008aa9ab0f1beecec8ed6c3a591b2", - "reference": "ab9c2ac9305008aa9ab0f1beecec8ed6c3a591b2", + "url": "https://api.github.com/repos/livewire/livewire/zipball/ff019f8f6f48b7a2315922e45a70ad8fd75d1934", + "reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934", "shasum": "" }, "require": { @@ -3708,7 +3708,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.8.3" + "source": "https://github.com/livewire/livewire/tree/v3.8.7" }, "funding": [ { @@ -3716,7 +3716,7 @@ "type": "github" } ], - "time": "2026-07-31T00:08:18+00:00" + "time": "2026-08-31T15:40:46+00:00" }, { "name": "log1x/laravel-webfonts", diff --git a/config/constants.php b/config/constants.php index b6fac21d1..22d5e1011 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.12', + 'version' => env('COOLIFY_VERSION') ?: '4.3.18', 'helper_version' => '1.0.16', - 'realtime_version' => '1.0.17', + 'realtime_version' => '1.0.18', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), @@ -14,6 +14,7 @@ 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), + 'avatar_cdn_url' => env('AVATAR_CDN_URL'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), 'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'), diff --git a/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php b/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php new file mode 100644 index 000000000..fd1865ed3 --- /dev/null +++ b/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php @@ -0,0 +1,28 @@ +json('domain_dns_statuses')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('domain_dns_statuses'); + }); + } +}; diff --git a/database/migrations/2026_08_30_193506_add_container_present_to_applications_table.php b/database/migrations/2026_08_30_193506_add_container_present_to_applications_table.php new file mode 100644 index 000000000..fc59fb587 --- /dev/null +++ b/database/migrations/2026_08_30_193506_add_container_present_to_applications_table.php @@ -0,0 +1,28 @@ +boolean('container_present')->nullable()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('container_present'); + }); + } +}; diff --git a/database/migrations/2026_08_30_220617_add_restart_limit_reached_to_applications_table.php b/database/migrations/2026_08_30_220617_add_restart_limit_reached_to_applications_table.php new file mode 100644 index 000000000..80603ee97 --- /dev/null +++ b/database/migrations/2026_08_30_220617_add_restart_limit_reached_to_applications_table.php @@ -0,0 +1,37 @@ +boolean('restart_limit_reached')->default(false)->after('max_restart_count'); + }); + + DB::table('applications') + ->where('status', 'like', 'exited%') + ->where('restart_count', '>', 0) + ->where('max_restart_count', '>', 0) + ->whereColumn('restart_count', '>=', 'max_restart_count') + ->where('last_restart_type', 'crash') + ->update(['restart_limit_reached' => true]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached'); + }); + } +}; diff --git a/database/migrations/2026_08_31_073116_add_restart_limit_to_application_previews.php b/database/migrations/2026_08_31_073116_add_restart_limit_to_application_previews.php new file mode 100644 index 000000000..adf119a6e --- /dev/null +++ b/database/migrations/2026_08_31_073116_add_restart_limit_to_application_previews.php @@ -0,0 +1,32 @@ +integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } + + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_31_073117_add_restart_limit_to_service_applications.php b/database/migrations/2026_08_31_073117_add_restart_limit_to_service_applications.php new file mode 100644 index 000000000..0838ad4bd --- /dev/null +++ b/database/migrations/2026_08_31_073117_add_restart_limit_to_service_applications.php @@ -0,0 +1,32 @@ +integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } + + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_31_073118_add_restart_limit_to_service_databases.php b/database/migrations/2026_08_31_073118_add_restart_limit_to_service_databases.php new file mode 100644 index 000000000..046b02960 --- /dev/null +++ b/database/migrations/2026_08_31_073118_add_restart_limit_to_service_databases.php @@ -0,0 +1,32 @@ +integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } + + public function down(): void + { + Schema::table('service_databases', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_31_073119_add_restart_limit_to_standalone_postgresqls.php b/database/migrations/2026_08_31_073119_add_restart_limit_to_standalone_postgresqls.php new file mode 100644 index 000000000..641da5b75 --- /dev/null +++ b/database/migrations/2026_08_31_073119_add_restart_limit_to_standalone_postgresqls.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_postgresqls', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073120_add_restart_limit_to_standalone_redis.php b/database/migrations/2026_08_31_073120_add_restart_limit_to_standalone_redis.php new file mode 100644 index 000000000..24329da9f --- /dev/null +++ b/database/migrations/2026_08_31_073120_add_restart_limit_to_standalone_redis.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_redis', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073121_add_restart_limit_to_standalone_mongodbs.php b/database/migrations/2026_08_31_073121_add_restart_limit_to_standalone_mongodbs.php new file mode 100644 index 000000000..08980a7cb --- /dev/null +++ b/database/migrations/2026_08_31_073121_add_restart_limit_to_standalone_mongodbs.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_mongodbs', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073122_add_restart_limit_to_standalone_mysqls.php b/database/migrations/2026_08_31_073122_add_restart_limit_to_standalone_mysqls.php new file mode 100644 index 000000000..729f85273 --- /dev/null +++ b/database/migrations/2026_08_31_073122_add_restart_limit_to_standalone_mysqls.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_mysqls', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073123_add_restart_limit_to_standalone_mariadbs.php b/database/migrations/2026_08_31_073123_add_restart_limit_to_standalone_mariadbs.php new file mode 100644 index 000000000..6bada2326 --- /dev/null +++ b/database/migrations/2026_08_31_073123_add_restart_limit_to_standalone_mariadbs.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_mariadbs', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073124_add_restart_limit_to_standalone_keydbs.php b/database/migrations/2026_08_31_073124_add_restart_limit_to_standalone_keydbs.php new file mode 100644 index 000000000..41a983924 --- /dev/null +++ b/database/migrations/2026_08_31_073124_add_restart_limit_to_standalone_keydbs.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_keydbs', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073125_add_restart_limit_to_standalone_dragonflies.php b/database/migrations/2026_08_31_073125_add_restart_limit_to_standalone_dragonflies.php new file mode 100644 index 000000000..23d8ccf2c --- /dev/null +++ b/database/migrations/2026_08_31_073125_add_restart_limit_to_standalone_dragonflies.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_dragonflies', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073126_add_restart_limit_to_standalone_clickhouses.php b/database/migrations/2026_08_31_073126_add_restart_limit_to_standalone_clickhouses.php new file mode 100644 index 000000000..5ec8d4522 --- /dev/null +++ b/database/migrations/2026_08_31_073126_add_restart_limit_to_standalone_clickhouses.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_clickhouses', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_092837_add_restart_limit_reached_notifications_to_email_notification_settings_table.php b/database/migrations/2026_08_31_092837_add_restart_limit_reached_notifications_to_email_notification_settings_table.php new file mode 100644 index 000000000..e4db4b7e8 --- /dev/null +++ b/database/migrations/2026_08_31_092837_add_restart_limit_reached_notifications_to_email_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_email_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_email_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092838_add_restart_limit_reached_notifications_to_discord_notification_settings_table.php b/database/migrations/2026_08_31_092838_add_restart_limit_reached_notifications_to_discord_notification_settings_table.php new file mode 100644 index 000000000..b7803e633 --- /dev/null +++ b/database/migrations/2026_08_31_092838_add_restart_limit_reached_notifications_to_discord_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_discord_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('discord_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_discord_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092840_add_restart_limit_reached_notifications_to_telegram_notification_settings_table.php b/database/migrations/2026_08_31_092840_add_restart_limit_reached_notifications_to_telegram_notification_settings_table.php new file mode 100644 index 000000000..51880ceb8 --- /dev/null +++ b/database/migrations/2026_08_31_092840_add_restart_limit_reached_notifications_to_telegram_notification_settings_table.php @@ -0,0 +1,30 @@ +boolean('restart_limit_reached_telegram_notifications')->default(true); + $table->text('telegram_notifications_restart_limit_reached_thread_id')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('telegram_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_telegram_notifications'); + $table->dropColumn('telegram_notifications_restart_limit_reached_thread_id'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092841_add_restart_limit_reached_notifications_to_slack_notification_settings_table.php b/database/migrations/2026_08_31_092841_add_restart_limit_reached_notifications_to_slack_notification_settings_table.php new file mode 100644 index 000000000..0b82b423f --- /dev/null +++ b/database/migrations/2026_08_31_092841_add_restart_limit_reached_notifications_to_slack_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_slack_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('slack_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_slack_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092842_add_restart_limit_reached_notifications_to_pushover_notification_settings_table.php b/database/migrations/2026_08_31_092842_add_restart_limit_reached_notifications_to_pushover_notification_settings_table.php new file mode 100644 index 000000000..596af0b5c --- /dev/null +++ b/database/migrations/2026_08_31_092842_add_restart_limit_reached_notifications_to_pushover_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_pushover_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('pushover_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_pushover_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092843_add_restart_limit_reached_notifications_to_webhook_notification_settings_table.php b/database/migrations/2026_08_31_092843_add_restart_limit_reached_notifications_to_webhook_notification_settings_table.php new file mode 100644 index 000000000..cc03cf0f5 --- /dev/null +++ b/database/migrations/2026_08_31_092843_add_restart_limit_reached_notifications_to_webhook_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_webhook_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('webhook_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_webhook_notifications'); + }); + } +}; diff --git a/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php b/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php new file mode 100644 index 000000000..c51551e79 --- /dev/null +++ b/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php b/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php new file mode 100644 index 000000000..f208c5865 --- /dev/null +++ b/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php b/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php new file mode 100644 index 000000000..80fc8a061 --- /dev/null +++ b/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php b/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php new file mode 100644 index 000000000..710578fd9 --- /dev/null +++ b/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php @@ -0,0 +1,56 @@ +dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } + + Schema::table('service_databases', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } + + public function down(): void + { + foreach (self::STANDALONE_DATABASE_TABLES as $tableName) { + Schema::table($tableName, function (Blueprint $table) { + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + Schema::table('service_databases', function (Blueprint $table) { + $table->integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } +}; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0d7caceb9..ebf12379d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -62,7 +62,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.17' + image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.18' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 33709873f..cc266e556 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -97,7 +97,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.17' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.18' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 0d7b6dcdd..b72574c8d 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -10,6 +10,8 @@ import { extractTimeout, getTerminalSessionTimeout, isAuthorizedTargetHost, + sanitizeSshArgs, + validateSshArgs, } from './terminal-utils.js'; async function postToCoolify(path, headers) { @@ -384,6 +386,16 @@ async function handleCommand(ws, command, userId) { return; } + if (!validateSshArgs(sshArgs, userSession.authorizedIPs)) { + logTerminal('warn', 'Rejecting terminal command because its SSH arguments are not allowed.', { + userId, + targetHost, + }); + ws.send('Invalid SSH command: Unsupported SSH arguments'); + return; + } + const sanitizedSshArgs = sanitizeSshArgs(sshArgs); + const options = { name: 'xterm-color', cols: 80, @@ -401,7 +413,7 @@ async function handleCommand(ws, command, userId) { commandTimeout, terminalSessionTimeout, }); - const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); + const ptyProcess = pty.spawn('ssh', sanitizedSshArgs.concat([hereDocContent]), options); userSession.ptyProcess = ptyProcess; userSession.isActive = true; diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 8769d62d9..4e86dc1f7 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -131,3 +131,133 @@ export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) { .map(host => normalizeHostForAuthorization(host)) .includes(normalizedTargetHost); } + +const REQUIRED_SSH_OPTIONS = new Set([ + 'StrictHostKeyChecking', + 'UserKnownHostsFile', + 'PasswordAuthentication', + 'ConnectTimeout', + 'ServerAliveInterval', + 'RequestTTY', + 'LogLevel', +]); + +function isAllowedSshOption(name, value) { + const fixedOptions = { + StrictHostKeyChecking: 'no', + UserKnownHostsFile: '/dev/null', + PasswordAuthentication: 'no', + LogLevel: 'ERROR', + ControlMaster: 'auto', + ProxyCommand: 'cloudflared access ssh --hostname %h', + }; + + if (Object.hasOwn(fixedOptions, name)) { + return value === fixedOptions[name]; + } + + if (name === 'RequestTTY') { + return value === 'yes' || value === 'no'; + } + + if (name === 'ConnectTimeout' || name === 'ServerAliveInterval' || name === 'ControlPersist') { + return /^\d+$/.test(value) && Number(value) > 0; + } + + if (name === 'ControlPath') { + return /^\/var\/www\/html\/storage\/app\/ssh\/mux\/mux_[a-zA-Z0-9_-]+$/.test(value); + } + + return false; +} + +export function validateSshArgs(sshArgs, authorizedHosts = []) { + if (!Array.isArray(sshArgs) || sshArgs.length === 0) { + return false; + } + + const seenOptions = new Set(); + let hasIdentityFile = false; + let hasPort = false; + let targetHost = null; + + for (let index = 0; index < sshArgs.length; index++) { + const argument = sshArgs[index]; + + if (typeof argument !== 'string' || /[\0\r\n]/.test(argument)) { + return false; + } + + if (argument === '-i') { + const identityFile = sshArgs[++index]; + if (hasIdentityFile || !/^\/var\/www\/html\/storage\/app\/ssh\/keys\/ssh_key@[a-zA-Z0-9_-]+$/.test(identityFile ?? '')) { + return false; + } + hasIdentityFile = true; + continue; + } + + if (argument === '-p') { + const port = sshArgs[++index]; + if (hasPort || !/^\d+$/.test(port ?? '') || Number(port) < 1 || Number(port) > 65535) { + return false; + } + hasPort = true; + continue; + } + + if (argument === '-o') { + const option = sshArgs[++index]; + const separator = option?.indexOf('=') ?? -1; + if (separator < 1) { + return false; + } + + const name = option.slice(0, separator); + const value = option.slice(separator + 1); + if (seenOptions.has(name) || !isAllowedSshOption(name, value)) { + return false; + } + seenOptions.add(name); + continue; + } + + if (/^[a-zA-Z0-9_][a-zA-Z0-9._-]*@[^@]+$/.test(argument) && targetHost === null) { + targetHost = extractTargetHost([argument]); + continue; + } + + return false; + } + + const hasRequiredOptions = [...REQUIRED_SSH_OPTIONS].every(option => seenOptions.has(option)); + const hasCompleteMultiplexingOptions = + !['ControlMaster', 'ControlPath', 'ControlPersist'].some(option => seenOptions.has(option)) + || ['ControlMaster', 'ControlPath', 'ControlPersist'].every(option => seenOptions.has(option)); + + return hasIdentityFile + && hasPort + && targetHost !== null + && hasRequiredOptions + && hasCompleteMultiplexingOptions + && isAuthorizedTargetHost(targetHost, authorizedHosts); +} + +export function sanitizeSshArgs(sshArgs) { + const multiplexingOptions = new Set(['ControlMaster', 'ControlPath', 'ControlPersist']); + const sanitizedArgs = []; + + for (let index = 0; index < sshArgs.length; index++) { + if (sshArgs[index] === '-o') { + const optionName = sshArgs[index + 1]?.split('=', 1)[0]; + if (multiplexingOptions.has(optionName)) { + index++; + continue; + } + } + + sanitizedArgs.push(sshArgs[index]); + } + + return sanitizedArgs; +} diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index bf863099b..5ca0be830 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -7,6 +7,8 @@ import { getTerminalSessionTimeout, isAuthorizedTargetHost, normalizeHostForAuthorization, + sanitizeSshArgs, + validateSshArgs, } from './terminal-utils.js'; test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => { @@ -48,6 +50,79 @@ test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false); }); +test('validateSshArgs accepts the SSH arguments generated by Coolify', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p '22' 'root'@'10.0.0.5' 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true); +}); + +test('validateSshArgs rejects an injected ProxyCommand', () => { + const sshArgs = extractSshArgs( + "timeout 300 ssh -o 'ProxyCommand=/bin/busybox id >/tmp/marker' root@10.0.0.5 'bash -se' << \\ENDSSH\nENDSSH" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false); +}); + +test('validateSshArgs accepts only the fixed Cloudflare ProxyCommand', () => { + const validArgs = extractSshArgs( + "timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@example.com 'bash -se' << \\$abc\necho hi\nabc" + ); + const maliciousArgs = [...validArgs]; + maliciousArgs[1] = 'ProxyCommand=cloudflared access ssh --hostname %h; id'; + + assert.equal(validateSshArgs(validArgs, ['example.com']), true); + assert.equal(validateSshArgs(maliciousArgs, ['example.com']), false); +}); + +test('validateSshArgs rejects unknown SSH options and key paths', () => { + const baseArgs = extractSshArgs( + "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(['-F', '/tmp/config', ...baseArgs], ['10.0.0.5']), false); + assert.equal(validateSshArgs(['-i', '/tmp/attacker-key', ...baseArgs.slice(2)], ['10.0.0.5']), false); +}); + +test('validateSshArgs rejects a destination that begins with an option prefix', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 -evil@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false); +}); + +test('sanitizeSshArgs removes SSH multiplexing options before spawning SSH', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o ControlMaster=auto -o ControlPath=/var/www/html/storage/app/ssh/mux/mux_cm123 -o ControlPersist=3600 -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true); + assert.deepEqual(sanitizeSshArgs(sshArgs), [ + '-i', + '/var/www/html/storage/app/ssh/keys/ssh_key@cm123', + '-o', + 'StrictHostKeyChecking=no', + '-o', + 'UserKnownHostsFile=/dev/null', + '-o', + 'PasswordAuthentication=no', + '-o', + 'ConnectTimeout=10', + '-o', + 'ServerAliveInterval=20', + '-o', + 'RequestTTY=yes', + '-o', + 'LogLevel=ERROR', + '-p', + '22', + 'root@10.0.0.5', + ]); +}); + test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => { assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 0d7caceb9..ebf12379d 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -62,7 +62,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.17' + image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.18' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 43f6f0d0e..32524c4f4 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.17' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.18' pull_policy: always container_name: coolify-realtime restart: always diff --git a/other/nightly/versions.json b/other/nightly/versions.json index a9c5daea6..97ab3b3a6 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.12" + "version": "4.3.18" }, "nightly": { "version": "4.4-rc.1" @@ -10,7 +10,7 @@ "version": "1.0.16" }, "realtime": { - "version": "1.0.17" + "version": "1.0.18" }, "sentinel": { "version": "0.0.22" diff --git a/public/svgs/executor.png b/public/svgs/executor.png new file mode 100644 index 000000000..a7cc57de9 Binary files /dev/null and b/public/svgs/executor.png differ diff --git a/resources/css/app.css b/resources/css/app.css index b1ae74c09..40af521c6 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2521,8 +2521,25 @@ .backup-table-grid { } .service-backup-table-grid { - grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr); - min-width: 45rem; + grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 7.5rem; + width: 100%; +} + +.data-table-row.service-backup-table-grid { + background: var(--coollabs-base); + border-bottom: 1px solid var(--coollabs-fill); +} + +.data-table-row.service-backup-table-grid:last-child { + border-bottom: 0; +} + +.data-table-row.service-backup-table-grid:hover { + background: color-mix(in srgb, var(--coollabs-base) 98%, black); +} + +.dark .data-table-row.service-backup-table-grid:hover { + background: color-mix(in srgb, var(--coollabs-base) 98%, white); } /* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */ diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 078f3cafa..774bc49f0 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -261,7 +261,8 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 {{ $menuItem['label'] }} @if ($menuItem['badge'] ?? false) - + @endif diff --git a/resources/views/components/application/restart-limit-warning.blade.php b/resources/views/components/application/restart-limit-warning.blade.php new file mode 100644 index 000000000..5a35623fc --- /dev/null +++ b/resources/views/components/application/restart-limit-warning.blade.php @@ -0,0 +1,10 @@ +@props(['application']) + +@if (method_exists($application, 'stoppedAfterRestartLimit') && $application->stoppedAfterRestartLimit()) + @php($restartLimit = method_exists($application, 'restartLimitMaximum') ? $application->restartLimitMaximum() : ($application->max_restart_count ?? 0)) + @php($displayRestartCount = max($application->restart_count ?? 0, $restartLimit)) + +@endif diff --git a/resources/views/components/backup-sidebar.blade.php b/resources/views/components/backup-sidebar.blade.php index 90c86ddb5..a2177b690 100644 --- a/resources/views/components/backup-sidebar.blade.php +++ b/resources/views/components/backup-sidebar.blade.php @@ -15,7 +15,7 @@ 'danger' => 'project.application.backup.danger', ], 'service' => [ - 'back' => 'project.service.database.backups', + 'back' => 'project.service.volume-backups.index', 'general' => 'project.service.database.backup.show', 's3' => 'project.service.database.backup.s3', 'retention' => 'project.service.database.backup.retention', @@ -48,9 +48,11 @@ ['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'shield-alert'], ]; $backLabel = $context === 'database' ? 'Back to database' : 'Back to backups'; - $backParameters = $context === 'database' - ? collect($parameters)->except('backup_uuid')->all() - : $parameters; + $backParameters = match ($context) { + 'database' => collect($parameters)->except('backup_uuid')->all(), + 'service' => collect($parameters)->except(['stack_service_uuid', 'backup_uuid'])->all(), + default => $parameters, + }; @endphp