Merge remote-tracking branch 'origin/main' into pr/11528

This commit is contained in:
peaklabs-dev
2026-09-04 18:25:18 +02:00
322 changed files with 12132 additions and 1641 deletions
+17 -7
View File
@@ -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 @@ class StopApplication
}
if ($server->isSwarm()) {
$containerPresent = false;
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
continue;
@@ -39,13 +41,15 @@ class StopApplication
$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 @@ class StopApplication
}
}
$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;
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Actions\Application;
use App\Events\ServiceStatusChanged;
use App\Models\ApplicationPreview;
use Lorisleiva\Actions\Concerns\AsAction;
class StopApplicationPreview
{
use AsAction;
public function handle(ApplicationPreview $preview, bool $resetRestartCount = true, bool $removeContainer = true): void
{
$application = $preview->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);
}
}
+5
View File
@@ -28,6 +28,11 @@ class StartDatabase
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);
+17 -13
View File
@@ -4,6 +4,7 @@ namespace App\Actions\Database;
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 @@ class StopDatabase
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 @@ class StopDatabase
}
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);
}
}
+113 -66
View File
@@ -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 @@ class GetContainersStatus
$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 @@ class GetContainersStatus
} 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 @@ class GetContainersStatus
$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 @@ class GetContainersStatus
// 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 @@ class GetContainersStatus
$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 @@ class GetContainersStatus
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 @@ class GetContainersStatus
// 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 @@ class GetContainersStatus
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 @@ class GetContainersStatus
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 @@ class GetContainersStatus
});
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 @@ class GetContainersStatus
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 @@ class GetContainersStatus
}
}
}
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));
}
});
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ class StartSentinel
}
$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',
+1
View File
@@ -24,6 +24,7 @@ class StartService
}
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
$service->applications()->get()->each->resetRestartLimit();
$workdir = $service->workdir();
// $commands[] = "cd {$workdir}";
$commands[] = "echo 'Saved configuration files to {$workdir}.'";
+7 -2
View File
@@ -49,8 +49,13 @@ class StopService
$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();
+10 -4
View File
@@ -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);
}
}
@@ -56,7 +56,7 @@ class UpdateServiceApplicationFromApi
}
}
$serviceApplication->fqdn = $parsed['normalized'];
$serviceApplication->setEditableUrls($parsed['normalized']);
}
if (array_key_exists('noindex_domains', $payload)) {
+3 -2
View File
@@ -47,9 +47,10 @@ class Kernel extends ConsoleKernel
->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();
@@ -23,6 +23,7 @@ use App\Rules\DockerImageFormat;
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 @@ class ApplicationsController extends Controller
]);
}
#[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 @@ class ApplicationsController extends Controller
'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 @@ class ApplicationsController extends Controller
$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 @@ class ApplicationsController extends Controller
$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;
@@ -15,6 +15,7 @@ use App\Rules\ValidHostname;
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 @@ class NotificationsController extends Controller
'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 @@ class NotificationsController extends Controller
'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 @@ class NotificationsController extends Controller
'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 @@ class NotificationsController extends Controller
'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 @@ class NotificationsController extends Controller
'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 @@ class NotificationsController extends Controller
'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 @@ class NotificationsController extends Controller
'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 @@ class NotificationsController extends Controller
$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)) {
@@ -138,7 +138,7 @@ class SentinelController extends Controller
/**
* 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 @@ class SentinelController extends Controller
->map(fn ($c) => [
'name' => data_get($c, 'name'),
'state' => data_get($c, 'state'),
'restart_count' => data_get($c, 'restart_count'),
])
->sortBy('name')
->values()
+248 -61
View File
@@ -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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
// 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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// 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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$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 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$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 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
*/
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 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$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 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
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 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$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 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
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 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$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 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// 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);
+2 -1
View File
@@ -4,6 +4,7 @@ namespace App\Jobs;
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,
+11 -1
View File
@@ -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 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
*/
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 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
private function clearOutdatedInfo(): void
{
$this->server->update(['traefik_outdated_info' => null]);
$this->server->update([
'detected_traefik_version' => null,
'traefik_outdated_info' => null,
]);
}
/**
+14
View File
@@ -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();
+6 -1
View File
@@ -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 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
'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) {
+8 -3
View File
@@ -322,6 +322,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'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 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'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 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'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 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'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 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'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 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} 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);
}
}
}
+163 -10
View File
@@ -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\StandaloneMysql;
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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
{
// 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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->updateProxyStatus();
Application::whereIn('id', $this->foundApplicationIds->unique())
->update(['container_present' => true]);
$this->updateNotFoundApplicationStatus();
$this->updateNotFoundApplicationPreviewStatus();
$this->updateNotFoundDatabaseStatus();
@@ -324,6 +367,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->updateAdditionalServersStatus();
$this->trackPreviewRestartCounts();
// Aggregate multi-container application statuses
$this->aggregateMultiContainerStatuses();
@@ -349,11 +394,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
'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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
'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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
'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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
'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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// 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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
// 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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// 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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
}
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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
}
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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// 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
+4 -1
View File
@@ -66,7 +66,10 @@ class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue
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());
}
+2 -2
View File
@@ -43,7 +43,7 @@ class Show extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -85,7 +85,7 @@ class Show extends Component
}
$this->destination->delete();
return redirect()->route('destination.index');
return redirectRoute($this, 'destination.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
+1 -2
View File
@@ -1507,8 +1507,7 @@ class GlobalSearch extends Component
'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,
]));
+10 -2
View File
@@ -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 @@ class Discord extends Component
}
}
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 @@ class Discord extends Component
$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 @@ class Discord extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -179,6 +184,7 @@ class Discord extends Component
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@@ -188,6 +194,8 @@ class Discord extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');
+8 -2
View File
@@ -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 @@ class Email extends Component
}
}
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 @@ class Email extends Component
$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 @@ class Email extends Component
$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 @@ class Email extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->dispatch('success', 'Email notifications settings updated.');
}
+10 -2
View File
@@ -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 @@ class Pushover extends Component
}
}
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 @@ class Pushover extends Component
$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 @@ class Pushover extends Component
$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 @@ class Pushover extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -174,6 +179,7 @@ class Pushover extends Component
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@@ -183,6 +189,8 @@ class Pushover extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');
+10 -2
View File
@@ -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 @@ class Slack extends Component
}
}
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 @@ class Slack extends Component
$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 @@ class Slack extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -165,6 +170,7 @@ class Slack extends Component
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@@ -174,6 +180,8 @@ class Slack extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');
+29 -16
View File
@@ -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 @@ class Telegram extends Component
}
}
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 @@ class Telegram extends Component
$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 @@ class Telegram extends Component
$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 @@ class Telegram extends Component
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 @@ class Telegram extends Component
$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 @@ class Telegram extends Component
$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 @@ class Telegram extends Component
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@@ -254,6 +265,8 @@ class Telegram extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');
+10 -2
View File
@@ -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 @@ class Webhook extends Component
}
}
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 @@ class Webhook extends Component
$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 @@ class Webhook extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -157,6 +162,7 @@ class Webhook extends Component
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@@ -166,6 +172,8 @@ class Webhook extends Component
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
+1 -1
View File
@@ -47,7 +47,7 @@ class Index extends Component
$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;
@@ -99,7 +99,7 @@ class Advanced extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+252 -23
View File
@@ -8,6 +8,7 @@ use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
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<int, 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}> */
/** @var array<int, 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}> */
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 @@ class Domains extends Component
/**
* @param array<string, array{status?: string, message?: string, expected_ip?: ?string, checked_at?: ?string}> $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 @@ class Domains extends Component
'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 @@ class Domains extends Component
$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 @@ class Domains extends Component
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 @@ class Domains extends Component
->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 @@ class Domains extends Component
$this->forceSaveDomains = false;
$this->pendingAction = null;
$this->forceUseUnknownPort = false;
$serviceForCheck = $this->newDomainService;
$this->resetAddDomainForm();
$this->dispatch('close-modal');
@@ -1007,6 +1144,7 @@ class Domains extends Component
$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 @@ class Domains extends Component
$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 @@ class Domains extends Component
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 @@ class Domains extends Component
$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 @@ class Domains extends Component
$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 @@ class Domains extends Component
}
}
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 @@ class Domains extends Component
}
}
$intendedComposeOverrides = null;
if ($this->isCompose) {
if (blank($serviceName)) {
$this->dispatch('error', 'A service is required for compose domains.');
@@ -1815,6 +1999,15 @@ class Domains extends Component
$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 @@ class Domains extends Component
]);
$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 @@ class Domains extends Component
}
$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<int, string> $previousServiceUrls
* @param array<string, int>|null $incomingOverrides
* @return array<string, int>|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 {
+8 -21
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Application;
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 @@ class General extends Component
}
}
$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 @@ class General extends Component
$this->syncData();
}
public function syncData(bool $toModel = false): void
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -530,7 +520,7 @@ class General extends Component
$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 @@ class General extends Component
$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 @@ class General extends Component
public function resetDefaultLabels($manualReset = false)
{
$this->authorize('update', $this->application);
try {
if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) {
return;
@@ -0,0 +1,617 @@
<?php
namespace App\Livewire\Project\Application;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Models\ApplicationPreview;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class PreviewDomains extends Component
{
use AuthorizesRequests;
public ApplicationPreview $preview;
public array $domainRows = [];
public array $newDomainParts = ['scheme' => '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 [];
}
}
}
+22 -115
View File
@@ -7,7 +7,6 @@ use App\Events\ServiceStatusChanged;
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 @@ class Previews extends Component
{
$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 @@ class Previews extends Component
}
}
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}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> 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.<br><br>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.<br><br>Do not forget to redeploy the preview to apply the changes.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -211,7 +118,7 @@ class Previews extends Component
}
$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 @@ class Previews extends Component
$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.');
}
@@ -1,165 +0,0 @@
<?php
namespace App\Livewire\Project\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Url\Url;
class PreviewsCompose extends Component
{
use AuthorizesRequests;
public $service;
public $serviceName;
public ApplicationPreview $preview;
public ?string $domain = null;
public function mount()
{
$this->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<string, mixed> $previewDomains
* @return list<string>
*/
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();
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ class Source extends Component
$this->gitCommitSha = trim($this->gitCommitSha);
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+1 -1
View File
@@ -31,7 +31,7 @@ class Swarm extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+3 -3
View File
@@ -128,7 +128,7 @@ class BackupEdit extends Component
$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 @@ class BackupEdit extends Component
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'],
@@ -6,7 +6,6 @@ use App\Models\ScheduledDatabaseBackup;
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',
];
}
@@ -121,7 +121,7 @@ class General extends Component
);
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -199,6 +199,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -115,7 +115,7 @@ class General extends Component
);
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -191,6 +191,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -35,6 +35,12 @@ class Heading extends Component
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()) {
+1 -1
View File
@@ -34,7 +34,7 @@ class Health extends Component
$this->syncData();
}
public function syncData(bool $toModel = false): void
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+5 -1
View File
@@ -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 @@ class InitScript extends Component
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 @@ class InitScript extends Component
$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);
}
@@ -118,7 +118,7 @@ class General extends Component
);
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -196,6 +196,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -136,7 +136,7 @@ class General extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -244,6 +244,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -128,7 +128,7 @@ class General extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -237,6 +237,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -136,7 +136,7 @@ class General extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -244,6 +244,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -149,7 +149,7 @@ class General extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -240,6 +240,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -247,16 +248,16 @@ class General extends Component
}
}
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 @@ class General extends Component
}
}
$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);
@@ -127,7 +127,7 @@ class General extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -235,6 +235,7 @@ class General extends Component
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
+1 -1
View File
@@ -77,7 +77,7 @@ class Edit extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+1 -1
View File
@@ -48,7 +48,7 @@ class EnvironmentEdit extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+1 -4
View File
@@ -53,10 +53,7 @@ class Index extends Component
'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,
+1 -25
View File
@@ -111,38 +111,14 @@ class Select extends Component
$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
+5
View File
@@ -187,6 +187,11 @@ class Index extends Component
'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' => [
@@ -0,0 +1,124 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
class BackupExecutions extends Component
{
use AuthorizesRequests;
public Service $service;
public bool $executionModalOpen = false;
public ?array $selectedExecution = null;
public function getListeners(): array
{
$teamId = currentTeam()->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();
}
}
@@ -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 @@ class DatabaseBackups extends Component
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 @@ class DatabaseBackups extends Component
'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);
+94 -23
View File
@@ -9,6 +9,7 @@ use App\Livewire\Project\Shared\ConfigurationChecker;
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 @@ class Domains extends Component
{
$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 @@ class Domains extends Component
'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 @@ class Domains extends Component
->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 @@ class Domains extends Component
$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 @@ class Domains extends Component
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 @@ class Domains extends Component
$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 @@ class Domains extends Component
}
}
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 @@ class Domains extends Component
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 @@ class Domains extends Component
$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;
+1 -1
View File
@@ -78,7 +78,7 @@ class EditCompose extends Component
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);
}
+18 -22
View File
@@ -46,18 +46,18 @@ class EditDomain extends Component
$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 @@ class EditDomain extends Component
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 @@ class EditDomain extends Component
$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 @@ class EditDomain extends Component
$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
+7 -7
View File
@@ -120,7 +120,7 @@ class FileStorage extends Component
: 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 @@ class FileStorage extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('refreshStorages');
$this->dispatch('storageCountsChanged')->to(Storage::class);
}
}
@@ -179,7 +179,7 @@ class FileStorage extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('refreshStorages');
$this->dispatch('storageCountsChanged')->to(Storage::class);
}
}
@@ -207,7 +207,7 @@ class FileStorage extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('refreshStorages');
$this->dispatch('storageCountsChanged')->to(Storage::class);
}
}
@@ -242,7 +242,7 @@ class FileStorage extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('refreshStorages');
$this->dispatch('storageCountsChanged')->to(Storage::class);
}
return true;
@@ -308,10 +308,10 @@ class FileStorage extends Component
{
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.'],
+26
View File
@@ -5,8 +5,11 @@ namespace App\Livewire\Project\Service;
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 @@ class Heading extends Component
}
}
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 {
@@ -0,0 +1,80 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
class ImportBackup extends Component
{
use AuthorizesRequests;
public Service $service;
public Collection $databases;
public ?ServiceDatabase $selectedDatabase = null;
public string $selectedDatabaseUuid = '';
public array $parameters;
public function mount(): mixed
{
$this->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']);
}
}
+36 -28
View File
@@ -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 @@ class Index extends Component
$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 @@ class Index extends Component
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 @@ class Index extends Component
} 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 @@ class Index extends Component
$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 @@ class Index extends Component
$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 @@ class Index extends Component
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 @@ class Index extends Component
$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;
+13 -1
View File
@@ -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 @@ class Status extends Component
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'));
}
}
+8 -5
View File
@@ -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 @@ class Storage extends Component
return [
"echo-private:team.{$teamId},FileStorageChanged" => 'refreshStoragesFromEvent',
'refreshStorages',
'storageCountsChanged' => 'refreshStorages',
'addNewVolume',
];
}
@@ -87,11 +88,17 @@ class Storage extends Component
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 @@ class Storage extends Component
$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 @@ class Storage extends Component
$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 @@ class Storage extends Component
$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 @@ class Storage extends Component
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Directory mount added successfully');
$this->dispatch('closeStorageModal', 'directory');
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -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 @@ class Index extends Component
->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();
}
}
@@ -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 @@ class Show extends Component
'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
+1 -2
View File
@@ -106,14 +106,13 @@ class Danger extends Component
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,
@@ -818,19 +818,21 @@ class All extends Component
{
$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 @@ class All extends Component
$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 @@ class All extends Component
$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 @@ class All extends Component
// 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 @@ class All extends Component
if ($isUsed) {
$this->dispatch('error', "Cannot delete environment variable '{$envVar->key}' <br><br>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();
}
@@ -145,6 +145,8 @@ class Show extends Component
*/
public function loadValues(): void
{
$this->authorize('update', $this->env);
if ($this->valuesLoaded) {
return;
}
@@ -162,7 +164,7 @@ class Show extends Component
$this->valuesLoaded = true;
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
+7
View File
@@ -17,12 +17,15 @@ use App\Models\StandaloneMysql;
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 @@ class GetLogs extends Component
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();
+1 -1
View File
@@ -86,7 +86,7 @@ class HealthChecks extends Component
}
}
public function syncData(bool $toModel = false): void
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -102,7 +102,7 @@ class Add extends Component
}
}
public function saveScheduledTask()
private function saveScheduledTask(): void
{
try {
$task = new ScheduledTask;
@@ -128,7 +128,7 @@ class Add extends Component
$this->dispatch('refreshTasks');
$this->dispatch('success', 'Scheduled task added.');
} catch (\Throwable $e) {
return handleError($e, $this);
handleError($e, $this);
}
}
@@ -87,7 +87,7 @@ class Show extends Component
}
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@@ -169,9 +169,9 @@ class Show extends Component
$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);
+3 -2
View File
@@ -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 @@ class All extends Component
$storage->delete();
$this->refreshList();
$this->dispatch('refreshStorages');
$this->dispatch('storageCountsChanged')->to(StorageComponent::class);
$this->dispatch('configurationChanged');
return true;
@@ -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 @@ class Show extends Component
}
$this->storage->delete();
$this->dispatch('refreshStorages');
$this->dispatch('storageCountsChanged')->to(StorageComponent::class);
$this->dispatch('configurationChanged');
return true;
@@ -28,6 +28,8 @@ class CloudInitScripts extends Component
public function loadScripts()
{
$this->authorize('viewAny', CloudInitScript::class);
CloudInitScript::ownedByCurrentTeam()
->whereNull('uuid')
->get()
@@ -94,6 +94,7 @@ class CloudProviderTokenForm extends Component
public function addToken()
{
$this->authorize('create', CloudProviderToken::class);
$this->validate();
try {
+3 -1
View File
@@ -76,7 +76,9 @@ class Show extends Component
// 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;
}
}
+3 -2
View File
@@ -42,10 +42,9 @@ class Advanced extends Component
}
}
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 @@ class Advanced extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
$this->dispatch('success', 'Server updated.');
} catch (\Throwable $e) {
@@ -81,6 +81,7 @@ class Advanced extends Component
$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) {
+3 -2
View File
@@ -97,10 +97,9 @@ class DockerCleanup extends Component
}
}
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 @@ class DockerCleanup extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
$this->dispatch('success', 'Server updated.');
} catch (\Throwable $e) {
@@ -147,6 +147,7 @@ class DockerCleanup extends Component
$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) {
+4 -4
View File
@@ -52,7 +52,7 @@ class LogDrains extends Component
}
}
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 @@ class LogDrains extends Component
}
}
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 @@ class LogDrains extends Component
}
}
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 @@ class LogDrains extends Component
}
}
public function syncData(bool $toModel = false, ?string $type = null)
private function syncData(bool $toModel = false, ?string $type = null): void
{
if ($toModel) {
$this->customValidation();
+2
View File
@@ -106,6 +106,8 @@ class Proxy extends Component
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');
@@ -62,10 +62,9 @@ class TerminalAccess extends Component
}
}
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 {
+3 -2
View File
@@ -54,10 +54,9 @@ class Sentinel extends Component
$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 @@ class Sentinel extends Component
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 @@ class Sentinel extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
$this->restartSentinel();
} catch (\Throwable $e) {
+4 -3
View File
@@ -230,12 +230,10 @@ class Show extends Component
->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 @@ class Show extends Component
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 @@ class Show extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -694,6 +694,7 @@ class Show extends Component
public function submit()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
$this->dispatch('success', 'Server settings updated.');
} catch (\Throwable $e) {
+2 -2
View File
@@ -29,10 +29,9 @@ class Swarm extends Component
}
}
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 @@ class Swarm extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
} catch (\Throwable $e) {
@@ -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 @@ class ValidateAndInstall extends Component
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: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
@@ -174,6 +178,8 @@ class ValidateAndInstall extends Component
public function validatePrerequisites()
{
$this->authorize('update', $this->server);
$validationResult = $this->server->validatePrerequisites();
$this->prerequisites_installed = $validationResult['success'];
if (! $validationResult['success']) {
@@ -212,6 +218,8 @@ class ValidateAndInstall extends Component
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 @@ class ValidateAndInstall extends Component
public function validateDockerVersion()
{
$this->authorize('update', $this->server);
if ($this->server->isSwarm()) {
$swarmInstalled = $this->server->validateDockerSwarm();
if ($swarmInstalled) {
+1 -1
View File
@@ -74,7 +74,7 @@ class SettingsEmail extends Component
$this->testEmailAddress = auth()->user()->email;
}
public function syncData(bool $toModel = false)
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
+6 -17
View File
@@ -122,13 +122,6 @@ class Change extends Component
}
}
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 @@ class Change extends Component
$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 @@ class Change extends Component
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 @@ class Change extends Component
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 @@ class Change extends Component
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 @@ class Change extends Component
{
$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 @@ class Change extends Component
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 @@ class Change extends Component
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 @@ class Change extends Component
// @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);
}
+1 -1
View File
@@ -338,7 +338,7 @@ class Change extends Component
// @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);
}
+1 -1
View File
@@ -43,7 +43,7 @@ class Show extends Component
$this->storage->delete();
return redirect()->route('storage.index');
return redirectRoute($this, 'storage.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
+59 -31
View File
@@ -7,6 +7,8 @@ use App\Services\ConfigurationGenerator;
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 @@ class Application extends BaseModel
'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 @@ class Application extends BaseModel
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 @@ class Application extends BaseModel
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 @@ class Application extends BaseModel
);
}
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 @@ class Application extends BaseModel
$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 @@ class Application extends BaseModel
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<int>
*/
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
+84 -38
View File
@@ -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 @@ class ApplicationPreview extends BaseModel
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 @@ class ApplicationPreview extends BaseModel
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 @@ class ApplicationPreview extends BaseModel
->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 @@ class ApplicationPreview extends BaseModel
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 @@ class ApplicationPreview extends BaseModel
->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.
*
@@ -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',
+2
View File
@@ -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',
@@ -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',

Some files were not shown because too many files have changed in this diff Show More