mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-25 17:01:49 -04:00
Merge remote-tracking branch 'origin/main' into pr/11528
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}.'";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]));
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user