mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 00:23:27 +00:00
Merge remote-tracking branch 'origin/next' into audit-policies
This commit is contained in:
@@ -13,7 +13,7 @@ class StopApplication
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true)
|
||||
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true)
|
||||
{
|
||||
$servers = collect([$application->destination->server]);
|
||||
if ($application?->additional_servers?->count() > 0) {
|
||||
@@ -57,12 +57,17 @@ class StopApplication
|
||||
}
|
||||
}
|
||||
|
||||
// Reset restart tracking when application is manually stopped
|
||||
$application->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
if ($resetRestartCount) {
|
||||
$application->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
} else {
|
||||
$application->update([
|
||||
'status' => 'exited',
|
||||
]);
|
||||
}
|
||||
|
||||
ServiceStatusChanged::dispatch($application->environment->project->team->id);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Docker;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Actions\Shared\ComplexStatusCheck;
|
||||
@@ -9,6 +10,7 @@ use App\Events\ServiceChecked;
|
||||
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\Traits\CalculatesExcludedStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -464,7 +466,9 @@ class GetContainersStatus
|
||||
}
|
||||
|
||||
// Wrap all database updates in a transaction to ensure consistency
|
||||
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
|
||||
$restartLimitReached = false;
|
||||
|
||||
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) {
|
||||
$previousRestartCount = $application->restart_count ?? 0;
|
||||
|
||||
if ($maxRestartCount > $previousRestartCount) {
|
||||
@@ -475,16 +479,10 @@ class GetContainersStatus
|
||||
'last_restart_type' => 'crash',
|
||||
]);
|
||||
|
||||
// Send notification
|
||||
$containerName = $application->name;
|
||||
$projectUuid = data_get($application, 'environment.project.uuid');
|
||||
$environmentName = data_get($application, 'environment.name');
|
||||
$applicationUuid = data_get($application, 'uuid');
|
||||
|
||||
if ($projectUuid && $applicationUuid && $environmentName) {
|
||||
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
|
||||
} else {
|
||||
$url = null;
|
||||
// Check if restart limit has been reached
|
||||
$maxAllowedRestarts = $application->max_restart_count ?? 0;
|
||||
if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) {
|
||||
$restartLimitReached = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +497,12 @@ class GetContainersStatus
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if ($restartLimitReached) {
|
||||
$application->refresh();
|
||||
StopApplication::dispatch($application, false, true, false);
|
||||
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class CleanupDocker
|
||||
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
|
||||
$imagePruneCmd,
|
||||
'docker builder prune -af',
|
||||
'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true',
|
||||
"docker run --rm -v \$HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
|
||||
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StartLogDrain
|
||||
@@ -201,10 +202,29 @@ Files:
|
||||
"echo 'Starting Fluent Bit'",
|
||||
"cd $config_path && docker compose up -d",
|
||||
];
|
||||
$command = array_merge($command, $this->logDrainNetworkConnectCommands($server));
|
||||
|
||||
return instant_remote_process($command, $server);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function logDrainNetworkConnectCommands(Server $server): array
|
||||
{
|
||||
if (! $server->isLogDrainEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $server->services()
|
||||
->with('destination')
|
||||
->where('connect_to_docker_network', true)
|
||||
->get()
|
||||
->map(fn (Service $service) => data_get($service, 'destination.network'))
|
||||
->filter()
|
||||
->unique()
|
||||
->map(fn (string $network) => 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +50,32 @@ class StartService
|
||||
$commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true";
|
||||
}
|
||||
}
|
||||
$commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service));
|
||||
|
||||
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
|
||||
}
|
||||
|
||||
private function logDrainNetworkConnectCommands(Service $service): array
|
||||
{
|
||||
if (! data_get($service, 'connect_to_docker_network')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! $service->destination?->server?->isLogDrainEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$network = data_get($service, 'destination.network');
|
||||
|
||||
if (blank($network)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true',
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool
|
||||
{
|
||||
return $stopBeforeStart && ! $pullLatestImages;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/**
|
||||
* Stores an array as an encrypted JSON string at rest. Tolerates legacy
|
||||
* plaintext JSON rows written before the column was encrypted, so existing
|
||||
* snapshots keep decoding instead of throwing.
|
||||
*
|
||||
* @implements CastsAttributes<array<mixed>|null, array<mixed>|null>
|
||||
*/
|
||||
class EncryptedArrayCast implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): ?array
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$value = Crypt::decryptString($value);
|
||||
} catch (DecryptException) {
|
||||
// Legacy plaintext JSON written before this column was encrypted.
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $value, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Crypt::encryptString(json_encode($value, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
@@ -253,7 +253,7 @@ class Init extends Command
|
||||
'save_s3' => false,
|
||||
'frequency' => '0 0 * * *',
|
||||
'database_id' => $database->id,
|
||||
'database_type' => \App\Models\StandalonePostgresql::class,
|
||||
'database_type' => StandalonePostgresql::class,
|
||||
'team_id' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ class SyncBunny extends Command
|
||||
$compose_file_prod = 'docker-compose.prod.yml';
|
||||
$install_script = 'install.sh';
|
||||
$upgrade_script = 'upgrade.sh';
|
||||
$upgrade_postgres_script = 'upgrade-postgres.sh';
|
||||
$production_env = '.env.production';
|
||||
$service_template = config('constants.services.file_name');
|
||||
$versions = 'versions.json';
|
||||
@@ -52,6 +53,7 @@ class SyncBunny extends Command
|
||||
$compose_file_prod_location = "$parent_dir/$compose_file_prod";
|
||||
$install_script_location = "$parent_dir/scripts/install.sh";
|
||||
$upgrade_script_location = "$parent_dir/scripts/upgrade.sh";
|
||||
$upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh";
|
||||
$production_env_location = "$parent_dir/.env.production";
|
||||
$versions_location = "$parent_dir/$versions";
|
||||
|
||||
@@ -87,6 +89,7 @@ class SyncBunny extends Command
|
||||
$compose_file_prod_location = "$parent_dir/other/nightly/$compose_file_prod";
|
||||
$production_env_location = "$parent_dir/other/nightly/$production_env";
|
||||
$upgrade_script_location = "$parent_dir/other/nightly/$upgrade_script";
|
||||
$upgrade_postgres_script_location = "$parent_dir/other/nightly/$upgrade_postgres_script";
|
||||
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
||||
$versions_location = "$parent_dir/other/nightly/$versions";
|
||||
}
|
||||
@@ -101,6 +104,7 @@ class SyncBunny extends Command
|
||||
$compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod",
|
||||
$production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env",
|
||||
$upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script",
|
||||
$upgrade_postgres_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_postgres_script",
|
||||
$install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script",
|
||||
];
|
||||
|
||||
@@ -215,6 +219,7 @@ class SyncBunny extends Command
|
||||
$pool->storage(fileName: "$compose_file_prod_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$compose_file_prod"),
|
||||
$pool->storage(fileName: "$production_env_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$production_env"),
|
||||
$pool->storage(fileName: "$upgrade_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_script"),
|
||||
$pool->storage(fileName: "$upgrade_postgres_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_postgres_script"),
|
||||
$pool->storage(fileName: "$install_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$install_script"),
|
||||
]);
|
||||
Http::pool(fn (Pool $pool) => [
|
||||
@@ -222,6 +227,7 @@ class SyncBunny extends Command
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$compose_file_prod"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$production_env"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_postgres_script"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"),
|
||||
]);
|
||||
$this->info('All files uploaded & purged to BunnyCDN.');
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\CheckTraefikVersionJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
||||
use App\Jobs\CleanupStaleMultiplexedConnections;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Jobs\PullTemplatesFromCDN;
|
||||
use App\Jobs\RegenerateSslCertJob;
|
||||
@@ -40,6 +41,10 @@ class Kernel extends ConsoleKernel
|
||||
$this->instanceTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
$this->scheduleInstance->call(fn () => app(CleanupStaleMultiplexedConnections::class)->handle())
|
||||
->name('cleanup:ssh-mux')
|
||||
->hourly()
|
||||
->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('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
|
||||
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Helpers;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
@@ -23,23 +25,77 @@ class SshMultiplexingHelper
|
||||
|
||||
public static function ensureMultiplexedConnection(Server $server): bool
|
||||
{
|
||||
return self::isMultiplexingEnabled();
|
||||
if (! self::isMultiplexingEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::connectionIsReusable($server)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return Cache::lock(
|
||||
self::connectionLockKey($server),
|
||||
config('constants.ssh.mux_lock_ttl')
|
||||
)->block(config('constants.ssh.mux_lock_timeout'), function () use ($server) {
|
||||
if (self::connectionIsReusable($server)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (self::masterConnectionExists($server)) {
|
||||
return self::refreshMultiplexedConnection($server);
|
||||
}
|
||||
|
||||
return self::establishNewMultiplexedConnection($server);
|
||||
});
|
||||
} catch (LockTimeoutException) {
|
||||
Log::warning('SSH multiplexing lock timeout, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
]);
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SSH multiplexing lock unavailable, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function establishNewMultiplexedConnection(Server $server): bool
|
||||
{
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
$connectionTimeout = self::getConnectionTimeout($server);
|
||||
$serverInterval = config('constants.ssh.server_interval');
|
||||
$muxPersistTime = config('constants.ssh.mux_persist_time');
|
||||
|
||||
$establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
|
||||
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
|
||||
$establishCommand .= self::escapedUserAtHost($server);
|
||||
|
||||
$establishProcess = Process::run($establishCommand);
|
||||
if ($establishProcess->exitCode() !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::storeConnectionMetadata($server);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function removeMuxFile(Server $server): void
|
||||
{
|
||||
$closeCommand = self::muxControlCommand($server, 'exit');
|
||||
Process::run($closeCommand);
|
||||
}
|
||||
|
||||
private static function muxControlCommand(Server $server, string $operation): string
|
||||
{
|
||||
$command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
|
||||
return $command.self::escapedUserAtHost($server);
|
||||
Process::run(self::muxControlCommand($server, 'exit'));
|
||||
self::clearConnectionMetadata($server);
|
||||
}
|
||||
|
||||
public static function generateScpCommand(Server $server, string $source, string $dest): string
|
||||
@@ -53,7 +109,16 @@ class SshMultiplexingHelper
|
||||
}
|
||||
|
||||
if (self::isMultiplexingEnabled()) {
|
||||
$scpCommand .= self::multiplexingOptions($server);
|
||||
try {
|
||||
if (self::ensureMultiplexedConnection($server)) {
|
||||
$scpCommand .= self::multiplexingOptions($server);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
@@ -84,7 +149,16 @@ class SshMultiplexingHelper
|
||||
$sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
|
||||
|
||||
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
|
||||
$sshCommand .= self::multiplexingOptions($server);
|
||||
try {
|
||||
if (self::ensureMultiplexedConnection($server)) {
|
||||
$sshCommand .= self::multiplexingOptions($server);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SSH multiplexing failed, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
@@ -101,6 +175,99 @@ class SshMultiplexingHelper
|
||||
.$delimiter;
|
||||
}
|
||||
|
||||
public static function getConnectionTimeout(Server $server): int
|
||||
{
|
||||
$timeout = data_get($server, 'settings.connection_timeout');
|
||||
|
||||
return is_numeric($timeout) && (int) $timeout > 0
|
||||
? (int) $timeout
|
||||
: (int) config('constants.ssh.connection_timeout');
|
||||
}
|
||||
|
||||
public static function isConnectionHealthy(Server $server): bool
|
||||
{
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
|
||||
|
||||
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
|
||||
|
||||
$process = Process::run($healthCommand);
|
||||
|
||||
return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
|
||||
}
|
||||
|
||||
public static function isConnectionExpired(Server $server): bool
|
||||
{
|
||||
$connectionAge = self::getConnectionAge($server);
|
||||
$maxAge = config('constants.ssh.mux_max_age');
|
||||
|
||||
return $connectionAge !== null && $connectionAge > $maxAge;
|
||||
}
|
||||
|
||||
public static function getConnectionAge(Server $server): ?int
|
||||
{
|
||||
$connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}");
|
||||
|
||||
if ($connectionTime === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return time() - $connectionTime;
|
||||
}
|
||||
|
||||
public static function refreshMultiplexedConnection(Server $server): bool
|
||||
{
|
||||
self::removeMuxFile($server);
|
||||
|
||||
return self::establishNewMultiplexedConnection($server);
|
||||
}
|
||||
|
||||
private static function connectionLockKey(Server $server): string
|
||||
{
|
||||
return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
|
||||
}
|
||||
|
||||
private static function masterConnectionExists(Server $server): bool
|
||||
{
|
||||
return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0;
|
||||
}
|
||||
|
||||
private static function connectionIsReusable(Server $server): bool
|
||||
{
|
||||
if (! self::masterConnectionExists($server)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::getConnectionAge($server) === null) {
|
||||
self::storeConnectionMetadata($server);
|
||||
}
|
||||
|
||||
if (self::isConnectionExpired($server)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function muxControlCommand(Server $server, string $operation): string
|
||||
{
|
||||
$command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
|
||||
return $command.self::escapedUserAtHost($server);
|
||||
}
|
||||
|
||||
private static function multiplexingOptions(Server $server): string
|
||||
{
|
||||
return '-o ControlMaster=auto '
|
||||
@@ -158,15 +325,6 @@ class SshMultiplexingHelper
|
||||
}
|
||||
}
|
||||
|
||||
public static function getConnectionTimeout(Server $server): int
|
||||
{
|
||||
$timeout = data_get($server, 'settings.connection_timeout');
|
||||
|
||||
return is_numeric($timeout) && (int) $timeout > 0
|
||||
? (int) $timeout
|
||||
: (int) config('constants.ssh.connection_timeout');
|
||||
}
|
||||
|
||||
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
|
||||
{
|
||||
$options = "-i {$sshKeyLocation} "
|
||||
@@ -183,4 +341,14 @@ class SshMultiplexingHelper
|
||||
|
||||
return $options.'-p '.escapeshellarg((string) $server->port).' ';
|
||||
}
|
||||
|
||||
private static function storeConnectionMetadata(Server $server): void
|
||||
{
|
||||
Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300);
|
||||
}
|
||||
|
||||
private static function clearConnectionMetadata(Server $server): void
|
||||
{
|
||||
Cache::forget("ssh_mux_connection_time_{$server->uuid}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ class ApplicationsController extends Controller
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
|
||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'],
|
||||
properties: [
|
||||
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
||||
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
||||
@@ -316,7 +316,7 @@ class ApplicationsController extends Controller
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
|
||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'],
|
||||
properties: [
|
||||
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
||||
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
||||
@@ -482,7 +482,7 @@ class ApplicationsController extends Controller
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
|
||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'],
|
||||
properties: [
|
||||
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
||||
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
||||
@@ -785,7 +785,7 @@ class ApplicationsController extends Controller
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name', 'ports_exposes'],
|
||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'],
|
||||
properties: [
|
||||
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
||||
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
||||
@@ -1028,7 +1028,7 @@ class ApplicationsController extends Controller
|
||||
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
@@ -1234,7 +1234,7 @@ class ApplicationsController extends Controller
|
||||
'git_repository' => 'string|required',
|
||||
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
|
||||
'github_app_uuid' => 'string|required',
|
||||
'watch_paths' => 'string|nullable',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
@@ -1474,7 +1474,7 @@ class ApplicationsController extends Controller
|
||||
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
|
||||
'private_key_uuid' => 'string|required',
|
||||
'watch_paths' => 'string|nullable',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
@@ -1797,7 +1797,7 @@ class ApplicationsController extends Controller
|
||||
$validationRules = [
|
||||
'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
|
||||
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
|
||||
];
|
||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||
$validator = customApiValidator($request->all(), $validationRules);
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server as ModelsServer;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -489,10 +490,12 @@ class ServersController extends Controller
|
||||
'ip' => ['string', 'required', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|required',
|
||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -668,7 +671,7 @@ class ServersController extends Controller
|
||||
'ip' => ['string', 'nullable', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|nullable',
|
||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
@@ -678,6 +681,8 @@ class ServersController extends Controller
|
||||
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
|
||||
'server_disk_usage_check_frequency' => 'string',
|
||||
'connection_timeout' => 'integer|min:1|max:300',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -703,17 +708,17 @@ class ServersController extends Controller
|
||||
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
|
||||
return str($proxyType->value)->lower();
|
||||
});
|
||||
if ($validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
} else {
|
||||
if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||
return response()->json(['message' => 'Invalid proxy type.'], 422);
|
||||
}
|
||||
}
|
||||
$server->update($request->only(['name', 'description', 'ip', 'port', 'user']));
|
||||
if ($request->is_build_server) {
|
||||
$server->settings()->update([
|
||||
'is_build_server' => $request->is_build_server,
|
||||
]);
|
||||
$updateFields = $request->only(['name', 'description', 'ip', 'port', 'user']);
|
||||
if ($request->filled('private_key_uuid')) {
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
$updateFields['private_key_id'] = $privateKey->id;
|
||||
}
|
||||
|
||||
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
|
||||
@@ -723,11 +728,22 @@ class ServersController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$server->update($updateFields);
|
||||
if ($request->has('is_build_server')) {
|
||||
$server->settings()->update([
|
||||
'is_build_server' => $request->boolean('is_build_server'),
|
||||
]);
|
||||
}
|
||||
|
||||
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
|
||||
if (! empty($advancedSettings)) {
|
||||
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
|
||||
}
|
||||
|
||||
if ($request->proxy_type) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
}
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -98,23 +99,50 @@ class Controller extends BaseController
|
||||
{
|
||||
$token = request()->get('token');
|
||||
if ($token) {
|
||||
$decrypted = Crypt::decryptString($token);
|
||||
$email = str($decrypted)->before('@@@');
|
||||
$password = str($decrypted)->after('@@@');
|
||||
try {
|
||||
$decrypted = Crypt::decryptString($token);
|
||||
} catch (DecryptException) {
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
if (! str_contains($decrypted, '@@@')) {
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
$payload = explode('@@@', $decrypted, 3);
|
||||
if (count($payload) === 3) {
|
||||
[$email, $invitationUuid, $password] = $payload;
|
||||
} else {
|
||||
[$email, $password] = $payload;
|
||||
$invitationUuid = null;
|
||||
}
|
||||
|
||||
$email = Str::lower($email);
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$invitation = TeamInvitation::query()
|
||||
->where('email', $email)
|
||||
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
|
||||
->where('link', request()->fullUrl())
|
||||
->first();
|
||||
if (! $invitation || ! $invitation->isValid()) {
|
||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||
}
|
||||
|
||||
if (Hash::check($password, $user->password)) {
|
||||
$invitation = TeamInvitation::whereEmail($email);
|
||||
if ($invitation->exists()) {
|
||||
$team = $invitation->first()->team;
|
||||
$user->teams()->attach($team->id, ['role' => $invitation->first()->role]);
|
||||
$invitation->delete();
|
||||
} else {
|
||||
$team = $user->teams()->first();
|
||||
$team = $invitation->team;
|
||||
if (! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||
$user->teams()->attach($team->id, ['role' => $invitation->role]);
|
||||
}
|
||||
$invitation->delete();
|
||||
|
||||
Auth::login($user);
|
||||
$user->forceFill([
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
])->save();
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
|
||||
@@ -11,6 +11,8 @@ use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Exception;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -539,19 +541,22 @@ class Github extends Controller
|
||||
|
||||
public function install(Request $request)
|
||||
{
|
||||
$source = (string) $request->query('source', '');
|
||||
abort_if(blank($source), 404);
|
||||
|
||||
$github_app = GithubApp::ownedByCurrentTeam()->where('uuid', $source)->firstOrFail();
|
||||
|
||||
$setup_action = (string) $request->query('setup_action', '');
|
||||
if ($setup_action !== 'install') {
|
||||
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||
}
|
||||
abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
|
||||
|
||||
$installation_id = (string) $request->query('installation_id', '');
|
||||
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
|
||||
|
||||
if ($setup_action === 'update') {
|
||||
return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
|
||||
}
|
||||
|
||||
$github_app = $this->consumeGithubAppSetupState(
|
||||
request: $request,
|
||||
state: (string) $request->query('state', ''),
|
||||
action: 'install',
|
||||
);
|
||||
|
||||
abort_unless(
|
||||
$this->githubInstallationBelongsToApp($github_app, $installation_id),
|
||||
403,
|
||||
@@ -564,6 +569,19 @@ class Github extends Controller
|
||||
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||
}
|
||||
|
||||
private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
|
||||
{
|
||||
$github_app = GithubApp::ownedByCurrentTeam()
|
||||
->where('installation_id', $installation_id)
|
||||
->first();
|
||||
|
||||
if ($github_app) {
|
||||
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||
}
|
||||
|
||||
return redirect()->route('source.all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the given installation id actually belongs to this GitHub App.
|
||||
*
|
||||
@@ -596,11 +614,14 @@ class Github extends Controller
|
||||
|
||||
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
|
||||
{
|
||||
abort_if(blank($state), 404);
|
||||
if (blank($state)) {
|
||||
$this->rejectInvalidGithubAppSetupState($request);
|
||||
}
|
||||
|
||||
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
|
||||
abort_unless(is_array($payload), 404);
|
||||
abort_unless(data_get($payload, 'action') === $action, 404);
|
||||
if (! is_array($payload) || data_get($payload, 'action') !== $action) {
|
||||
$this->rejectInvalidGithubAppSetupState($request);
|
||||
}
|
||||
|
||||
$team_id = $request->user()?->currentTeam()?->id;
|
||||
abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
|
||||
@@ -610,6 +631,18 @@ class Github extends Controller
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function rejectInvalidGithubAppSetupState(Request $request): never
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
throw new HttpResponseException(
|
||||
redirect()
|
||||
->route('source.all')
|
||||
);
|
||||
}
|
||||
|
||||
private function githubAppSetupStateCacheKey(string $state): string
|
||||
{
|
||||
return 'github-app-setup-state:'.hash('sha256', $state);
|
||||
|
||||
@@ -1388,7 +1388,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
// Add PORT if not exists, use the first port as default
|
||||
if ($this->build_pack !== 'dockercompose') {
|
||||
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
|
||||
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty() && ! empty($ports)) {
|
||||
$envs->push("PORT={$ports[0]}");
|
||||
}
|
||||
}
|
||||
@@ -2129,21 +2129,23 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$helperImage = "{$helperImage}:".getHelperVersion();
|
||||
// Get user home directory
|
||||
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
|
||||
instant_remote_process(["mkdir -p {$this->serverUserHomeDir}/.docker/buildx"], $this->server);
|
||||
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
|
||||
|
||||
$env_flags = $this->generate_docker_env_flags_for_secrets();
|
||||
$buildxMetadataVolume = "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
|
||||
if ($this->use_build_server) {
|
||||
if ($this->dockerConfigFileExists === 'NOK') {
|
||||
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
|
||||
}
|
||||
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||
} else {
|
||||
if ($this->dockerConfigFileExists === 'OK') {
|
||||
$safeNetwork = escapeshellarg($this->destination->network);
|
||||
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||
} else {
|
||||
$safeNetwork = escapeshellarg($this->destination->network);
|
||||
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||
}
|
||||
}
|
||||
if ($firstTry) {
|
||||
@@ -2258,7 +2260,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$sshCommand = "ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null";
|
||||
|
||||
if ($identityFile !== null) {
|
||||
$sshCommand .= " -i {$identityFile}";
|
||||
$sshCommand .= " -i {$identityFile} -o IdentitiesOnly=yes";
|
||||
}
|
||||
|
||||
return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef);
|
||||
@@ -2298,18 +2300,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$private_key = data_get($this->application, 'private_key.private_key');
|
||||
if ($private_key) {
|
||||
$private_key = base64_encode($private_key);
|
||||
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}";
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
|
||||
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
||||
executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, '/root/.ssh/id_rsa')),
|
||||
executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, $customSshKeyLocation)),
|
||||
'hidden' => true,
|
||||
'save' => 'git_commit_sha',
|
||||
]
|
||||
@@ -3136,7 +3139,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
'image' => $this->production_image_name,
|
||||
'container_name' => $this->container_name,
|
||||
'restart' => RESTART_MODE,
|
||||
'expose' => $ports,
|
||||
...(! empty($ports) ? ['expose' => $ports] : []),
|
||||
'networks' => [
|
||||
$this->destination->network => [
|
||||
'aliases' => array_merge(
|
||||
@@ -3168,16 +3171,19 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
// If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used
|
||||
// If healthcheck is disabled, no healthcheck will be added
|
||||
if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) {
|
||||
$docker_compose['services'][$this->container_name]['healthcheck'] = [
|
||||
'test' => [
|
||||
'CMD-SHELL',
|
||||
$this->generate_healthcheck_commands(),
|
||||
],
|
||||
'interval' => $this->application->health_check_interval.'s',
|
||||
'timeout' => $this->application->health_check_timeout.'s',
|
||||
'retries' => $this->application->health_check_retries,
|
||||
'start_period' => $this->application->health_check_start_period.'s',
|
||||
];
|
||||
$healthcheck_command = $this->generate_healthcheck_commands();
|
||||
if ($healthcheck_command !== null) {
|
||||
$docker_compose['services'][$this->container_name]['healthcheck'] = [
|
||||
'test' => [
|
||||
'CMD-SHELL',
|
||||
$healthcheck_command,
|
||||
],
|
||||
'interval' => $this->application->health_check_interval.'s',
|
||||
'timeout' => $this->application->health_check_timeout.'s',
|
||||
'retries' => $this->application->health_check_retries,
|
||||
'start_period' => $this->application->health_check_start_period.'s',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_null($this->application->limits_cpuset)) {
|
||||
@@ -3387,7 +3393,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
|
||||
// HTTP type healthcheck (default)
|
||||
if (! $this->application->health_check_port) {
|
||||
$health_check_port = (int) $this->application->ports_exposes_array[0];
|
||||
if (! empty($this->application->ports_exposes_array)) {
|
||||
$health_check_port = (int) $this->application->ports_exposes_array[0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
$health_check_port = (int) $this->application->health_check_port;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Server;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CleanupStaleMultiplexedConnections implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->cleanupStaleConnections();
|
||||
$this->cleanupNonExistentServerConnections();
|
||||
$this->cleanupOrphanedSshProcesses();
|
||||
$this->cleanupOrphanedCloudflaredProcesses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill backgrounded ssh master processes that lost the ControlPath socket
|
||||
* race. Such processes are not masters, so ControlPersist never reaps them
|
||||
* and they leak memory until the container restarts. A legitimate master
|
||||
* always owns its socket file; an orphan has none.
|
||||
*
|
||||
* Processes younger than the minimum age are skipped: a freshly forked
|
||||
* master creates its socket a few milliseconds after starting, so a young
|
||||
* process with no socket may simply be mid-establish rather than orphaned.
|
||||
*/
|
||||
private function cleanupOrphanedSshProcesses(): void
|
||||
{
|
||||
$muxDir = storage_path('app/ssh/mux');
|
||||
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
|
||||
|
||||
foreach ($this->listProcesses() as $process) {
|
||||
// Backgrounded ssh master: current `ssh -fN` or legacy `ssh -fNM`.
|
||||
if (! preg_match('#(^|/)ssh -fN#', $process['args'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only ever touch ssh processes pointing at Coolify's mux directory.
|
||||
if (! preg_match('#ControlPath=('.preg_quote($muxDir, '#').'/\S+)#', $process['args'], $pathMatch)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) {
|
||||
$this->reapOrphan('ssh', $process);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill orphaned `cloudflared access ssh` proxy processes. Each is spawned
|
||||
* as the SSH ProxyCommand transport for a Cloudflare Tunnel server and must
|
||||
* die with its parent ssh. When that ssh is killed or orphaned (e.g. a lost
|
||||
* mux master), the cloudflared process can leak and accumulate. A legitimate
|
||||
* proxy always has a live ssh parent; one without is safe to reap.
|
||||
*
|
||||
* Processes younger than the minimum age are skipped so a proxy whose parent
|
||||
* ssh is still starting up, or a transient `ssh -O check` proxy mid-exit, is
|
||||
* never mistaken for an orphan.
|
||||
*/
|
||||
private function cleanupOrphanedCloudflaredProcesses(): void
|
||||
{
|
||||
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
|
||||
$processes = $this->listProcesses();
|
||||
|
||||
$sshPids = [];
|
||||
foreach ($processes as $process) {
|
||||
// The ssh binary itself, not `cloudflared access ssh` (space before ssh).
|
||||
if (preg_match('#(^|/)ssh\s#', $process['args'])) {
|
||||
$sshPids[$process['pid']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($processes as $process) {
|
||||
// `cloudflared access ssh`, never the `cloudflared tunnel` daemon.
|
||||
if (! str_contains($process['args'], 'cloudflared access ssh')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Orphaned when no live ssh process is its parent.
|
||||
if ($process['etimes'] >= $minAge && ! isset($sshPids[$process['ppid']])) {
|
||||
$this->reapOrphan('cloudflared', $process);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap a detected orphan process. When orphan reaping is disabled (the
|
||||
* default), the orphan is only logged — a dry-run mode that lets operators
|
||||
* verify what would be killed before enabling it for real.
|
||||
*
|
||||
* @param array{pid: string, ppid: string, etimes: int, args: string} $process
|
||||
*/
|
||||
private function reapOrphan(string $kind, array $process): void
|
||||
{
|
||||
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
|
||||
Log::info("Orphaned {$kind} process detected (dry-run, not killed)", [
|
||||
'pid' => $process['pid'],
|
||||
'etimes' => $process['etimes'],
|
||||
'command' => $process['args'],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Process::run('kill '.escapeshellarg($process['pid']));
|
||||
Log::info("Killed orphaned {$kind} process", [
|
||||
'pid' => $process['pid'],
|
||||
'etimes' => $process['etimes'],
|
||||
'command' => $process['args'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of running processes.
|
||||
*
|
||||
* @return list<array{pid: string, ppid: string, etimes: int, args: string}>
|
||||
*/
|
||||
private function listProcesses(): array
|
||||
{
|
||||
$ps = Process::run('ps -ww -eo pid=,ppid=,etimes=,args=');
|
||||
if ($ps->exitCode() !== 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$processes = [];
|
||||
foreach (explode("\n", trim($ps->output())) as $line) {
|
||||
if (! preg_match('/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/', $line, $matches)) {
|
||||
continue;
|
||||
}
|
||||
$processes[] = [
|
||||
'pid' => $matches[1],
|
||||
'ppid' => $matches[2],
|
||||
'etimes' => (int) $matches[3],
|
||||
'args' => $matches[4],
|
||||
];
|
||||
}
|
||||
|
||||
return $processes;
|
||||
}
|
||||
|
||||
private function cleanupStaleConnections()
|
||||
{
|
||||
$muxFiles = Storage::disk('ssh-mux')->files();
|
||||
|
||||
foreach ($muxFiles as $muxFile) {
|
||||
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
|
||||
$server = Server::where('uuid', $serverUuid)->first();
|
||||
|
||||
if (! $server) {
|
||||
$this->removeMultiplexFile($muxFile, 'server_not_found');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
|
||||
$checkCommand = "ssh -O check -o ControlPath={$muxSocket} {$server->user}@{$server->ip} 2>/dev/null";
|
||||
$checkProcess = Process::run($checkCommand);
|
||||
|
||||
if ($checkProcess->exitCode() !== 0) {
|
||||
$this->removeMultiplexFile($muxFile, 'connection_check_failed');
|
||||
} else {
|
||||
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
|
||||
$establishedAt = Carbon::parse(substr($muxContent, 37));
|
||||
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
|
||||
|
||||
if (Carbon::now()->isAfter($expirationTime)) {
|
||||
$this->removeMultiplexFile($muxFile, 'expired');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupNonExistentServerConnections()
|
||||
{
|
||||
$muxFiles = Storage::disk('ssh-mux')->files();
|
||||
$existingServerUuids = Server::pluck('uuid')->toArray();
|
||||
|
||||
foreach ($muxFiles as $muxFile) {
|
||||
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
|
||||
if (! in_array($serverUuid, $existingServerUuids)) {
|
||||
$this->removeMultiplexFile($muxFile, 'server_does_not_exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function extractServerUuidFromMuxFile($muxFile)
|
||||
{
|
||||
return substr($muxFile, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and delete a stale mux socket file. When orphan reaping is disabled
|
||||
* (the default), the file is only logged — a dry-run mode that lets operators
|
||||
* verify what would be removed before enabling it for real.
|
||||
*/
|
||||
private function removeMultiplexFile(string $muxFile, string $reason): void
|
||||
{
|
||||
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
|
||||
Log::info('Stale mux file detected (dry-run, not removed)', [
|
||||
'file' => $muxFile,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
|
||||
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
|
||||
Process::run($closeCommand);
|
||||
Storage::disk('ssh-mux')->delete($muxFile);
|
||||
|
||||
Log::info('Removed stale mux file', [
|
||||
'file' => $muxFile,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -44,7 +45,7 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
$validator = Validator::make(
|
||||
['webhook_url' => $this->webhookUrl],
|
||||
['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]]
|
||||
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Services\ConfigurationRepository;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Url;
|
||||
@@ -218,6 +219,23 @@ class Index extends Component
|
||||
}
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'remoteServerName' => 'required|string',
|
||||
'remoteServerHost' => 'required|string',
|
||||
'remoteServerPort' => 'required|integer|min:1|max:65535',
|
||||
'remoteServerUser' => ValidationPatterns::serverUsernameRules(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
...ValidationPatterns::serverUsernameMessages('remoteServerUser', 'SSH User'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getProxyType()
|
||||
{
|
||||
$this->selectProxy(ProxyTypes::TRAEFIK->value);
|
||||
@@ -281,12 +299,7 @@ class Index extends Component
|
||||
|
||||
public function saveServer()
|
||||
{
|
||||
$this->validate([
|
||||
'remoteServerName' => 'required|string',
|
||||
'remoteServerHost' => 'required|string',
|
||||
'remoteServerPort' => 'required|integer',
|
||||
'remoteServerUser' => 'required|string',
|
||||
]);
|
||||
$this->validate();
|
||||
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
@@ -478,10 +491,10 @@ class Index extends Component
|
||||
|
||||
public function saveAndValidateServer()
|
||||
{
|
||||
$this->validate([
|
||||
'remoteServerPort' => 'required|integer|min:1|max:65535',
|
||||
'remoteServerUser' => 'required|string',
|
||||
]);
|
||||
$this->validate(array_intersect_key($this->rules(), array_flip([
|
||||
'remoteServerPort',
|
||||
'remoteServerUser',
|
||||
])));
|
||||
|
||||
$this->createdServer->update([
|
||||
'port' => $this->remoteServerPort,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Destination;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\BaseModel;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class Resources extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public $destination;
|
||||
|
||||
public array $resources = [];
|
||||
|
||||
public function mount(string $destination_uuid)
|
||||
{
|
||||
try {
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
return redirect()->route('destination.index');
|
||||
}
|
||||
if (! $destination instanceof StandaloneDocker) {
|
||||
return redirect()->route('destination.show', ['destination_uuid' => $destination->uuid]);
|
||||
}
|
||||
|
||||
$this->destination = $destination;
|
||||
$this->loadResources();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load applications, services, and database resources deployed to the standalone Docker destination.
|
||||
*
|
||||
* @return void Populates the resources property for display.
|
||||
*/
|
||||
public function loadResources(): void
|
||||
{
|
||||
$this->resources = $this->collectResources([
|
||||
$this->destination->applications,
|
||||
$this->destination->services,
|
||||
$this->destination->postgresqls,
|
||||
$this->destination->redis,
|
||||
$this->destination->mongodbs,
|
||||
$this->destination->mysqls,
|
||||
$this->destination->mariadbs,
|
||||
$this->destination->keydbs,
|
||||
$this->destination->dragonflies,
|
||||
$this->destination->clickhouses,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, iterable<Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse>> $groups
|
||||
* @return array<int, array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}>
|
||||
*/
|
||||
protected function collectResources(array $groups): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($groups as $group) {
|
||||
foreach ($group as $resource) {
|
||||
$rows[] = $this->resourceRow($resource);
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource
|
||||
* @return array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}
|
||||
*/
|
||||
protected function resourceRow(BaseModel $resource): array
|
||||
{
|
||||
$type = match (true) {
|
||||
$resource instanceof Application => 'application',
|
||||
$resource instanceof Service => 'service',
|
||||
default => 'database',
|
||||
};
|
||||
$environment = $resource->environment;
|
||||
$project = $environment?->project;
|
||||
$routeName = "project.{$type}.configuration";
|
||||
$url = ($project && $environment)
|
||||
? route($routeName, [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
"{$type}_uuid" => $resource->uuid,
|
||||
])
|
||||
: null;
|
||||
|
||||
return [
|
||||
'uuid' => $resource->uuid,
|
||||
'type' => $type,
|
||||
'name' => $resource->name,
|
||||
'project' => $project?->name,
|
||||
'environment' => $environment?->name,
|
||||
'url' => $url,
|
||||
'search' => strtolower(implode(' ', array_filter([
|
||||
$type,
|
||||
$resource->name,
|
||||
$project?->name,
|
||||
$environment?->name,
|
||||
]))),
|
||||
];
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.destination.resources');
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,9 @@ class Advanced extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isConnectToDockerNetworkEnabled = false;
|
||||
|
||||
#[Validate(['integer', 'min:0'])]
|
||||
public int $maxRestartCount = 10;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
@@ -149,6 +152,7 @@ class Advanced extends Component
|
||||
$this->disableBuildCache = $this->application->settings->disable_build_cache;
|
||||
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
|
||||
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
|
||||
$this->maxRestartCount = $this->application->max_restart_count ?? 10;
|
||||
}
|
||||
|
||||
// Load stop_grace_period separately since it has its own save handler
|
||||
@@ -289,6 +293,21 @@ class Advanced extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function saveMaxRestartCount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$this->validate([
|
||||
'maxRestartCount' => 'integer|min:0',
|
||||
]);
|
||||
$this->application->max_restart_count = $this->maxRestartCount;
|
||||
$this->application->save();
|
||||
$this->dispatch('success', 'Max restart count saved.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.advanced');
|
||||
|
||||
@@ -28,7 +28,7 @@ class Configuration extends Component
|
||||
|
||||
$project = currentTeam()
|
||||
->projects()
|
||||
->select('id', 'uuid', 'team_id')
|
||||
->select('id', 'uuid', 'name', 'team_id')
|
||||
->where('uuid', request()->route('project_uuid'))
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
|
||||
@@ -154,7 +154,7 @@ class General extends Component
|
||||
'staticImage' => 'required',
|
||||
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
|
||||
'publishDirectory' => ValidationPatterns::directoryPathRules(),
|
||||
'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'],
|
||||
'portsExposes' => ['nullable', 'string', 'regex:/^(\d+)(,\d+)*$/'],
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'customNetworkAliases' => 'nullable',
|
||||
'dockerfile' => 'nullable',
|
||||
@@ -212,7 +212,6 @@ class General extends Component
|
||||
'buildPack.required' => 'The Build Pack field is required.',
|
||||
'staticImage.required' => 'The Static Image field is required.',
|
||||
'baseDirectory.required' => 'The Base Directory field is required.',
|
||||
'portsExposes.required' => 'The Exposed Ports field is required.',
|
||||
'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).',
|
||||
...ValidationPatterns::portMappingMessages(),
|
||||
'isStatic.required' => 'The Static setting is required.',
|
||||
@@ -760,7 +759,7 @@ class General extends Component
|
||||
|
||||
$this->resetErrorBag();
|
||||
|
||||
$this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString();
|
||||
$this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString() ?: null;
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class Configuration extends Component
|
||||
|
||||
$project = currentTeam()
|
||||
->projects()
|
||||
->select('id', 'uuid', 'team_id')
|
||||
->select('id', 'uuid', 'name', 'team_id')
|
||||
->where('uuid', request()->route('project_uuid'))
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace App\Livewire\Project\New;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Component;
|
||||
|
||||
class Select extends Component
|
||||
@@ -105,7 +107,9 @@ class Select extends Component
|
||||
public function loadServices()
|
||||
{
|
||||
$services = get_service_templates();
|
||||
$services = collect($services)->map(function ($service, $key) {
|
||||
$templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services->keys());
|
||||
|
||||
$services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) {
|
||||
$default_logo = 'images/default.webp';
|
||||
$logo = data_get($service, 'logo', $default_logo);
|
||||
$local_logo_path = public_path($logo);
|
||||
@@ -116,6 +120,7 @@ class Select extends Component
|
||||
'logo_github_url' => file_exists($local_logo_path)
|
||||
? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo
|
||||
: asset($default_logo),
|
||||
'templateLastUpdated' => $templateLastUpdatedMap[(string) $key] ?? null,
|
||||
] + (array) $service;
|
||||
})->all();
|
||||
|
||||
@@ -247,6 +252,7 @@ class Select extends Component
|
||||
];
|
||||
|
||||
return [
|
||||
'serviceTemplatesLastUpdated' => $this->serviceTemplatesLastUpdated(),
|
||||
'services' => $services,
|
||||
'categories' => $categories,
|
||||
'gitBasedApplications' => $gitBasedApplications,
|
||||
@@ -268,6 +274,55 @@ class Select extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function serviceTemplatesLastUpdated(): ?string
|
||||
{
|
||||
return $this->formatLastModified($this->serviceTemplatesPath());
|
||||
}
|
||||
|
||||
private function serviceTemplateLastUpdatedMap(Collection $serviceNames): array
|
||||
{
|
||||
$bundleMtime = file_exists($this->serviceTemplatesPath()) ? filemtime($this->serviceTemplatesPath()) : 0;
|
||||
|
||||
return Cache::remember(
|
||||
"service-template-last-updated-map:{$bundleMtime}",
|
||||
now()->addDay(),
|
||||
fn () => $serviceNames
|
||||
->mapWithKeys(fn ($serviceName) => [
|
||||
(string) $serviceName => $this->serviceTemplateLastUpdated((string) $serviceName),
|
||||
])
|
||||
->all()
|
||||
);
|
||||
}
|
||||
|
||||
private function serviceTemplateLastUpdated(string $serviceName): ?string
|
||||
{
|
||||
foreach (['yaml', 'yml'] as $extension) {
|
||||
$templatePath = base_path("templates/compose/{$serviceName}.{$extension}");
|
||||
|
||||
if (file_exists($templatePath)) {
|
||||
return $this->formatLastModified($templatePath);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function serviceTemplatesPath(): string
|
||||
{
|
||||
return base_path('templates/'.config('constants.services.file_name'));
|
||||
}
|
||||
|
||||
private function formatLastModified(string $path): ?string
|
||||
{
|
||||
if (! file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CarbonImmutable::createFromTimestamp(filemtime($path))
|
||||
->timezone(config('app.timezone'))
|
||||
->format('M j, Y H:i');
|
||||
}
|
||||
|
||||
public function setType(string $type)
|
||||
{
|
||||
$type = str($type)->lower()->slug()->value();
|
||||
|
||||
@@ -44,7 +44,7 @@ class Configuration extends Component
|
||||
$this->query = request()->query();
|
||||
$project = currentTeam()
|
||||
->projects()
|
||||
->select('id', 'uuid', 'team_id')
|
||||
->select('id', 'uuid', 'name', 'team_id')
|
||||
->where('uuid', request()->route('project_uuid'))
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
|
||||
@@ -21,8 +21,6 @@ class ConfigurationChecker extends Component
|
||||
|
||||
public array $configurationDiff = [];
|
||||
|
||||
public array $groupedConfigurationChanges = [];
|
||||
|
||||
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
|
||||
|
||||
public function getListeners(): array
|
||||
@@ -50,21 +48,56 @@ class ConfigurationChecker extends Component
|
||||
$this->configurationChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Members must never see environment variable values, so redact every
|
||||
* environment-section change before it is serialized to the browser.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $changes
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function redactEnvironmentChanges(array $changes, bool $redact): array
|
||||
{
|
||||
if (! $redact) {
|
||||
return $changes;
|
||||
}
|
||||
|
||||
return collect($changes)
|
||||
->map(function (array $change): array {
|
||||
if (data_get($change, 'section') !== 'environment') {
|
||||
return $change;
|
||||
}
|
||||
|
||||
$change['old_display_value'] = data_get($change, 'old_display_value') === '-' ? '-' : '••••••••';
|
||||
$change['new_display_value'] = data_get($change, 'new_display_value') === '-' ? '-' : '••••••••';
|
||||
$change['old_full_value'] = null;
|
||||
$change['new_full_value'] = null;
|
||||
$change['expandable'] = false;
|
||||
$change['display_summary'] = data_get($change, 'type') === 'changed' ? 'Changed' : null;
|
||||
|
||||
return $change;
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
public function configurationChanged(): void
|
||||
{
|
||||
$this->resource->refresh();
|
||||
|
||||
if ($this->resource instanceof Application) {
|
||||
$diff = $this->resource->pendingDeploymentConfigurationDiff();
|
||||
// Fail closed: only owners/admins may see unlocked env values.
|
||||
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
|
||||
|
||||
$array = $diff->toArray();
|
||||
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
|
||||
|
||||
$this->isConfigurationChanged = $diff->isChanged();
|
||||
$this->configurationDiff = $diff->toArray();
|
||||
$this->groupedConfigurationChanges = $diff->groupedChanges();
|
||||
$this->configurationDiff = $array;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isConfigurationChanged = $this->resource->isConfigurationChanged();
|
||||
$this->configurationDiff = [];
|
||||
$this->groupedConfigurationChanges = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ use App\Models\EnvironmentVariable;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\EnvironmentVariableProtection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
|
||||
class All extends Component
|
||||
@@ -25,6 +27,8 @@ class All extends Component
|
||||
|
||||
public string $view = 'normal';
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public bool $is_env_sorting_enabled = false;
|
||||
|
||||
public bool $use_build_secrets = false;
|
||||
@@ -35,6 +39,20 @@ class All extends Component
|
||||
'environmentVariableDeleted' => 'refreshEnvs',
|
||||
];
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->clearEnvironmentVariableCaches();
|
||||
}
|
||||
|
||||
private function clearEnvironmentVariableCaches(): void
|
||||
{
|
||||
unset($this->environmentVariables);
|
||||
unset($this->environmentVariablesPreview);
|
||||
unset($this->hardcodedEnvironmentVariables);
|
||||
unset($this->hardcodedEnvironmentVariablesPreview);
|
||||
unset($this->hasEnvironmentVariables);
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
|
||||
@@ -65,8 +83,27 @@ class All extends Component
|
||||
|
||||
public function getEnvironmentVariablesProperty()
|
||||
{
|
||||
$query = $this->resource->environment_variables()
|
||||
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
|
||||
return $this->getEnvironmentVariables(false);
|
||||
}
|
||||
|
||||
public function getEnvironmentVariablesPreviewProperty()
|
||||
{
|
||||
return $this->getEnvironmentVariables(true);
|
||||
}
|
||||
|
||||
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection
|
||||
{
|
||||
$query = $isPreview
|
||||
? $this->resource->environment_variables_preview()
|
||||
: $this->resource->environment_variables();
|
||||
|
||||
$query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
|
||||
|
||||
if ($withSearch && $this->searchTerm() !== '') {
|
||||
$escapedSearch = addcslashes(Str::lower($this->searchTerm()), '%_\\');
|
||||
|
||||
$query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']);
|
||||
}
|
||||
|
||||
if ($this->is_env_sorting_enabled) {
|
||||
$query->orderBy('key');
|
||||
@@ -77,18 +114,17 @@ class All extends Component
|
||||
return $this->nullLockedValues($query->get());
|
||||
}
|
||||
|
||||
public function getEnvironmentVariablesPreviewProperty()
|
||||
private function searchTerm(): string
|
||||
{
|
||||
$query = $this->resource->environment_variables_preview()
|
||||
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
|
||||
return trim($this->search);
|
||||
}
|
||||
|
||||
if ($this->is_env_sorting_enabled) {
|
||||
$query->orderBy('key');
|
||||
} else {
|
||||
$query->orderBy('order');
|
||||
}
|
||||
|
||||
return $this->nullLockedValues($query->get());
|
||||
public function getHasEnvironmentVariablesProperty(): bool
|
||||
{
|
||||
return $this->environmentVariables->isNotEmpty() ||
|
||||
$this->environmentVariablesPreview->isNotEmpty() ||
|
||||
$this->hardcodedEnvironmentVariables->isNotEmpty() ||
|
||||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty();
|
||||
}
|
||||
|
||||
private function nullLockedValues($envs)
|
||||
@@ -105,6 +141,11 @@ class All extends Component
|
||||
return $envs;
|
||||
}
|
||||
|
||||
public function getIsSearchActiveProperty(): bool
|
||||
{
|
||||
return $this->searchTerm() !== '';
|
||||
}
|
||||
|
||||
public function getHardcodedEnvironmentVariablesProperty()
|
||||
{
|
||||
return $this->getHardcodedVariables(false);
|
||||
@@ -152,6 +193,12 @@ class All extends Component
|
||||
return ! in_array($var['key'], $managedKeys);
|
||||
});
|
||||
|
||||
if ($this->searchTerm() !== '') {
|
||||
$hardcodedVars = $hardcodedVars->filter(function ($var) {
|
||||
return str($var['key'])->contains($this->searchTerm(), true);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sorting based on is_env_sorting_enabled
|
||||
if ($this->is_env_sorting_enabled) {
|
||||
$hardcodedVars = $hardcodedVars->sortBy('key')->values();
|
||||
@@ -163,9 +210,9 @@ class All extends Component
|
||||
|
||||
public function getDevView()
|
||||
{
|
||||
$this->variables = $this->formatEnvironmentVariables($this->environmentVariables);
|
||||
$this->variables = $this->formatEnvironmentVariables($this->getEnvironmentVariables(false, false));
|
||||
if ($this->showPreview) {
|
||||
$this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview);
|
||||
$this->variablesPreview = $this->formatEnvironmentVariables($this->getEnvironmentVariables(true, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,9 +348,7 @@ class All extends Component
|
||||
$environment->order = $maxOrder + 1;
|
||||
$environment->save();
|
||||
|
||||
// Clear computed property cache to force refresh
|
||||
unset($this->environmentVariables);
|
||||
unset($this->environmentVariablesPreview);
|
||||
$this->clearEnvironmentVariableCaches();
|
||||
|
||||
$this->dispatch('success', 'Environment variable added.');
|
||||
}
|
||||
@@ -432,9 +477,7 @@ class All extends Component
|
||||
public function refreshEnvs()
|
||||
{
|
||||
$this->resource->refresh();
|
||||
// Clear computed property cache to force refresh
|
||||
unset($this->environmentVariables);
|
||||
unset($this->environmentVariablesPreview);
|
||||
$this->clearEnvironmentVariableCaches();
|
||||
$this->getDevView();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class ResourceDetails extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $resource;
|
||||
|
||||
public ?string $project_uuid = null;
|
||||
|
||||
public ?string $project_name = null;
|
||||
|
||||
public ?string $environment_uuid = null;
|
||||
|
||||
public ?string $environment_name = null;
|
||||
|
||||
public ?string $server_uuid = null;
|
||||
|
||||
public ?string $server_name = null;
|
||||
|
||||
public array $stack_applications = [];
|
||||
|
||||
public array $stack_databases = [];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('view', $this->resource);
|
||||
|
||||
$environment = $this->resource->environment ?? null;
|
||||
if ($environment) {
|
||||
$this->environment_uuid = $environment->uuid;
|
||||
$this->environment_name = $environment->name;
|
||||
$project = $environment->project ?? null;
|
||||
if ($project) {
|
||||
$this->project_uuid = $project->uuid;
|
||||
$this->project_name = $project->name;
|
||||
}
|
||||
}
|
||||
|
||||
$server = $this->resolveServer();
|
||||
if ($server) {
|
||||
$this->server_uuid = $server->uuid;
|
||||
$this->server_name = $server->name;
|
||||
}
|
||||
|
||||
if ($this->resource instanceof Service) {
|
||||
$this->stack_applications = $this->resource->applications
|
||||
->map(fn ($app) => [
|
||||
'name' => $app->human_name ?: $app->name,
|
||||
'uuid' => $app->uuid,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->stack_databases = $this->resource->databases
|
||||
->map(fn ($db) => [
|
||||
'name' => $db->human_name ?: $db->name,
|
||||
'uuid' => $db->uuid,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveServer()
|
||||
{
|
||||
try {
|
||||
if (isset($this->resource->destination) && $this->resource->destination && isset($this->resource->destination->server)) {
|
||||
return $this->resource->destination->server;
|
||||
}
|
||||
if (method_exists($this->resource, 'server') && $this->resource->server) {
|
||||
return $this->resource->server;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.resource-details');
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Charts extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public $chartId = 'server';
|
||||
@@ -28,6 +32,29 @@ class Charts extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleMetrics(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->server->settings->is_metrics_enabled = ! $this->server->settings->is_metrics_enabled;
|
||||
$this->server->settings->save();
|
||||
$this->server->refresh();
|
||||
|
||||
if ($this->server->isMetricsEnabled()) {
|
||||
StartSentinel::run($this->server, true);
|
||||
$this->dispatch('success', 'Metrics enabled. Starting Sentinel.');
|
||||
$this->dispatch('refreshServerShow');
|
||||
$this->redirect(route('server.metrics', ['server_uuid' => $this->server->uuid]), navigate: true);
|
||||
} else {
|
||||
$this->server->restartSentinel();
|
||||
$this->dispatch('success', 'Metrics disabled. Restarting Sentinel.');
|
||||
$this->dispatch('refreshServerShow');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function pollData()
|
||||
{
|
||||
if ($this->poll || $this->interval <= 10) {
|
||||
|
||||
@@ -57,7 +57,7 @@ class ByIp extends Component
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'ip' => ['required', 'string', new ValidServerIp],
|
||||
'user' => ['required', 'string', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'user' => ValidationPatterns::serverUsernameRules(),
|
||||
'port' => 'required|integer|between:1,65535',
|
||||
'is_build_server' => 'required|boolean',
|
||||
];
|
||||
@@ -75,6 +75,7 @@ class ByIp extends Component
|
||||
'ip.string' => 'The IP Address/Domain must be a string.',
|
||||
'user.required' => 'The User field is required.',
|
||||
'user.string' => 'The User field must be a string.',
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
'port.required' => 'The Port field is required.',
|
||||
'port.integer' => 'The Port field must be an integer.',
|
||||
'port.between' => 'The Port field must be between 1 and 65535.',
|
||||
|
||||
@@ -15,8 +15,6 @@ class Sentinel extends Component
|
||||
|
||||
public Server $server;
|
||||
|
||||
public array $parameters = [];
|
||||
|
||||
public bool $isMetricsEnabled;
|
||||
|
||||
#[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])]
|
||||
@@ -51,15 +49,9 @@ class Sentinel extends Component
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->syncData();
|
||||
} catch (\Throwable) {
|
||||
return redirect()->route('server.index');
|
||||
}
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
@@ -112,27 +104,29 @@ class Sentinel extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedIsSentinelEnabled($value)
|
||||
public function toggleSentinel(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
if ($value === true) {
|
||||
if (! $this->isSentinelEnabled) {
|
||||
if ($this->server->isBuildServer()) {
|
||||
$this->isSentinelEnabled = false;
|
||||
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->isSentinelEnabled = true;
|
||||
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||
StartSentinel::run($this->server, true, null, $customImage);
|
||||
} else {
|
||||
$this->isSentinelEnabled = false;
|
||||
$this->isMetricsEnabled = false;
|
||||
$this->isSentinelDebugEnabled = false;
|
||||
StopSentinel::dispatch($this->server);
|
||||
}
|
||||
$this->submit();
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server\Sentinel;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Logs extends Component
|
||||
{
|
||||
public ?Server $server = null;
|
||||
|
||||
public array $parameters = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
try {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.server.sentinel.logs');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server\Sentinel;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
public ?Server $server = null;
|
||||
|
||||
public array $parameters = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
try {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.server.sentinel.show');
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ class Show extends Component
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'ip' => ['required', new ValidServerIp],
|
||||
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'user' => ValidationPatterns::serverUsernameRules(),
|
||||
'port' => 'required|integer|between:1,65535',
|
||||
'connectionTimeout' => 'required|integer|min:1|max:300',
|
||||
'validationLogs' => 'nullable',
|
||||
@@ -140,6 +140,7 @@ class Show extends Component
|
||||
[
|
||||
'ip.required' => 'The IP Address field is required.',
|
||||
'user.required' => 'The User field is required.',
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
'port.required' => 'The Port field is required.',
|
||||
'connectionTimeout.required' => 'The SSH Connection Timeout field is required.',
|
||||
'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.',
|
||||
|
||||
@@ -8,6 +8,15 @@ use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Services\SchedulerLogParser;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -125,7 +134,21 @@ class ScheduledJobs extends Component
|
||||
: collect();
|
||||
|
||||
$backups = $backupIds->isNotEmpty()
|
||||
? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id')
|
||||
? ScheduledDatabaseBackup::with('database')
|
||||
->whereIn('id', $backupIds)
|
||||
->get()
|
||||
->loadMorph('database', [
|
||||
ServiceDatabase::class => ['service.environment.project'],
|
||||
StandaloneClickhouse::class => ['environment.project'],
|
||||
StandaloneDragonfly::class => ['environment.project'],
|
||||
StandaloneKeydb::class => ['environment.project'],
|
||||
StandaloneMariadb::class => ['environment.project'],
|
||||
StandaloneMongodb::class => ['environment.project'],
|
||||
StandaloneMysql::class => ['environment.project'],
|
||||
StandalonePostgresql::class => ['environment.project'],
|
||||
StandaloneRedis::class => ['environment.project'],
|
||||
])
|
||||
->keyBy('id')
|
||||
: collect();
|
||||
|
||||
$servers = $serverIds->isNotEmpty()
|
||||
@@ -161,14 +184,29 @@ class ScheduledJobs extends Component
|
||||
if ($backup) {
|
||||
$database = $backup->database;
|
||||
$skip['resource_name'] = $database?->name ?? 'Database backup';
|
||||
$environment = $database?->environment;
|
||||
$project = $environment?->project;
|
||||
if ($project && $environment && $database) {
|
||||
$skip['link'] = route('project.database.backup.index', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
]);
|
||||
|
||||
if ($database instanceof ServiceDatabase) {
|
||||
$service = $database->service;
|
||||
$environment = $service?->environment;
|
||||
$project = $environment?->project;
|
||||
if ($project && $environment && $service) {
|
||||
$skip['link'] = route('project.service.database.backups', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'service_uuid' => $service->uuid,
|
||||
'stack_service_uuid' => $database->uuid,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$environment = $database?->environment;
|
||||
$project = $environment?->project;
|
||||
if ($project && $environment && $database) {
|
||||
$skip['link'] = route('project.database.backup.index', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($skip['type'] === 'docker_cleanup') {
|
||||
|
||||
@@ -210,6 +210,9 @@ class Change extends Component
|
||||
|
||||
GithubAppPermissionJob::dispatchSync($this->github_app);
|
||||
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
$this->syncData(false);
|
||||
$this->name = str($this->github_app->name)->kebab();
|
||||
|
||||
$this->dispatch('success', 'Github App permissions updated.');
|
||||
} catch (\Throwable $e) {
|
||||
// Provide better error message for unsupported key formats
|
||||
|
||||
@@ -61,7 +61,7 @@ class InviteLink extends Component
|
||||
if ($member_emails->contains($this->email)) {
|
||||
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');
|
||||
}
|
||||
$uuid = new Cuid2(32);
|
||||
$uuid = (string) new Cuid2(32);
|
||||
$link = url('/').config('constants.invitation.link.base_url').$uuid;
|
||||
$user = User::whereEmail($this->email)->first();
|
||||
|
||||
@@ -73,7 +73,7 @@ class InviteLink extends Component
|
||||
'password' => Hash::make($password),
|
||||
'force_password_reset' => true,
|
||||
]);
|
||||
$token = Crypt::encryptString("{$user->email}@@@$password");
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
|
||||
$link = route('auth.link', ['token' => $token]);
|
||||
}
|
||||
$invitation = TeamInvitation::whereEmail($this->email)->first();
|
||||
|
||||
+91
-35
@@ -204,6 +204,7 @@ class Application extends BaseModel
|
||||
'config_hash',
|
||||
'last_online_at',
|
||||
'restart_count',
|
||||
'max_restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'uuid',
|
||||
@@ -227,6 +228,7 @@ class Application extends BaseModel
|
||||
'manual_webhook_secret_bitbucket' => 'encrypted',
|
||||
'manual_webhook_secret_gitea' => 'encrypted',
|
||||
'restart_count' => 'integer',
|
||||
'max_restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -570,6 +572,15 @@ class Application extends BaseModel
|
||||
return null;
|
||||
}
|
||||
|
||||
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')) {
|
||||
@@ -1347,6 +1358,7 @@ class Application extends BaseModel
|
||||
$branch = $this->git_branch;
|
||||
['repository' => $customRepository, 'port' => $customPort] = $this->customRepository();
|
||||
$commands = collect([]);
|
||||
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}";
|
||||
$base_command = 'git ls-remote';
|
||||
|
||||
if ($this->deploymentType() === 'source') {
|
||||
@@ -1400,19 +1412,20 @@ class Application extends BaseModel
|
||||
$private_key = base64_encode($private_key);
|
||||
$gitlabPort = $gitlabSource->custom_port ?? 22;
|
||||
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
|
||||
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'";
|
||||
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
|
||||
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
]);
|
||||
} else {
|
||||
$commands = collect([
|
||||
"trap 'rm -f {$customSshKeyLocation}' EXIT",
|
||||
'mkdir -p /root/.ssh',
|
||||
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
|
||||
'chmod 600 /root/.ssh/id_rsa',
|
||||
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
|
||||
"chmod 600 {$customSshKeyLocation}",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1458,19 +1471,20 @@ class Application extends BaseModel
|
||||
// When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context
|
||||
// Replace ' with '\'' to safely escape within single-quoted bash strings
|
||||
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
|
||||
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'";
|
||||
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
|
||||
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
]);
|
||||
} else {
|
||||
$commands = collect([
|
||||
"trap 'rm -f {$customSshKeyLocation}' EXIT",
|
||||
'mkdir -p /root/.ssh',
|
||||
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
|
||||
'chmod 600 /root/.ssh/id_rsa',
|
||||
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
|
||||
"chmod 600 {$customSshKeyLocation}",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1506,11 +1520,34 @@ class Application extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
private function withGitHttpTransportConfig(?string $gitConfigOptions = null): string
|
||||
{
|
||||
return trim(($gitConfigOptions ? "{$gitConfigOptions} " : '').'-c http.version=HTTP/1.1');
|
||||
}
|
||||
|
||||
private function isHttpGitRepository(string $repository): bool
|
||||
{
|
||||
return str_starts_with($repository, 'https://') || str_starts_with($repository, 'http://');
|
||||
}
|
||||
|
||||
private function applyGitConfigOptionsToCloneCommand(string $gitCloneCommand, string $gitConfigOptions): string
|
||||
{
|
||||
$configuredCommand = preg_replace(
|
||||
"/^git(?:\s+-c\s+(?:'[^']*'|\S+))*\s+clone\b/",
|
||||
"git {$gitConfigOptions} clone",
|
||||
$gitCloneCommand,
|
||||
1
|
||||
);
|
||||
|
||||
return $configuredCommand ?: $gitCloneCommand;
|
||||
}
|
||||
|
||||
public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null)
|
||||
{
|
||||
$branch = $this->git_branch;
|
||||
['repository' => $customRepository, 'port' => $customPort] = $this->customRepository();
|
||||
$baseDir = $custom_base_dir ?? $this->generateBaseDir($deployment_uuid);
|
||||
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}";
|
||||
|
||||
// Escape shell arguments for safety to prevent command injection
|
||||
$escapedBranch = escapeshellarg($branch);
|
||||
@@ -1548,8 +1585,10 @@ class Application extends BaseModel
|
||||
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
|
||||
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
|
||||
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
|
||||
$gitConfigOptions = $this->withGitHttpTransportConfig();
|
||||
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
|
||||
if (! $only_checkout) {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
@@ -1562,6 +1601,7 @@ class Application extends BaseModel
|
||||
|
||||
// Rewrite same-host HTTPS URLs only for these git commands so submodules can authenticate without persisting credentials.
|
||||
$gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}/");
|
||||
$gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption);
|
||||
$git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
@@ -1575,8 +1615,9 @@ class Application extends BaseModel
|
||||
$git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}";
|
||||
$fullRepoUrl = $repoUrl;
|
||||
}
|
||||
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
|
||||
if (! $only_checkout) {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption);
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
@@ -1587,12 +1628,13 @@ class Application extends BaseModel
|
||||
if ($pull_request_id !== 0) {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
|
||||
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null);
|
||||
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions ?? null);
|
||||
$gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git';
|
||||
$escapedPrBranch = escapeshellarg($branch);
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"));
|
||||
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"));
|
||||
} else {
|
||||
$commands->push("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command");
|
||||
$commands->push("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1612,7 +1654,7 @@ class Application extends BaseModel
|
||||
$private_key = base64_encode($private_key);
|
||||
$gitlabPort = $gitlabSource->custom_port ?? 22;
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
|
||||
$gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes";
|
||||
$gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\"";
|
||||
$git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
if ($only_checkout) {
|
||||
@@ -1623,14 +1665,15 @@ class Application extends BaseModel
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
|
||||
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
]);
|
||||
} else {
|
||||
$commands = collect([
|
||||
"trap 'rm -f {$customSshKeyLocation}' EXIT",
|
||||
'mkdir -p /root/.ssh',
|
||||
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
|
||||
'chmod 600 /root/.ssh/id_rsa',
|
||||
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
|
||||
"chmod 600 {$customSshKeyLocation}",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1661,7 +1704,11 @@ class Application extends BaseModel
|
||||
$fullRepoUrl = $customRepository;
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
|
||||
$gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null;
|
||||
if ($gitConfigOptions) {
|
||||
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
|
||||
}
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
@@ -1684,7 +1731,7 @@ class Application extends BaseModel
|
||||
}
|
||||
$private_key = base64_encode($private_key);
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
|
||||
$deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes";
|
||||
$deployKeyGitSshCommand = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\"";
|
||||
$git_clone_command_base = "{$deployKeyGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
if ($only_checkout) {
|
||||
@@ -1695,14 +1742,15 @@ class Application extends BaseModel
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
|
||||
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
]);
|
||||
} else {
|
||||
$commands = collect([
|
||||
"trap 'rm -f {$customSshKeyLocation}' EXIT",
|
||||
'mkdir -p /root/.ssh',
|
||||
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
|
||||
'chmod 600 /root/.ssh/id_rsa',
|
||||
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
|
||||
"chmod 600 {$customSshKeyLocation}",
|
||||
]);
|
||||
}
|
||||
if ($pull_request_id !== 0) {
|
||||
@@ -1713,7 +1761,7 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
@@ -1721,14 +1769,14 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1748,10 +1796,15 @@ class Application extends BaseModel
|
||||
$fullRepoUrl = $customRepository;
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
|
||||
$gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null;
|
||||
if ($gitConfigOptions) {
|
||||
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
|
||||
}
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
$otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
|
||||
|
||||
if ($pull_request_id !== 0) {
|
||||
$gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git';
|
||||
if ($git_type === 'gitlab') {
|
||||
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
@@ -1759,7 +1812,7 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
@@ -1767,14 +1820,14 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1873,7 +1926,8 @@ class Application extends BaseModel
|
||||
return;
|
||||
}
|
||||
$uuid = new Cuid2;
|
||||
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: '.');
|
||||
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout');
|
||||
$cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand);
|
||||
$workdir = rtrim($this->base_directory, '/');
|
||||
$composeFile = $this->docker_compose_location;
|
||||
$fileList = collect([".$workdir$composeFile"]);
|
||||
@@ -1903,6 +1957,7 @@ class Application extends BaseModel
|
||||
"mkdir -p /tmp/{$uuid}",
|
||||
"cd /tmp/{$uuid}",
|
||||
$cloneCommand,
|
||||
'cd checkout',
|
||||
'git sparse-checkout init',
|
||||
"git sparse-checkout set {$fileList->implode(' ')}",
|
||||
'git read-tree -mu HEAD',
|
||||
@@ -1914,6 +1969,7 @@ class Application extends BaseModel
|
||||
"mkdir -p /tmp/{$uuid}",
|
||||
"cd /tmp/{$uuid}",
|
||||
$cloneCommand,
|
||||
'cd checkout',
|
||||
'git sparse-checkout init --cone',
|
||||
"git sparse-checkout set {$fileList->implode(' ')}",
|
||||
'git read-tree -mu HEAD',
|
||||
@@ -2346,7 +2402,7 @@ class Application extends BaseModel
|
||||
'config.build_pack' => 'required|string',
|
||||
'config.base_directory' => 'required|string',
|
||||
'config.publish_directory' => 'required|string',
|
||||
'config.ports_exposes' => 'required|string',
|
||||
'config.ports_exposes' => 'nullable|string',
|
||||
'config.settings.is_static' => 'required|boolean',
|
||||
]);
|
||||
if ($deepValidator->fails()) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\EncryptedArrayCast;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -74,11 +75,24 @@ class ApplicationDeploymentQueue extends Model
|
||||
'finished_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The configuration snapshot/diff hold full (decrypted on read) configuration,
|
||||
* including unlocked environment variable values. They are only meant for the
|
||||
* in-app diff modal (which redacts per role) and must never be serialized by the
|
||||
* API, so hide them globally as defense in depth.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'configuration_snapshot',
|
||||
'configuration_diff',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pull_request_id' => 'integer',
|
||||
'finished_at' => 'datetime',
|
||||
'configuration_snapshot' => 'array',
|
||||
'configuration_diff' => 'array',
|
||||
'configuration_snapshot' => EncryptedArrayCast::class,
|
||||
'configuration_diff' => EncryptedArrayCast::class,
|
||||
];
|
||||
|
||||
public function application()
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Livewire\Server\Proxy;
|
||||
use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use App\Services\ConfigurationRepository;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
@@ -945,10 +946,10 @@ $schema://$host {
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function ($value) {
|
||||
return preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
|
||||
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
|
||||
},
|
||||
set: function ($value) {
|
||||
return preg_replace('/[^A-Za-z0-9\-_]/', '', $value);
|
||||
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use App\Jobs\ConnectProxyToNetworksJob;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class StandaloneDocker extends BaseModel
|
||||
@@ -127,7 +128,7 @@ class StandaloneDocker extends BaseModel
|
||||
return $this->morphMany(Service::class, 'destination');
|
||||
}
|
||||
|
||||
public function databases()
|
||||
public function databases(): Collection
|
||||
{
|
||||
$postgresqls = $this->postgresqls;
|
||||
$redis = $this->redis;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Notifications\CustomEmailNotification;
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Notifications\Dto\PushoverMessage;
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class RestartLimitReached extends CustomEmailNotification
|
||||
{
|
||||
public string $resource_name;
|
||||
|
||||
public string $project_uuid;
|
||||
|
||||
public string $environment_uuid;
|
||||
|
||||
public string $environment_name;
|
||||
|
||||
public ?string $resource_url = null;
|
||||
|
||||
public ?string $fqdn;
|
||||
|
||||
public int $restart_count;
|
||||
|
||||
public int $max_restart_count;
|
||||
|
||||
public function __construct(public Application $resource)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
$this->afterCommit();
|
||||
$this->resource_name = data_get($resource, 'name');
|
||||
$this->project_uuid = data_get($resource, 'environment.project.uuid');
|
||||
$this->environment_uuid = data_get($resource, 'environment.uuid');
|
||||
$this->environment_name = data_get($resource, 'environment.name');
|
||||
$this->fqdn = data_get($resource, 'fqdn', null);
|
||||
$this->restart_count = $resource->restart_count;
|
||||
$this->max_restart_count = $resource->max_restart_count;
|
||||
if (str($this->fqdn)->explode(',')->count() > 1) {
|
||||
$this->fqdn = str($this->fqdn)->explode(',')->first();
|
||||
}
|
||||
$this->resource_url = $this->resource->link() ?? base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->resource->uuid}";
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return $notifiable->getEnabledChannels('status_change');
|
||||
}
|
||||
|
||||
public function toMail(): MailMessage
|
||||
{
|
||||
$mail = new MailMessage;
|
||||
$mail->subject("Coolify: {$this->resource_name} stopped - restart limit reached ({$this->restart_count}/{$this->max_restart_count})");
|
||||
$mail->view('emails.application-restart-limit-reached', [
|
||||
'name' => $this->resource_name,
|
||||
'fqdn' => $this->fqdn,
|
||||
'resource_url' => $this->resource_url,
|
||||
'restart_count' => $this->restart_count,
|
||||
'max_restart_count' => $this->max_restart_count,
|
||||
]);
|
||||
|
||||
return $mail;
|
||||
}
|
||||
|
||||
public function toDiscord(): DiscordMessage
|
||||
{
|
||||
return new DiscordMessage(
|
||||
title: ':warning: Restart limit reached',
|
||||
description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Application in Coolify]({$this->resource_url})",
|
||||
color: DiscordMessage::errorColor(),
|
||||
isCritical: true,
|
||||
);
|
||||
}
|
||||
|
||||
public function toTelegram(): array
|
||||
{
|
||||
$message = "Coolify: {$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).";
|
||||
|
||||
return [
|
||||
'message' => $message,
|
||||
'buttons' => [
|
||||
[
|
||||
'text' => 'Open Application in Coolify',
|
||||
'url' => $this->resource_url,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function toPushover(): PushoverMessage
|
||||
{
|
||||
$message = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).";
|
||||
|
||||
return new PushoverMessage(
|
||||
title: 'Restart limit reached',
|
||||
level: 'error',
|
||||
message: $message,
|
||||
buttons: [
|
||||
[
|
||||
'text' => 'Open Application in Coolify',
|
||||
'url' => $this->resource_url,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function toSlack(): SlackMessage
|
||||
{
|
||||
$title = 'Restart limit reached';
|
||||
$description = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})";
|
||||
|
||||
$description .= "\n\n*Project:* ".data_get($this->resource, 'environment.project.name');
|
||||
$description .= "\n*Environment:* {$this->environment_name}";
|
||||
$description .= "\n*Application URL:* {$this->resource_url}";
|
||||
|
||||
return new SlackMessage(
|
||||
title: $title,
|
||||
description: $description,
|
||||
color: SlackMessage::errorColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Restart limit reached',
|
||||
'event' => 'restart_limit_reached',
|
||||
'application_name' => $this->resource_name,
|
||||
'application_uuid' => $this->resource->uuid,
|
||||
'restart_count' => $this->restart_count,
|
||||
'max_restart_count' => $this->max_restart_count,
|
||||
'url' => $this->resource_url,
|
||||
'project' => data_get($this->resource, 'environment.project.name'),
|
||||
'environment' => $this->environment_name,
|
||||
'fqdn' => $this->fqdn,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ApplicationConfigurationSnapshot
|
||||
{
|
||||
use SummarizesDiffText;
|
||||
|
||||
public const SCHEMA_VERSION = 1;
|
||||
|
||||
public function __construct(protected Application $application) {}
|
||||
@@ -115,12 +118,14 @@ class ApplicationConfigurationSnapshot
|
||||
$this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'),
|
||||
$this->item('install_command', 'Install command', $this->application->install_command, 'build'),
|
||||
$this->item('build_command', 'Build command', $this->application->build_command, 'build'),
|
||||
$this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile)),
|
||||
$this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile), displayFull: $this->application->dockerfile),
|
||||
$this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'),
|
||||
$this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'),
|
||||
$this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'),
|
||||
$this->item('docker_compose', 'Docker Compose', $this->application->docker_compose, 'build', displayValue: $this->summarizeText($this->application->docker_compose)),
|
||||
$this->item('docker_compose_raw', 'Raw Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw)),
|
||||
// The generated docker_compose is intentionally excluded: it is re-rendered
|
||||
// from git on every parse (resolved env, generated labels, deployment context),
|
||||
// so comparing it would flag a permanent change for git-based compose apps.
|
||||
$this->item('docker_compose_raw', 'Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw), displayFull: $this->application->docker_compose_raw, diffMode: 'lines'),
|
||||
$this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'),
|
||||
$this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'),
|
||||
$this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'),
|
||||
@@ -162,9 +167,10 @@ class ApplicationConfigurationSnapshot
|
||||
{
|
||||
return [
|
||||
$this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'),
|
||||
$this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'),
|
||||
$this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'),
|
||||
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->application->custom_labels)),
|
||||
$this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration)),
|
||||
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'),
|
||||
$this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration),
|
||||
$this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'),
|
||||
$this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'),
|
||||
$this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'),
|
||||
@@ -234,6 +240,7 @@ class ApplicationConfigurationSnapshot
|
||||
private function environmentItem(EnvironmentVariable $environmentVariable): array
|
||||
{
|
||||
$impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy';
|
||||
$locked = (bool) $environmentVariable->is_shown_once;
|
||||
$compareValue = [
|
||||
'value_hash' => $this->sensitiveHash($environmentVariable->value),
|
||||
'is_multiline' => $environmentVariable->is_multiline,
|
||||
@@ -242,20 +249,62 @@ class ApplicationConfigurationSnapshot
|
||||
'is_runtime' => $environmentVariable->is_runtime,
|
||||
];
|
||||
|
||||
// Locked (is_shown_once) variables are always redacted and never store a value.
|
||||
if ($locked) {
|
||||
return $this->item(
|
||||
key: (string) $environmentVariable->key,
|
||||
label: (string) $environmentVariable->key,
|
||||
value: $compareValue,
|
||||
impact: $impact,
|
||||
sensitive: true,
|
||||
displayValue: $this->environmentDisplayValue($environmentVariable),
|
||||
);
|
||||
}
|
||||
|
||||
// Unlocked variables expose their value so owners/admins can see the change.
|
||||
// The compare value is pre-hashed (identical formula to the locked branch) so
|
||||
// change detection stays stable and never carries the raw value; members are
|
||||
// redacted at render time in ConfigurationChecker; the column is encrypted at rest.
|
||||
// The value and each scope flag are rendered as their own line and diffed by line,
|
||||
// so a change to one or more attributes shows exactly what changed (one line each).
|
||||
$value = (string) $environmentVariable->value;
|
||||
|
||||
return $this->item(
|
||||
key: (string) $environmentVariable->key,
|
||||
label: (string) $environmentVariable->key,
|
||||
value: $compareValue,
|
||||
value: $this->sensitiveHash($this->normalizeValue($compareValue)),
|
||||
impact: $impact,
|
||||
sensitive: true,
|
||||
displayValue: $this->environmentDisplayValue($environmentVariable),
|
||||
sensitive: false,
|
||||
displayValue: $this->summarizeText($value),
|
||||
displayFull: $this->environmentLines($environmentVariable),
|
||||
diffMode: 'lines',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One line per attribute so the line diff surfaces exactly which value/flags changed.
|
||||
*/
|
||||
private function environmentLines(EnvironmentVariable $environmentVariable): string
|
||||
{
|
||||
$lines = collect();
|
||||
|
||||
$value = (string) $environmentVariable->value;
|
||||
if (filled($value)) {
|
||||
$lines->push($value);
|
||||
}
|
||||
|
||||
$lines->push('Available at build: '.($environmentVariable->is_buildtime ? 'enabled' : 'disabled'));
|
||||
$lines->push('Available at runtime: '.($environmentVariable->is_runtime ? 'enabled' : 'disabled'));
|
||||
$lines->push('Multiline: '.($environmentVariable->is_multiline ? 'enabled' : 'disabled'));
|
||||
$lines->push('Literal: '.($environmentVariable->is_literal ? 'enabled' : 'disabled'));
|
||||
|
||||
return $lines->implode("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null): array
|
||||
private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null, ?string $displayFull = null, string $diffMode = 'default'): array
|
||||
{
|
||||
$normalizedValue = $this->normalizeValue($value);
|
||||
|
||||
@@ -264,21 +313,28 @@ class ApplicationConfigurationSnapshot
|
||||
'label' => $label,
|
||||
'impact' => $impact,
|
||||
'sensitive' => $sensitive,
|
||||
'diff_mode' => $diffMode,
|
||||
'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue,
|
||||
'display_value' => $displayValue ?? $this->displayValue($normalizedValue),
|
||||
'display_full' => $sensitive ? null : $this->expandableText($displayFull ?? $this->stringifyValue($normalizedValue)),
|
||||
];
|
||||
}
|
||||
|
||||
private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string
|
||||
{
|
||||
$flags = collect([
|
||||
$flags = $this->environmentFlags($environmentVariable);
|
||||
|
||||
return $flags ? "Hidden ({$flags})" : 'Hidden';
|
||||
}
|
||||
|
||||
private function environmentFlags(EnvironmentVariable $environmentVariable): string
|
||||
{
|
||||
return collect([
|
||||
$environmentVariable->is_buildtime ? 'build-time' : null,
|
||||
$environmentVariable->is_runtime ? 'runtime' : null,
|
||||
$environmentVariable->is_multiline ? 'multiline' : null,
|
||||
$environmentVariable->is_literal ? 'literal' : null,
|
||||
])->filter()->implode(', ');
|
||||
|
||||
return $flags ? "Hidden ({$flags})" : 'Hidden';
|
||||
}
|
||||
|
||||
private function sensitiveHash(mixed $value): string
|
||||
@@ -320,6 +376,58 @@ class ApplicationConfigurationSnapshot
|
||||
return $this->summarizeText((string) $value);
|
||||
}
|
||||
|
||||
private function stringifyValue(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || is_bool($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function decodedComposeDomains(): ?array
|
||||
{
|
||||
if (blank($this->application->docker_compose_domains)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $this->application->docker_compose_domains, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function composeDomainsText(): ?string
|
||||
{
|
||||
$decoded = $this->decodedComposeDomains();
|
||||
|
||||
if (blank($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return collect($decoded)
|
||||
->map(fn ($value, $service): string => $service.': '.(filled(data_get($value, 'domain')) ? data_get($value, 'domain') : '-'))
|
||||
->sort()
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
private function decodeCustomLabels(?string $value): ?string
|
||||
{
|
||||
if (blank($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = base64_decode($value, true);
|
||||
|
||||
return $decoded === false ? $value : $decoded;
|
||||
}
|
||||
|
||||
private function summarizeText(?string $value): string
|
||||
{
|
||||
if (blank($value)) {
|
||||
@@ -333,6 +441,6 @@ class ApplicationConfigurationSnapshot
|
||||
return str($value)->limit(80)." ({$lines} lines)";
|
||||
}
|
||||
|
||||
return str($value)->limit(120)->value();
|
||||
return str($value)->limit(self::SINGLE_LINE_LIMIT)->value();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\DeploymentConfiguration\Concerns;
|
||||
|
||||
trait SummarizesDiffText
|
||||
{
|
||||
/**
|
||||
* Maximum length of a single-line value before it is truncated/considered
|
||||
* worth expanding. Kept as one constant so the snapshot summary and the
|
||||
* differ's expand decision never drift apart.
|
||||
*/
|
||||
private const SINGLE_LINE_LIMIT = 120;
|
||||
|
||||
/**
|
||||
* Returns the value only when it is worth expanding (multi-line or longer
|
||||
* than the single-line truncation limit). Otherwise null.
|
||||
*/
|
||||
private function expandableText(?string $value): ?string
|
||||
{
|
||||
if (blank($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
if (str_contains($value, "\n") || mb_strlen($value) > self::SINGLE_LINE_LIMIT) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ConfigurationDiff
|
||||
{
|
||||
/**
|
||||
@@ -81,20 +79,6 @@ class ConfigurationDiff
|
||||
return $this->changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{label: string, changes: array<int, array<string, mixed>>}>
|
||||
*/
|
||||
public function groupedChanges(): array
|
||||
{
|
||||
return collect($this->changes)
|
||||
->groupBy('section')
|
||||
->map(fn (Collection $changes): array => [
|
||||
'label' => (string) data_get($changes->first(), 'section_label', str((string) $changes->keys()->first())->headline()),
|
||||
'changes' => $changes->values()->all(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{changed: bool, count: int, requires_build: bool, requires_redeploy: bool, legacy_fallback: bool, changes: array<int, array<string, mixed>>}
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,21 @@
|
||||
|
||||
namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText;
|
||||
|
||||
class ConfigurationDiffer
|
||||
{
|
||||
use SummarizesDiffText;
|
||||
|
||||
/**
|
||||
* Keys that must never be reported as changes. The generated docker_compose
|
||||
* is re-rendered from git on every parse, so legacy snapshots that still
|
||||
* contain it would otherwise flag a permanent change after it was dropped.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const IGNORED_KEYS = ['build.docker_compose'];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $previousSnapshot
|
||||
* @param array<string, mixed> $currentSnapshot
|
||||
@@ -16,6 +29,10 @@ class ConfigurationDiffer
|
||||
$changes = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (in_array($key, self::IGNORED_KEYS, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$previous = $previousItems[$key] ?? null;
|
||||
$current = $currentItems[$key] ?? null;
|
||||
|
||||
@@ -27,6 +44,37 @@ class ConfigurationDiffer
|
||||
$sensitive = (bool) data_get($item, 'sensitive', false);
|
||||
$type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed');
|
||||
$displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null;
|
||||
$diffMode = data_get($item, 'diff_mode', 'default');
|
||||
|
||||
$oldFull = null;
|
||||
$newFull = null;
|
||||
|
||||
if ($sensitive) {
|
||||
$oldDisplay = $previous === null ? '-' : '••••••••';
|
||||
$newDisplay = $current === null ? '-' : '••••••••';
|
||||
} elseif ($diffMode === 'lines' && $type === 'changed') {
|
||||
[$oldDisplay, $newDisplay] = $this->changedLines(
|
||||
data_get($previous, 'display_full'),
|
||||
data_get($current, 'display_full'),
|
||||
);
|
||||
|
||||
// No line-level difference (e.g. only reordering) — fall back to the summary.
|
||||
if ($oldDisplay === '-' && $newDisplay === '-') {
|
||||
$oldDisplay = data_get($previous, 'display_value', '-');
|
||||
$newDisplay = data_get($current, 'display_value', '-');
|
||||
}
|
||||
|
||||
// Expansion reveals the full changed lines, not the entire value.
|
||||
$oldFull = $this->expandableText($oldDisplay);
|
||||
$newFull = $this->expandableText($newDisplay);
|
||||
} else {
|
||||
$oldDisplay = data_get($previous, 'display_value', '-');
|
||||
$newDisplay = data_get($current, 'display_value', '-');
|
||||
$oldFull = data_get($previous, 'display_full');
|
||||
$newFull = data_get($current, 'display_full');
|
||||
}
|
||||
|
||||
$expandable = ! $sensitive && (filled($oldFull) || filled($newFull));
|
||||
|
||||
$changes[] = [
|
||||
'key' => $key,
|
||||
@@ -37,14 +85,54 @@ class ConfigurationDiffer
|
||||
'impact' => data_get($item, 'impact', 'redeploy'),
|
||||
'sensitive' => $sensitive,
|
||||
'display_summary' => $displaySummary,
|
||||
'old_display_value' => $sensitive ? ($previous === null ? '-' : '••••••••') : data_get($previous, 'display_value', '-'),
|
||||
'new_display_value' => $sensitive ? ($current === null ? '-' : '••••••••') : data_get($current, 'display_value', '-'),
|
||||
'old_display_value' => $oldDisplay,
|
||||
'new_display_value' => $newDisplay,
|
||||
'old_full_value' => $oldFull,
|
||||
'new_full_value' => $newFull,
|
||||
'expandable' => $expandable,
|
||||
];
|
||||
}
|
||||
|
||||
return ConfigurationDiff::fromChanges($changes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce two multi-line values to only the lines that differ, so the modal
|
||||
* shows just the changed container labels instead of the whole block.
|
||||
*
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function changedLines(?string $old, ?string $new): array
|
||||
{
|
||||
$oldLines = $this->textLines($old);
|
||||
$newLines = $this->textLines($new);
|
||||
|
||||
$removed = array_values(array_diff($oldLines, $newLines));
|
||||
$added = array_values(array_diff($newLines, $oldLines));
|
||||
|
||||
return [
|
||||
$removed === [] ? '-' : implode("\n", $removed),
|
||||
$added === [] ? '-' : implode("\n", $added),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function textLines(?string $value): array
|
||||
{
|
||||
if (blank($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Keep leading indentation (meaningful for YAML/compose), drop trailing whitespace.
|
||||
return collect(preg_split('/\r\n|\r|\n/', (string) $value))
|
||||
->map(fn (string $line): string => rtrim($line))
|
||||
->filter(fn (string $line): bool => trim($line) !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, array<string, mixed>>
|
||||
|
||||
@@ -35,6 +35,17 @@ class ValidationPatterns
|
||||
*/
|
||||
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
|
||||
|
||||
/**
|
||||
* Pattern for SSH usernames.
|
||||
* Allows alphanumeric characters, dots, hyphens, and underscores.
|
||||
*/
|
||||
public const SERVER_USERNAME_PATTERN = '/^[a-zA-Z0-9._-]+$/';
|
||||
|
||||
/**
|
||||
* Pattern for removing characters not allowed in SSH usernames.
|
||||
*/
|
||||
public const INVALID_SERVER_USERNAME_CHARACTERS_PATTERN = '/[^A-Za-z0-9.\-_]/';
|
||||
|
||||
/**
|
||||
* Token-aware pattern for shell-safe command strings (docker compose commands, docker run options).
|
||||
*
|
||||
@@ -283,6 +294,28 @@ class ValidationPatterns
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation rules for SSH username fields.
|
||||
*/
|
||||
public static function serverUsernameRules(bool $required = true): array
|
||||
{
|
||||
return [
|
||||
$required ? 'required' : 'nullable',
|
||||
'string',
|
||||
'regex:'.self::SERVER_USERNAME_PATTERN,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation messages for SSH username fields.
|
||||
*/
|
||||
public static function serverUsernameMessages(string $field = 'user', string $label = 'User'): array
|
||||
{
|
||||
return [
|
||||
"{$field}.regex" => "The {$label} may only contain letters, numbers, dots, hyphens, and underscores.",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation messages for database identifier fields.
|
||||
*/
|
||||
|
||||
@@ -40,6 +40,7 @@ trait SshRetryable
|
||||
'Remote host closed connection',
|
||||
'Authentication failed',
|
||||
'Too many authentication failures',
|
||||
'SSH command failed with exit code: 255',
|
||||
];
|
||||
|
||||
$lowerErrorOutput = strtolower($errorOutput);
|
||||
|
||||
@@ -86,7 +86,7 @@ function format_docker_command_output_to_json($rawOutput): Collection
|
||||
return $outputLines
|
||||
->reject(fn ($line) => empty($line))
|
||||
->map(fn ($outputLine) => json_decode($outputLine, true, flags: JSON_THROW_ON_ERROR));
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return collect([]);
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ function format_docker_envs_to_json($rawOutput)
|
||||
|
||||
return [$env[0] => $env[1]];
|
||||
});
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return collect([]);
|
||||
}
|
||||
}
|
||||
@@ -255,12 +255,12 @@ function defaultLabels($id, $name, string $projectName, string $resourceName, st
|
||||
|
||||
function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
|
||||
{
|
||||
if ($resource->getMorphClass() === \App\Models\ServiceApplication::class) {
|
||||
if ($resource->getMorphClass() === ServiceApplication::class) {
|
||||
$uuid = data_get($resource, 'uuid');
|
||||
$server = data_get($resource, 'service.server');
|
||||
$environment_variables = data_get($resource, 'service.environment_variables');
|
||||
$type = $resource->serviceType();
|
||||
} elseif ($resource->getMorphClass() === \App\Models\Application::class) {
|
||||
} elseif ($resource->getMorphClass() === Application::class) {
|
||||
$uuid = data_get($resource, 'uuid');
|
||||
$server = data_get($resource, 'destination.server');
|
||||
$environment_variables = data_get($resource, 'environment_variables');
|
||||
@@ -641,7 +641,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -1000,6 +1000,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
|
||||
'--ulimit',
|
||||
'--device',
|
||||
'--shm-size',
|
||||
'--dns',
|
||||
]);
|
||||
$mapping = collect([
|
||||
'--cap-add' => 'cap_add',
|
||||
@@ -1013,6 +1014,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
|
||||
'--ip' => 'ip',
|
||||
'--ip6' => 'ip6',
|
||||
'--shm-size' => 'shm_size',
|
||||
'--dns' => 'dns',
|
||||
'--gpus' => 'gpus',
|
||||
'--hostname' => 'hostname',
|
||||
'--entrypoint' => 'entrypoint',
|
||||
@@ -1219,7 +1221,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
|
||||
$server = Server::ownedByCurrentTeam()->find($server_id);
|
||||
try {
|
||||
if (! $server) {
|
||||
throw new \Exception('Server not found');
|
||||
throw new Exception('Server not found');
|
||||
}
|
||||
$yaml_compose = Yaml::parse($compose);
|
||||
|
||||
@@ -1235,7 +1237,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
|
||||
], $server);
|
||||
|
||||
return 'OK';
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
return $e->getMessage();
|
||||
} finally {
|
||||
if (filled($server)) {
|
||||
@@ -1351,10 +1353,10 @@ function escapeBashDoubleQuoted(?string $value): string
|
||||
* Generate Docker build arguments from environment variables collection
|
||||
* Returns only keys (no values) since values are sourced from environment via export
|
||||
*
|
||||
* @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
||||
* @return \Illuminate\Support\Collection Collection of formatted --build-arg strings (keys only)
|
||||
* @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
||||
* @return Collection Collection of formatted --build-arg strings (keys only)
|
||||
*/
|
||||
function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
|
||||
function generateDockerBuildArgs($variables): Collection
|
||||
{
|
||||
$variables = collect($variables);
|
||||
|
||||
@@ -1369,7 +1371,7 @@ function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
|
||||
/**
|
||||
* Generate Docker environment flags from environment variables collection
|
||||
*
|
||||
* @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
||||
* @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
||||
* @return string Space-separated environment flags
|
||||
*/
|
||||
function generateDockerEnvFlags($variables): string
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Lcobucci\JWT\Encoding\ChainedFormatter;
|
||||
@@ -20,7 +21,7 @@ function generateGithubToken(GithubApp $source, string $type)
|
||||
$timeDiff = abs($serverTime->diffInSeconds($githubTime));
|
||||
|
||||
if ($timeDiff > 50) {
|
||||
throw new \Exception(
|
||||
throw new Exception(
|
||||
'System time is out of sync with GitHub API time:<br>'.
|
||||
'- System time: '.$serverTime->format('Y-m-d H:i:s').' UTC<br>'.
|
||||
'- GitHub time: '.$githubTime->format('Y-m-d H:i:s').' UTC<br>'.
|
||||
@@ -60,7 +61,7 @@ function generateGithubToken(GithubApp $source, string $type)
|
||||
|
||||
return $response->json()['token'];
|
||||
})(),
|
||||
default => throw new \InvalidArgumentException("Unsupported token type: {$type}")
|
||||
default => throw new InvalidArgumentException("Unsupported token type: {$type}")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,11 +78,11 @@ function generateGithubJwt(GithubApp $source)
|
||||
function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
|
||||
{
|
||||
if (is_null($source)) {
|
||||
throw new \Exception('Source is required for API calls');
|
||||
throw new Exception('Source is required for API calls');
|
||||
}
|
||||
|
||||
if ($source->getMorphClass() !== GithubApp::class) {
|
||||
throw new \InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}");
|
||||
throw new InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}");
|
||||
}
|
||||
|
||||
if ($source->is_public) {
|
||||
@@ -100,7 +101,7 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
|
||||
$errorMessage = data_get($response->json(), 'message', 'no error message found');
|
||||
$remainingCalls = $response->header('X-RateLimit-Remaining', '0');
|
||||
|
||||
throw new \Exception(
|
||||
throw new Exception(
|
||||
'GitHub API call failed:<br>'.
|
||||
"Error: {$errorMessage}<br>".
|
||||
'Rate Limit Status:<br>'.
|
||||
@@ -116,13 +117,19 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
|
||||
];
|
||||
}
|
||||
|
||||
function getInstallationPath(GithubApp $source)
|
||||
function getInstallationPath(GithubApp $source): string
|
||||
{
|
||||
$github = GithubApp::where('uuid', $source->uuid)->first();
|
||||
$name = str(Str::kebab($github->name));
|
||||
$installation_path = $github->html_url === 'https://github.com' ? 'apps' : 'github-apps';
|
||||
$name = str(Str::kebab($source->name));
|
||||
$installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps';
|
||||
$state = Str::random(64);
|
||||
|
||||
return "$github->html_url/$installation_path/$name/installations/new";
|
||||
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
|
||||
'action' => 'install',
|
||||
'github_app_id' => $source->id,
|
||||
'team_id' => $source->team_id,
|
||||
], now()->addMinutes(60));
|
||||
|
||||
return "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]);
|
||||
}
|
||||
|
||||
function getPermissionsPath(GithubApp $source)
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
@@ -137,7 +138,7 @@ function connectProxyToNetworks(Server $server)
|
||||
* This must be called BEFORE docker compose up since the compose file declares networks as external.
|
||||
*
|
||||
* @param Server $server The server to ensure networks on
|
||||
* @return \Illuminate\Support\Collection Commands to create networks if they don't exist
|
||||
* @return Collection Commands to create networks if they don't exist
|
||||
*/
|
||||
function ensureProxyNetworksExist(Server $server)
|
||||
{
|
||||
@@ -215,7 +216,7 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar
|
||||
$custom_commands[] = $command;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
// If we can't parse the config, return empty array
|
||||
// Silently fail to avoid breaking the proxy regeneration
|
||||
}
|
||||
@@ -436,7 +437,7 @@ function getExactTraefikVersionFromContainer(Server $server): ?string
|
||||
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Could not detect exact version");
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
Log::error("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
|
||||
|
||||
return null;
|
||||
@@ -483,7 +484,7 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string
|
||||
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Image format doesn't match expected pattern: {$image}");
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
Log::error("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
|
||||
|
||||
return null;
|
||||
|
||||
@@ -200,6 +200,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
||||
}
|
||||
$application = Application::find(data_get($application_deployment_queue, 'application_id'));
|
||||
$is_debug_enabled = data_get($application, 'settings.is_debug_enabled');
|
||||
$serverTimezone = getServerTimezone(data_get($application, 'destination.server'));
|
||||
|
||||
// Members should never see debug logs, even if an admin enabled debug mode
|
||||
if ($is_debug_enabled && auth()->check() && auth()->user()->isMember()) {
|
||||
@@ -245,8 +246,14 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
||||
|
||||
return $formatted
|
||||
->sortBy(fn ($i) => data_get($i, 'order'))
|
||||
->map(function ($i) {
|
||||
data_set($i, 'timestamp', Carbon::parse(data_get($i, 'timestamp'))->format('Y-M-d H:i:s.u'));
|
||||
->map(function ($i) use ($serverTimezone) {
|
||||
$timestamp = Carbon::parse(data_get($i, 'timestamp'));
|
||||
try {
|
||||
$timestamp->setTimezone($serverTimezone);
|
||||
} catch (Exception) {
|
||||
$timestamp->setTimezone('UTC');
|
||||
}
|
||||
data_set($i, 'timestamp', $timestamp->format('Y-M-d H:i:s.u'));
|
||||
|
||||
return $i;
|
||||
})
|
||||
|
||||
@@ -1865,15 +1865,15 @@ function isBase64Encoded($strValue)
|
||||
{
|
||||
return base64_encode(base64_decode($strValue, true)) === $strValue;
|
||||
}
|
||||
function customApiValidator(Collection|array $item, array $rules)
|
||||
function customApiValidator(Collection|array $item, array $rules, array $messages = [])
|
||||
{
|
||||
if (is_array($item)) {
|
||||
$item = collect($item);
|
||||
}
|
||||
|
||||
return Validator::make($item->toArray(), $rules, [
|
||||
return Validator::make($item->toArray(), $rules, array_merge([
|
||||
'required' => 'This field is required.',
|
||||
]);
|
||||
], $messages));
|
||||
}
|
||||
function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null)
|
||||
{
|
||||
|
||||
@@ -68,6 +68,13 @@ return [
|
||||
'ssh' => [
|
||||
'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)),
|
||||
'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600),
|
||||
'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true),
|
||||
'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5),
|
||||
'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes
|
||||
'mux_lock_ttl' => env('SSH_MUX_LOCK_TTL', 30), // lock auto-release, seconds
|
||||
'mux_lock_timeout' => env('SSH_MUX_LOCK_TIMEOUT', 10), // max wait for lock, seconds
|
||||
'mux_orphan_min_age' => env('SSH_MUX_ORPHAN_MIN_AGE', 600), // min process age before reaping orphans, seconds
|
||||
'mux_orphan_reap_enabled' => env('SSH_MUX_ORPHAN_REAP_ENABLED', false), // false = dry-run, only log orphans
|
||||
'connection_timeout' => 10,
|
||||
'server_interval' => 20,
|
||||
'command_timeout' => 3600,
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Stevebauman\Purify\Cache\CacheDefinitionCache;
|
||||
use Stevebauman\Purify\Definitions\Html5Definition;
|
||||
|
||||
return [
|
||||
@@ -114,7 +115,7 @@ return [
|
||||
|
||||
'serializer' => [
|
||||
'driver' => env('CACHE_STORE', env('CACHE_DRIVER', 'file')),
|
||||
'cache' => \Stevebauman\Purify\Cache\CacheDefinitionCache::class,
|
||||
'cache' => CacheDefinitionCache::class,
|
||||
],
|
||||
|
||||
// 'serializer' => [
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->string('ports_exposes')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->string('ports_exposes')->nullable(false)->default('')->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $blueprint) {
|
||||
$blueprint->integer('max_restart_count')->default(10)->after('restart_count');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $blueprint) {
|
||||
$blueprint->dropColumn('max_restart_count');
|
||||
});
|
||||
}
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* The configuration snapshot/diff now store an encrypted blob (not valid
|
||||
* JSON), so the columns must hold arbitrary text instead of json.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text');
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json');
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
|
||||
}
|
||||
};
|
||||
@@ -35,7 +35,7 @@ class SharedEnvironmentVariableSeeder extends Seeder
|
||||
]);
|
||||
|
||||
// Add predefined server variables to all existing servers
|
||||
$servers = \App\Models\Server::all();
|
||||
$servers = Server::all();
|
||||
foreach ($servers as $server) {
|
||||
SharedEnvironmentVariable::firstOrCreate([
|
||||
'key' => 'COOLIFY_SERVER_UUID',
|
||||
|
||||
@@ -20,9 +20,22 @@ ENV PATH="/host/usr/local/sbin:/host/usr/local/bin:/host/usr/sbin:/host/usr/bin:
|
||||
|
||||
RUN apt update && apt -y install openssh-client openssh-server curl wget git jq jc
|
||||
RUN mkdir -p ~/.docker/cli-plugins
|
||||
RUN curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx
|
||||
RUN curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose
|
||||
RUN (curl -sSL https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker)
|
||||
|
||||
# Download architecture-matched Docker CLI, buildx, and compose binaries.
|
||||
# This image is published as a multi-arch manifest (amd64 + arm64), so the
|
||||
# downloaded binaries must match TARGETPLATFORM or they fail with "exec format error"
|
||||
# when the container runs on the other architecture.
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx && \
|
||||
curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose && \
|
||||
(curl -sSL https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker); \
|
||||
elif [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-arm64 -o ~/.docker/cli-plugins/docker-buildx && \
|
||||
curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-aarch64 -o ~/.docker/cli-plugins/docker-compose && \
|
||||
(curl -sSL https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker); \
|
||||
else \
|
||||
echo "Unsupported TARGETPLATFORM: ${TARGETPLATFORM}" && exit 1; \
|
||||
fi
|
||||
RUN chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /root/.docker/cli-plugins/docker-buildx
|
||||
|
||||
|
||||
|
||||
+4
-8
@@ -79,8 +79,7 @@
|
||||
"environment_uuid",
|
||||
"git_repository",
|
||||
"git_branch",
|
||||
"build_pack",
|
||||
"ports_exposes"
|
||||
"build_pack"
|
||||
],
|
||||
"properties": {
|
||||
"project_uuid": {
|
||||
@@ -526,8 +525,7 @@
|
||||
"github_app_uuid",
|
||||
"git_repository",
|
||||
"git_branch",
|
||||
"build_pack",
|
||||
"ports_exposes"
|
||||
"build_pack"
|
||||
],
|
||||
"properties": {
|
||||
"project_uuid": {
|
||||
@@ -977,8 +975,7 @@
|
||||
"private_key_uuid",
|
||||
"git_repository",
|
||||
"git_branch",
|
||||
"build_pack",
|
||||
"ports_exposes"
|
||||
"build_pack"
|
||||
],
|
||||
"properties": {
|
||||
"project_uuid": {
|
||||
@@ -1775,8 +1772,7 @@
|
||||
"server_uuid",
|
||||
"environment_name",
|
||||
"environment_uuid",
|
||||
"docker_registry_image_name",
|
||||
"ports_exposes"
|
||||
"docker_registry_image_name"
|
||||
],
|
||||
"properties": {
|
||||
"project_uuid": {
|
||||
|
||||
@@ -59,7 +59,6 @@ paths:
|
||||
- git_repository
|
||||
- git_branch
|
||||
- build_pack
|
||||
- ports_exposes
|
||||
properties:
|
||||
project_uuid:
|
||||
type: string
|
||||
@@ -344,7 +343,6 @@ paths:
|
||||
- git_repository
|
||||
- git_branch
|
||||
- build_pack
|
||||
- ports_exposes
|
||||
properties:
|
||||
project_uuid:
|
||||
type: string
|
||||
@@ -632,7 +630,6 @@ paths:
|
||||
- git_repository
|
||||
- git_branch
|
||||
- build_pack
|
||||
- ports_exposes
|
||||
properties:
|
||||
project_uuid:
|
||||
type: string
|
||||
@@ -1141,7 +1138,6 @@ paths:
|
||||
- environment_name
|
||||
- environment_uuid
|
||||
- docker_registry_image_name
|
||||
- ports_exposes
|
||||
properties:
|
||||
project_uuid:
|
||||
type: string
|
||||
|
||||
@@ -781,10 +781,12 @@ curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production &
|
||||
PID3=$!
|
||||
curl -fsSL -L $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh &
|
||||
PID4=$!
|
||||
curl -fsSL -L $CDN/upgrade-postgres.sh -o /data/coolify/source/upgrade-postgres.sh &
|
||||
PID5=$!
|
||||
|
||||
# Wait for all downloads to complete and check for errors
|
||||
DOWNLOAD_FAILED=false
|
||||
for PID in $PID1 $PID2 $PID3 $PID4; do
|
||||
for PID in $PID1 $PID2 $PID3 $PID4 $PID5; do
|
||||
if ! wait $PID; then
|
||||
DOWNLOAD_FAILED=true
|
||||
fi
|
||||
@@ -795,6 +797,7 @@ if [ "$DOWNLOAD_FAILED" = true ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x /data/coolify/source/upgrade.sh /data/coolify/source/upgrade-postgres.sh
|
||||
log "All configuration files downloaded successfully"
|
||||
echo " Done."
|
||||
|
||||
|
||||
Executable
+405
@@ -0,0 +1,405 @@
|
||||
#!/bin/bash
|
||||
## Explicit Coolify internal PostgreSQL major-version migrator.
|
||||
## This script is intentionally not run by upgrade.sh automatically.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
SOURCE_DIR="/data/coolify/source"
|
||||
ENV_FILE="${SOURCE_DIR}/.env"
|
||||
BACKUP_DIR="/data/coolify/backups/internal-postgres"
|
||||
OVERRIDE_FILE="${SOURCE_DIR}/docker-compose.postgres-upgrade.yml"
|
||||
ROLLBACK_FILE="${SOURCE_DIR}/postgres-upgrade-rollback.env"
|
||||
DATE=$(date +%Y-%m-%d-%H-%M-%S)
|
||||
LOGFILE="${SOURCE_DIR}/postgres-upgrade-${DATE}.log"
|
||||
COMMAND="${1:-upgrade}"
|
||||
TARGET_MAJOR="${1:-18}"
|
||||
|
||||
if [ "$COMMAND" = "rollback" ]; then
|
||||
TARGET_MAJOR=""
|
||||
else
|
||||
COMMAND="upgrade"
|
||||
fi
|
||||
|
||||
TARGET_IMAGE="${COOLIFY_POSTGRES_TARGET_IMAGE:-postgres:${TARGET_MAJOR}-alpine}"
|
||||
TARGET_VOLUME="${COOLIFY_POSTGRES_TARGET_VOLUME:-coolify-db-pg${TARGET_MAJOR}}"
|
||||
TEMP_CONTAINER="coolify-db-pg${TARGET_MAJOR:-rollback}-restore-${DATE}"
|
||||
DUMP_FILE="${BACKUP_DIR}/postgres-upgrade-${DATE}.sql.gz"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOGFILE"
|
||||
}
|
||||
|
||||
fail() {
|
||||
log "ERROR: $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage:
|
||||
$0 <target-major>
|
||||
$0 rollback
|
||||
|
||||
Examples:
|
||||
$0 18
|
||||
$0 rollback
|
||||
|
||||
Environment overrides:
|
||||
COOLIFY_POSTGRES_TARGET_IMAGE=postgres:18-alpine
|
||||
COOLIFY_POSTGRES_TARGET_VOLUME=coolify-db-pg18
|
||||
EOF
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$TEMP_CONTAINER" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
get_env_var() {
|
||||
local key="$1"
|
||||
local fallback="${2:-}"
|
||||
local value
|
||||
|
||||
value=$(grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
|
||||
value="${value%\"}"
|
||||
value="${value#\"}"
|
||||
value="${value%\'}"
|
||||
value="${value#\'}"
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
printf '%s' "$fallback"
|
||||
else
|
||||
printf '%s' "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_postgres() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local database="$3"
|
||||
local attempts=60
|
||||
|
||||
for _ in $(seq 1 "$attempts"); do
|
||||
if docker exec "$container" pg_isready -U "$user" -d "$database" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
compose_files() {
|
||||
printf -- '-f %s/docker-compose.yml -f %s/docker-compose.prod.yml ' "$SOURCE_DIR" "$SOURCE_DIR"
|
||||
|
||||
if [ -f "${SOURCE_DIR}/docker-compose.custom.yml" ]; then
|
||||
printf -- '-f %s/docker-compose.custom.yml ' "$SOURCE_DIR"
|
||||
fi
|
||||
|
||||
if [ -f "$OVERRIDE_FILE" ]; then
|
||||
printf -- '-f %s ' "$OVERRIDE_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_target_major() {
|
||||
case "$TARGET_MAJOR" in
|
||||
''|*[!0-9]*)
|
||||
usage
|
||||
fail "Target major version must be numeric. Example: $0 18"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$TARGET_MAJOR" -lt 10 ]; then
|
||||
fail "Target major version must be 10 or higher."
|
||||
fi
|
||||
}
|
||||
|
||||
mount_path_for_major() {
|
||||
local major="$1"
|
||||
|
||||
if [ "$major" -ge 18 ]; then
|
||||
printf '%s' '/var/lib/postgresql'
|
||||
else
|
||||
printf '%s' '/var/lib/postgresql/data'
|
||||
fi
|
||||
}
|
||||
|
||||
current_postgres_mount_name() {
|
||||
docker inspect coolify-db --format '{{range .Mounts}}{{if or (eq .Destination "/var/lib/postgresql/data") (eq .Destination "/var/lib/postgresql")}}{{.Name}}{{end}}{{end}}' 2>/dev/null
|
||||
}
|
||||
|
||||
current_postgres_mount_path() {
|
||||
docker inspect coolify-db --format '{{range .Mounts}}{{if or (eq .Destination "/var/lib/postgresql/data") (eq .Destination "/var/lib/postgresql")}}{{.Destination}}{{end}}{{end}}' 2>/dev/null
|
||||
}
|
||||
|
||||
current_postgres_image() {
|
||||
docker inspect coolify-db --format '{{.Config.Image}}' 2>/dev/null
|
||||
}
|
||||
|
||||
current_coolify_image_tag() {
|
||||
local image
|
||||
local image_without_digest
|
||||
local last_segment
|
||||
|
||||
image=$(docker inspect coolify --format '{{.Config.Image}}' 2>/dev/null || true)
|
||||
image_without_digest="${image%@*}"
|
||||
last_segment="${image_without_digest##*/}"
|
||||
|
||||
if [[ "$last_segment" == *:* ]]; then
|
||||
printf '%s' "${last_segment##*:}"
|
||||
fi
|
||||
}
|
||||
|
||||
write_override_file() {
|
||||
local image="$1"
|
||||
local volume="$2"
|
||||
local mount_path="$3"
|
||||
|
||||
cat > "$OVERRIDE_FILE" <<YAML
|
||||
services:
|
||||
postgres:
|
||||
image: "${image}"
|
||||
volumes:
|
||||
- coolify-db:${mount_path}
|
||||
volumes:
|
||||
coolify-db:
|
||||
name: "${volume}"
|
||||
external: true
|
||||
YAML
|
||||
}
|
||||
|
||||
write_rollback_file() {
|
||||
local previous_image="$1"
|
||||
local previous_volume="$2"
|
||||
local previous_mount_path="$3"
|
||||
local previous_override_present="$4"
|
||||
local upgraded_image="$5"
|
||||
local upgraded_volume="$6"
|
||||
|
||||
cat > "$ROLLBACK_FILE" <<EOF
|
||||
PREVIOUS_IMAGE='${previous_image}'
|
||||
PREVIOUS_VOLUME='${previous_volume}'
|
||||
PREVIOUS_MOUNT_PATH='${previous_mount_path}'
|
||||
PREVIOUS_OVERRIDE_PRESENT='${previous_override_present}'
|
||||
UPGRADED_IMAGE='${upgraded_image}'
|
||||
UPGRADED_VOLUME='${upgraded_volume}'
|
||||
CREATED_AT='${DATE}'
|
||||
EOF
|
||||
chmod 600 "$ROLLBACK_FILE"
|
||||
}
|
||||
|
||||
start_stack() {
|
||||
local coolify_image_tag="${1:-${LATEST_IMAGE:-latest}}"
|
||||
local files
|
||||
files=$(compose_files)
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
LATEST_IMAGE="$coolify_image_tag" docker compose --env-file "$ENV_FILE" $files up -d --remove-orphans --wait --wait-timeout 120
|
||||
}
|
||||
|
||||
print_rollback_instructions() {
|
||||
cat <<EOF | tee -a "$LOGFILE"
|
||||
|
||||
Rollback command if the upgraded database does not work:
|
||||
${SOURCE_DIR}/upgrade-postgres.sh rollback
|
||||
|
||||
Rollback metadata was saved to:
|
||||
${ROLLBACK_FILE}
|
||||
|
||||
The previous active Docker volume was '${PREVIOUS_VOLUME}'.
|
||||
The new Docker volume is '${TARGET_VOLUME}'.
|
||||
The dump file is '${DUMP_FILE}'.
|
||||
EOF
|
||||
}
|
||||
|
||||
validate_common_requirements() {
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
touch "$LOGFILE"
|
||||
chmod 700 "$BACKUP_DIR"
|
||||
|
||||
[ -f "$ENV_FILE" ] || fail "Missing ${ENV_FILE}. Run this on a self-hosted Coolify server."
|
||||
command -v docker >/dev/null 2>&1 || fail "Docker is required."
|
||||
docker info >/dev/null 2>&1 || fail "Docker daemon is not reachable."
|
||||
}
|
||||
|
||||
rollback_postgres() {
|
||||
validate_common_requirements
|
||||
|
||||
[ -f "$ROLLBACK_FILE" ] || fail "Missing rollback metadata file: ${ROLLBACK_FILE}"
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
. "$ROLLBACK_FILE"
|
||||
|
||||
[ -n "${PREVIOUS_IMAGE:-}" ] || fail "Rollback metadata is missing PREVIOUS_IMAGE."
|
||||
[ -n "${PREVIOUS_VOLUME:-}" ] || fail "Rollback metadata is missing PREVIOUS_VOLUME."
|
||||
[ -n "${PREVIOUS_MOUNT_PATH:-}" ] || fail "Rollback metadata is missing PREVIOUS_MOUNT_PATH."
|
||||
[ -n "${PREVIOUS_OVERRIDE_PRESENT:-}" ] || fail "Rollback metadata is missing PREVIOUS_OVERRIDE_PRESENT."
|
||||
|
||||
CURRENT_COOLIFY_IMAGE_TAG=$(current_coolify_image_tag)
|
||||
|
||||
log "Rolling back Coolify internal PostgreSQL."
|
||||
log "Previous image: ${PREVIOUS_IMAGE}"
|
||||
log "Previous volume: ${PREVIOUS_VOLUME}"
|
||||
log "Previous mount path: ${PREVIOUS_MOUNT_PATH}"
|
||||
log "Current Coolify image tag: ${CURRENT_COOLIFY_IMAGE_TAG:-latest}"
|
||||
|
||||
docker volume inspect "$PREVIOUS_VOLUME" >/dev/null 2>&1 || fail "Previous volume '${PREVIOUS_VOLUME}' does not exist."
|
||||
|
||||
log "Stopping Coolify application container before rollback."
|
||||
docker stop coolify >>"$LOGFILE" 2>&1 || true
|
||||
|
||||
log "Removing current coolify-db container. Current upgraded volume is kept untouched."
|
||||
docker rm -f coolify-db >>"$LOGFILE" 2>&1 || true
|
||||
|
||||
if [ "$PREVIOUS_OVERRIDE_PRESENT" = "true" ]; then
|
||||
log "Restoring previous PostgreSQL compose override."
|
||||
write_override_file "$PREVIOUS_IMAGE" "$PREVIOUS_VOLUME" "$PREVIOUS_MOUNT_PATH"
|
||||
else
|
||||
log "Removing PostgreSQL compose override to restore base compose configuration."
|
||||
rm -f "$OVERRIDE_FILE"
|
||||
fi
|
||||
|
||||
log "Starting Coolify stack with rollback database volume."
|
||||
start_stack "$CURRENT_COOLIFY_IMAGE_TAG" >>"$LOGFILE" 2>&1 || fail "Could not start Coolify stack after rollback. See ${LOGFILE}."
|
||||
|
||||
log "Rollback completed successfully."
|
||||
cat <<EOF | tee -a "$LOGFILE"
|
||||
|
||||
Rollback completed.
|
||||
Current active PostgreSQL volume should be '${PREVIOUS_VOLUME}'.
|
||||
The upgraded volume '${UPGRADED_VOLUME:-unknown}' was left untouched for inspection or manual cleanup.
|
||||
EOF
|
||||
}
|
||||
|
||||
upgrade_postgres() {
|
||||
validate_target_major
|
||||
TARGET_MOUNT_PATH=$(mount_path_for_major "$TARGET_MAJOR")
|
||||
validate_common_requirements
|
||||
|
||||
log "Starting Coolify internal PostgreSQL major upgrade."
|
||||
log "Target major: ${TARGET_MAJOR}"
|
||||
log "Target image: ${TARGET_IMAGE}"
|
||||
log "Target volume: ${TARGET_VOLUME}"
|
||||
log "Target mount path: ${TARGET_MOUNT_PATH}"
|
||||
|
||||
DB_USERNAME=$(get_env_var DB_USERNAME coolify)
|
||||
DB_DATABASE=$(get_env_var DB_DATABASE coolify)
|
||||
|
||||
if ! docker ps -a --format '{{.Names}}' | grep -qx 'coolify-db'; then
|
||||
fail "Container 'coolify-db' was not found. Start Coolify before running this script."
|
||||
fi
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx 'coolify-db'; then
|
||||
log "Starting existing coolify-db container for version detection and dump."
|
||||
docker start coolify-db >>"$LOGFILE" 2>&1 || fail "Could not start coolify-db."
|
||||
fi
|
||||
|
||||
wait_for_postgres coolify-db "$DB_USERNAME" "$DB_DATABASE" || fail "Existing coolify-db is not ready."
|
||||
|
||||
SERVER_VERSION_NUM=$(docker exec coolify-db psql -U "$DB_USERNAME" -d "$DB_DATABASE" -Atc 'SHOW server_version_num;' | tr -d '[:space:]')
|
||||
CURRENT_MAJOR=$((SERVER_VERSION_NUM / 10000))
|
||||
PREVIOUS_VOLUME=$(current_postgres_mount_name)
|
||||
PREVIOUS_MOUNT_PATH=$(current_postgres_mount_path)
|
||||
PREVIOUS_IMAGE=$(current_postgres_image)
|
||||
CURRENT_COOLIFY_IMAGE_TAG=$(current_coolify_image_tag)
|
||||
|
||||
[ -n "$PREVIOUS_VOLUME" ] || fail "Could not detect current PostgreSQL Docker volume."
|
||||
[ -n "$PREVIOUS_MOUNT_PATH" ] || fail "Could not detect current PostgreSQL mount path."
|
||||
[ -n "$PREVIOUS_IMAGE" ] || fail "Could not detect current PostgreSQL image."
|
||||
|
||||
if [ -f "$OVERRIDE_FILE" ]; then
|
||||
PREVIOUS_OVERRIDE_PRESENT=true
|
||||
else
|
||||
PREVIOUS_OVERRIDE_PRESENT=false
|
||||
fi
|
||||
|
||||
log "Current PostgreSQL major: ${CURRENT_MAJOR}"
|
||||
log "Current active volume: ${PREVIOUS_VOLUME}"
|
||||
log "Current image: ${PREVIOUS_IMAGE}"
|
||||
log "Current mount path: ${PREVIOUS_MOUNT_PATH}"
|
||||
log "Current Coolify image tag: ${CURRENT_COOLIFY_IMAGE_TAG:-latest}"
|
||||
|
||||
if [ "$CURRENT_MAJOR" -eq "$TARGET_MAJOR" ]; then
|
||||
log "PostgreSQL is already on major ${TARGET_MAJOR}. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_MAJOR" -gt "$TARGET_MAJOR" ]; then
|
||||
fail "Downgrade from ${CURRENT_MAJOR} to ${TARGET_MAJOR} is not supported. Use '$0 rollback' to restore the previous upgrade state."
|
||||
fi
|
||||
|
||||
if docker volume inspect "$TARGET_VOLUME" >/dev/null 2>&1; then
|
||||
fail "Target volume '${TARGET_VOLUME}' already exists. Set COOLIFY_POSTGRES_TARGET_VOLUME to a new name or remove the old failed target volume."
|
||||
fi
|
||||
|
||||
log "Stopping Coolify application container to prevent writes during dump."
|
||||
docker stop coolify >>"$LOGFILE" 2>&1 || true
|
||||
|
||||
log "Creating compressed dump at ${DUMP_FILE}."
|
||||
docker exec coolify-db pg_dumpall -U "$DB_USERNAME" | gzip -c > "$DUMP_FILE"
|
||||
chmod 600 "$DUMP_FILE"
|
||||
|
||||
if [ ! -s "$DUMP_FILE" ]; then
|
||||
fail "Dump file is empty. Aborting."
|
||||
fi
|
||||
|
||||
log "Creating target Docker volume '${TARGET_VOLUME}'."
|
||||
docker volume create "$TARGET_VOLUME" >>"$LOGFILE" 2>&1
|
||||
|
||||
log "Pulling ${TARGET_IMAGE}."
|
||||
docker pull "$TARGET_IMAGE" >>"$LOGFILE" 2>&1
|
||||
|
||||
log "Starting temporary PostgreSQL ${TARGET_MAJOR} container."
|
||||
docker run -d \
|
||||
--name "$TEMP_CONTAINER" \
|
||||
--network coolify \
|
||||
-e POSTGRES_HOST_AUTH_METHOD=trust \
|
||||
-v "${TARGET_VOLUME}:${TARGET_MOUNT_PATH}" \
|
||||
"$TARGET_IMAGE" >>"$LOGFILE" 2>&1
|
||||
|
||||
wait_for_postgres "$TEMP_CONTAINER" postgres postgres || fail "Temporary PostgreSQL ${TARGET_MAJOR} container did not become ready."
|
||||
|
||||
log "Restoring dump into target volume."
|
||||
gunzip -c "$DUMP_FILE" | docker exec -i "$TEMP_CONTAINER" psql -U postgres -d postgres >>"$LOGFILE" 2>&1
|
||||
|
||||
log "Smoke-checking restored Coolify database."
|
||||
docker exec "$TEMP_CONTAINER" psql -U "$DB_USERNAME" -d "$DB_DATABASE" -Atc 'SELECT 1;' | grep -qx '1' || fail "Restored database smoke check failed."
|
||||
|
||||
log "Saving rollback metadata to ${ROLLBACK_FILE}."
|
||||
write_rollback_file "$PREVIOUS_IMAGE" "$PREVIOUS_VOLUME" "$PREVIOUS_MOUNT_PATH" "$PREVIOUS_OVERRIDE_PRESENT" "$TARGET_IMAGE" "$TARGET_VOLUME"
|
||||
|
||||
log "Writing Docker Compose override to ${OVERRIDE_FILE}."
|
||||
write_override_file "$TARGET_IMAGE" "$TARGET_VOLUME" "$TARGET_MOUNT_PATH"
|
||||
|
||||
log "Stopping temporary restore container."
|
||||
docker rm -f "$TEMP_CONTAINER" >>"$LOGFILE" 2>&1 || true
|
||||
|
||||
log "Stopping old coolify-db container. Previous volume '${PREVIOUS_VOLUME}' will be kept for rollback."
|
||||
docker rm -f coolify-db >>"$LOGFILE" 2>&1 || true
|
||||
|
||||
log "Starting Coolify stack with PostgreSQL ${TARGET_MAJOR}."
|
||||
start_stack "$CURRENT_COOLIFY_IMAGE_TAG" >>"$LOGFILE" 2>&1 || fail "Could not start Coolify stack with upgraded PostgreSQL. See ${LOGFILE}."
|
||||
|
||||
log "Coolify internal PostgreSQL upgrade completed successfully."
|
||||
print_rollback_instructions
|
||||
}
|
||||
|
||||
if [ "${COOLIFY_POSTGRES_UPGRADE_SOURCE_ONLY:-false}" = "true" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "$COMMAND" in
|
||||
rollback)
|
||||
rollback_postgres
|
||||
;;
|
||||
upgrade)
|
||||
upgrade_postgres
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
fail "Unknown command: ${COMMAND}"
|
||||
;;
|
||||
esac
|
||||
@@ -56,6 +56,9 @@ log "Downloading docker-compose.prod.yml from ${CDN}/docker-compose.prod.yml"
|
||||
curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
|
||||
log "Downloading .env.production from ${CDN}/.env.production"
|
||||
curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production
|
||||
log "Downloading upgrade-postgres.sh from ${CDN}/upgrade-postgres.sh"
|
||||
curl -fsSL -L $CDN/upgrade-postgres.sh -o /data/coolify/source/upgrade-postgres.sh
|
||||
chmod +x /data/coolify/source/upgrade-postgres.sh
|
||||
log "Configuration files downloaded successfully"
|
||||
echo " Done."
|
||||
|
||||
@@ -69,6 +72,12 @@ if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
|
||||
log "Including custom docker-compose.yml in image extraction"
|
||||
fi
|
||||
|
||||
# Check if PostgreSQL upgrade override exists
|
||||
if [ -f /data/coolify/source/docker-compose.postgres-upgrade.yml ]; then
|
||||
COMPOSE_FILES="$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml"
|
||||
log "Including PostgreSQL upgrade compose override in image extraction"
|
||||
fi
|
||||
|
||||
# Get all unique images from docker compose config
|
||||
# LATEST_IMAGE env var is needed for image substitution in compose files
|
||||
IMAGES=$(LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u)
|
||||
@@ -236,15 +245,18 @@ nohup bash -c "
|
||||
echo '============================================================' >>\"\$LOGFILE\"
|
||||
write_status '5' 'Starting new containers'
|
||||
|
||||
COMPOSE_FILES='-f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml'
|
||||
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
|
||||
log 'Using custom docker-compose.yml'
|
||||
log 'Running docker compose up with custom configuration...'
|
||||
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
|
||||
else
|
||||
log 'Using standard docker-compose configuration'
|
||||
log 'Running docker compose up...'
|
||||
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
|
||||
COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml\"
|
||||
fi
|
||||
if [ -f /data/coolify/source/docker-compose.postgres-upgrade.yml ]; then
|
||||
log 'Using PostgreSQL upgrade compose override'
|
||||
COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml\"
|
||||
fi
|
||||
|
||||
log 'Running docker compose up...'
|
||||
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
|
||||
log 'Docker compose up completed'
|
||||
|
||||
# Final log entry
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { initializeTerminalComponent } from './terminal.js';
|
||||
|
||||
// Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate
|
||||
// (via replaceHtmlAttributes). With `[x-cloak]{display:none}` on the app wrapper,
|
||||
// this blanks the whole page on every navigation until Alpine re-processes it.
|
||||
// Strip leftover x-cloak after each navigation; the initial-load FOUC guard stays.
|
||||
document.addEventListener('livewire:navigated', () => {
|
||||
document.querySelectorAll('[x-cloak]').forEach((el) => el.removeAttribute('x-cloak'));
|
||||
});
|
||||
|
||||
['livewire:navigated', 'alpine:init'].forEach((event) => {
|
||||
document.addEventListener(event, () => {
|
||||
// tree-shaking
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
])
|
||||
|
||||
@php
|
||||
$changes = collect(data_get($diff, 'changes', []))->filter(fn ($change) => data_get($change, 'key') !== 'domains.custom_labels')->values()->all();
|
||||
$changes = collect(data_get($diff, 'changes', []))->values()->all();
|
||||
$count = count($changes);
|
||||
$requiresBuild = collect($changes)->contains(fn ($change) => data_get($change, 'impact') === 'build');
|
||||
@endphp
|
||||
@@ -41,16 +41,63 @@
|
||||
</div>
|
||||
<div class="divide-y divide-neutral-300 dark:divide-coolgray-200">
|
||||
@foreach ($sectionChanges as $change)
|
||||
@php
|
||||
$changeKey = (string) data_get($change, 'key');
|
||||
$expandable = data_get($change, 'expandable', false);
|
||||
$oldDisplay = (string) data_get($change, 'old_display_value');
|
||||
$newDisplay = (string) data_get($change, 'new_display_value');
|
||||
$oldFull = data_get($change, 'old_full_value') ?? $oldDisplay;
|
||||
$newFull = data_get($change, 'new_full_value') ?? $newDisplay;
|
||||
$label = (string) data_get($change, 'label');
|
||||
$labelTruncated = mb_strlen($label) > 20;
|
||||
$rowExpandable = $expandable || $labelTruncated;
|
||||
@endphp
|
||||
<div class="grid grid-cols-[12rem_1fr_1.5rem_1fr] items-start gap-2 px-3 py-1.5 text-neutral-700 dark:text-neutral-300">
|
||||
<div class="shrink-0 font-medium text-black dark:text-white">
|
||||
{{ data_get($change, 'label') }}
|
||||
<div class="min-w-0 shrink-0 font-medium text-black dark:text-white">
|
||||
@if ($rowExpandable)
|
||||
<div class="break-words"
|
||||
:class="expandedRows['{{ $changeKey }}'] ? '' : 'truncate'"
|
||||
x-text="expandedRows['{{ $changeKey }}'] ? @js($label) : @js((string) str($label)->limit(20))"></div>
|
||||
@else
|
||||
{{ $label }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="truncate text-red-700 dark:text-red-400/80" title="{{ data_get($change, 'old_display_value') }}">
|
||||
{{ data_get($change, 'old_display_value') }}
|
||||
<div class="min-w-0 text-red-700 dark:text-red-400/80">
|
||||
@if ($expandable)
|
||||
<div class="break-words"
|
||||
:class="expandedRows['{{ $changeKey }}'] ? 'whitespace-pre-wrap' : 'truncate'"
|
||||
x-text="expandedRows['{{ $changeKey }}'] ? @js($oldFull) : @js($oldDisplay)"></div>
|
||||
@else
|
||||
<div class="truncate">{{ $oldDisplay }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-center text-neutral-500 dark:text-neutral-400">→</div>
|
||||
<div class="truncate text-green-700 dark:text-green-500" title="{{ data_get($change, 'new_display_value') }}">
|
||||
{{ data_get($change, 'new_display_value') }}
|
||||
<div class="flex min-w-0 items-start gap-1 text-green-700 dark:text-green-500">
|
||||
<div class="min-w-0 flex-1">
|
||||
@if ($expandable)
|
||||
<div class="break-words"
|
||||
:class="expandedRows['{{ $changeKey }}'] ? 'whitespace-pre-wrap' : 'truncate'"
|
||||
x-text="expandedRows['{{ $changeKey }}'] ? @js($newFull) : @js($newDisplay)"></div>
|
||||
@else
|
||||
<div class="truncate">{{ $newDisplay }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@if ($rowExpandable)
|
||||
<button type="button"
|
||||
x-on:click="expandedRows['{{ $changeKey }}'] = ! expandedRows['{{ $changeKey }}']"
|
||||
:aria-expanded="!! expandedRows['{{ $changeKey }}']"
|
||||
title="Toggle full value"
|
||||
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center text-neutral-400 transition hover:text-black dark:hover:text-white">
|
||||
<svg x-show="! expandedRows['{{ $changeKey }}']" class="h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M13.28 7.78l3.22-3.22v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.69l-3.22 3.22a.75.75 0 0 0 1.06 1.06ZM2 17.25v-4.5a.75.75 0 0 1 1.5 0v2.69l3.22-3.22a.75.75 0 0 1 1.06 1.06L4.56 16.5h2.69a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg x-show="expandedRows['{{ $changeKey }}']" x-cloak class="h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3.28 2.22a.75.75 0 0 0-1.06 1.06L5.44 6.5H2.75a.75.75 0 0 0 0 1.5h4.5A.75.75 0 0 0 8 7.25v-4.5a.75.75 0 0 0-1.5 0v2.69L3.28 2.22ZM13.5 2.75a.75.75 0 0 0-1.5 0v4.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 0-1.5h-2.69l3.22-3.22a.75.75 0 0 0-1.06-1.06L13.5 5.44V2.75ZM3.28 17.78l3.22-3.22v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.69l-3.22 3.22a.75.75 0 1 0 1.06 1.06ZM12 12.75c0-.414.336-.75.75-.75h4.5a.75.75 0 0 1 0 1.5h-2.69l3.22 3.22a.75.75 0 1 1-1.06 1.06l-3.22-3.22v2.69a.75.75 0 0 1-1.5 0v-4.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
@props(['text'])
|
||||
@props(['text', 'label' => null])
|
||||
|
||||
<div class="relative" x-data="{ copied: false, isSecure: window.isSecureContext }">
|
||||
<input type="text" value="{{ $text }}" readonly class="input">
|
||||
<div class="w-full" x-data="{ copied: false, isSecure: window.isSecureContext }">
|
||||
@if ($label)
|
||||
<label class="flex gap-1 items-center mb-1 text-sm font-medium">{{ $label }}</label>
|
||||
@endif
|
||||
<div class="relative">
|
||||
<input type="text" value="{{ $text }}" class="input pr-10"
|
||||
@keydown.prevent @paste.prevent @cut.prevent @drop.prevent
|
||||
@focus="$event.target.select()">
|
||||
<button
|
||||
x-show="isSecure"
|
||||
@click.prevent="copied = true; navigator.clipboard.writeText({{ Js::from($text) }}); setTimeout(() => copied = false, 1000)"
|
||||
@@ -15,4 +21,5 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,26 +196,6 @@
|
||||
}"
|
||||
@click.outside="showDropdown = false">
|
||||
|
||||
@if ($type === 'password' && $allowToPeak)
|
||||
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
|
||||
class="flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer dark:hover:text-white"
|
||||
aria-label="Toggle password visibility">
|
||||
<svg x-show="type === 'password'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
|
||||
</svg>
|
||||
<svg x-cloak x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
|
||||
<path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
|
||||
<path d="M3 3l18 18" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<input
|
||||
x-ref="input"
|
||||
@input="handleInput()"
|
||||
@@ -241,6 +221,26 @@
|
||||
placeholder="{{ $attributes->get('placeholder') }}"
|
||||
@if ($autofocus) autofocus @endif>
|
||||
|
||||
@if ($type === 'password' && $allowToPeak)
|
||||
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
|
||||
class="flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer dark:hover:text-white"
|
||||
aria-label="Toggle password visibility">
|
||||
<svg x-show="type === 'password'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
|
||||
</svg>
|
||||
<svg x-cloak x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
|
||||
<path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
|
||||
<path d="M3 3l18 18" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- Dropdown for suggestions --}}
|
||||
<div x-show="showDropdown"
|
||||
x-transition
|
||||
|
||||
@@ -14,6 +14,16 @@
|
||||
@endif
|
||||
@if ($type === 'password')
|
||||
<div class="relative" x-data="{ type: 'password' }" @success.window="type = 'password'">
|
||||
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
|
||||
x-bind:type="type"
|
||||
x-bind:class="{ 'truncate': type === 'text' && ! $el.disabled }"
|
||||
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
|
||||
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
|
||||
wire:loading.attr="disabled"
|
||||
@readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
|
||||
aria-placeholder="{{ $attributes->get('placeholder') }}"
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif>
|
||||
@if ($allowToPeak)
|
||||
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
|
||||
class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hover:text-white"
|
||||
@@ -35,16 +45,6 @@
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
|
||||
x-bind:type="type"
|
||||
x-bind:class="{ 'truncate': type === 'text' && ! $el.disabled }"
|
||||
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
|
||||
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
|
||||
wire:loading.attr="disabled"
|
||||
@readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
|
||||
aria-placeholder="{{ $attributes->get('placeholder') }}"
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif>
|
||||
|
||||
</div>
|
||||
@else
|
||||
|
||||
@@ -31,6 +31,21 @@
|
||||
@else
|
||||
@if ($type === 'password')
|
||||
<div class="relative" x-data="{ type: 'password' }" @success.window="type = 'password'">
|
||||
<input x-cloak x-show="type === 'password'" value="{{ $value }}"
|
||||
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
|
||||
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
|
||||
wire:loading.attr="disabled"
|
||||
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
|
||||
aria-placeholder="{{ $attributes->get('placeholder') }}">
|
||||
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}" x-cloak x-show="type !== 'password'"
|
||||
placeholder="{{ $placeholder }}" {{ $attributes->merge(['class' => $defaultClass]) }}
|
||||
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
|
||||
@else
|
||||
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
|
||||
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" name={{ $modelBinding }}
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
|
||||
@if ($allowToPeak)
|
||||
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
|
||||
class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer dark:hover:text-white"
|
||||
@@ -51,21 +66,6 @@
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
<input x-cloak x-show="type === 'password'" value="{{ $value }}"
|
||||
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
|
||||
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
|
||||
wire:loading.attr="disabled"
|
||||
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
|
||||
aria-placeholder="{{ $attributes->get('placeholder') }}">
|
||||
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}" x-cloak x-show="type !== 'password'"
|
||||
placeholder="{{ $placeholder }}" {{ $attributes->merge(['class' => $defaultClass]) }}
|
||||
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
|
||||
@else
|
||||
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
|
||||
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" name={{ $modelBinding }}
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
|
||||
|
||||
</div>
|
||||
@else
|
||||
|
||||
@@ -36,32 +36,34 @@
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen"
|
||||
x-init="$watch('modalOpen', value => { if(value) { $nextTick(() => { const firstInput = $el.querySelector('input, textarea, select'); firstInput?.focus(); }) } })"
|
||||
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-4">
|
||||
class="fixed inset-0 z-99 overflow-y-auto">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
@if ($closeOutside) @click="modalOpen=false" @endif
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div id="{{ $modalId }}" x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
|
||||
x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl border rounded-sm drop-shadow-sm bg-white border-neutral-200 dark:bg-base dark:border-coolgray-300 flex flex-col">
|
||||
<div class="flex items-center justify-between py-6 px-6 shrink-0">
|
||||
<h3 class="text-2xl font-bold">{{ $title }}</h3>
|
||||
<button @click="modalOpen=false"
|
||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex items-center justify-center w-auto px-6 pb-6">
|
||||
{{ $slot }}
|
||||
<div @if ($closeOutside) @click.self="modalOpen=false" @endif class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
|
||||
<div id="{{ $modalId }}" x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
|
||||
x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden rounded-sm border border-neutral-200 bg-white drop-shadow-sm dark:border-coolgray-300 dark:bg-base lg:w-auto lg:min-w-2xl lg:max-w-4xl">
|
||||
<div class="flex items-center justify-between py-6 px-6 shrink-0">
|
||||
<h3 class="text-2xl font-bold">{{ $title }}</h3>
|
||||
<button @click="modalOpen=false"
|
||||
class="absolute cursor-pointer top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-1"
|
||||
style="-webkit-overflow-scrolling: touch;">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="sub-menu-wrapper">
|
||||
<a class="{{ request()->routeIs('server.sentinel') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.sentinel', $parameters) }}">
|
||||
<span class="menu-item-label">Configuration</span>
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('server.sentinel.logs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.sentinel.logs', $parameters) }}">
|
||||
<span class="menu-item-label">Logs</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -6,11 +6,6 @@
|
||||
href="{{ route('server.advanced', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Advanced</span>
|
||||
</a>
|
||||
@endif
|
||||
@if ($server->isFunctional() && !$server->isSwarm() && !$server->isBuildServer())
|
||||
<a class="sub-menu-item {{ $activeMenu === 'sentinel' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.sentinel', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Sentinel</span>
|
||||
</a>
|
||||
@endif
|
||||
<a class="sub-menu-item {{ $activeMenu === 'private-key' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Private Key</span>
|
||||
</a>
|
||||
@@ -37,7 +32,7 @@
|
||||
<a class="sub-menu-item {{ $activeMenu === 'log-drains' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.log-drains', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Log Drains</span></a>
|
||||
<a class="sub-menu-item {{ $activeMenu === 'metrics' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.charts', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Metrics</span></a>
|
||||
href="{{ route('server.metrics', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Metrics</span></a>
|
||||
@endif
|
||||
@if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel)
|
||||
<a class="sub-menu-item {{ $activeMenu === 'swarm' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
|
||||
@@ -32,11 +32,12 @@ x-init="$watch('slideOverOpen', value => {
|
||||
<div class="px-4 pb-4 sm:px-5">
|
||||
<div class="flex items-start justify-between pb-1">
|
||||
<h2 class="text-2xl leading-6" id="slide-over-title">
|
||||
{{ $title }}</h2>
|
||||
{{ $title }}
|
||||
</h2>
|
||||
<div class="flex items-center h-auto ml-3">
|
||||
<button @click="slideOverOpen=false"
|
||||
class="absolute top-0 right-0 z-30 flex items-center justify-center px-3 py-2 mt-4 mr-2 space-x-1 text-xs font-normal border-none rounded-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none"
|
||||
class="absolute cursor-pointer top-0 right-0 z-30 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"></path>
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
'title' => null,
|
||||
'lastDeploymentLink' => null,
|
||||
'resource' => null,
|
||||
'showRefreshButton' => true,
|
||||
])
|
||||
@php
|
||||
$stoppedAfterRestartLimit = $resource && method_exists($resource, 'stoppedAfterRestartLimit') && $resource->stoppedAfterRestartLimit();
|
||||
@endphp
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
@if (str($resource->status)->startsWith('running'))
|
||||
<x-status.running :status="$resource->status" :title="$title" :lastDeploymentLink="$lastDeploymentLink" />
|
||||
@@ -13,13 +17,20 @@
|
||||
@else
|
||||
<x-status.stopped :status="$resource->status" />
|
||||
@endif
|
||||
@if (isset($resource->restart_count) && $resource->restart_count > 0 && !str($resource->status)->startsWith('exited'))
|
||||
@if (isset($resource->restart_count) && $resource->restart_count > 0 && (!str($resource->status)->startsWith('exited') || $stoppedAfterRestartLimit))
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs dark:text-warning" title="Container has restarted {{ $resource->restart_count }} time{{ $resource->restart_count > 1 ? 's' : '' }}. Last restart: {{ $resource->last_restart_at?->diffForHumans() }}">
|
||||
({{ $resource->restart_count }}x restarts)
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($stoppedAfterRestartLimit)
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs dark:text-warning" title="Container has crashed and Coolify stopped it after {{ $resource->restart_count }} restart attempts.">
|
||||
Stopped after reaching restart limit ({{ $resource->restart_count }}/{{ $resource->max_restart_count }}).
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@if (!str($resource->status)->contains('exited') && $showRefreshButton)
|
||||
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
|
||||
class="dark:hover:fill-white fill-black dark:fill-warning">
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<x-emails.layout>
|
||||
{{ $name }} has been automatically stopped after {{ $restart_count }} crash restarts (limit: {{ $max_restart_count }}).
|
||||
|
||||
The application appears to be in a crash loop. Please investigate the issue and redeploy when ready.
|
||||
|
||||
[Check what is going on]({{ $resource_url }}).
|
||||
</x-emails.layout>
|
||||
@@ -0,0 +1,14 @@
|
||||
@if ($destination->getMorphClass() === 'App\\Models\\StandaloneDocker')
|
||||
<div class="navbar-main">
|
||||
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
|
||||
<a class="{{ request()->routeIs('destination.show') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('destination.show', ['destination_uuid' => $destination->uuid]) }}">
|
||||
General
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('destination.resources') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('destination.resources', ['destination_uuid' => $destination->uuid]) }}">
|
||||
Resources
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,53 @@
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1>Destination</h1>
|
||||
</div>
|
||||
<div class="subtitle">Resources deployed to this Docker network.</div>
|
||||
|
||||
@include('livewire.destination.navbar', ['destination' => $destination])
|
||||
|
||||
<div class="pt-4" x-data="{ search: '' }">
|
||||
@if (count($resources) === 0)
|
||||
<div class="py-4 text-sm opacity-70">No resources are using this destination.</div>
|
||||
@else
|
||||
<x-forms.input placeholder="Search resources..." x-model="search" id="null" />
|
||||
<div class="overflow-x-auto pt-4">
|
||||
<div class="inline-block min-w-full">
|
||||
<div class="overflow-hidden">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Project</th>
|
||||
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Environment</th>
|
||||
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Name</th>
|
||||
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
@foreach ($resources as $row)
|
||||
<tr class="dark:hover:bg-coolgray-300 hover:bg-neutral-100"
|
||||
wire:key="destination-resource-{{ $row['type'] }}-{{ $row['uuid'] }}"
|
||||
x-show="search === '' || '{{ addslashes($row['search']) }}'.includes(search.toLowerCase())">
|
||||
<td class="px-5 py-4 text-sm whitespace-nowrap">{{ $row['project'] }}</td>
|
||||
<td class="px-5 py-4 text-sm whitespace-nowrap">{{ $row['environment'] }}</td>
|
||||
<td class="px-5 py-4 text-sm whitespace-nowrap">
|
||||
@if ($row['url'])
|
||||
<a {{ wireNavigate() }} href="{{ $row['url'] }}">
|
||||
{{ $row['name'] }}
|
||||
<x-internal-link />
|
||||
</a>
|
||||
@else
|
||||
<span>{{ $row['name'] }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm whitespace-nowrap">{{ ucfirst($row['type']) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,7 +20,9 @@
|
||||
<x-deprecated-badge />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex gap-2">
|
||||
@include('livewire.destination.navbar', ['destination' => $destination])
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<x-forms.input canGate="update" :canResource="$destination" id="name" label="Name" />
|
||||
<x-forms.input id="serverIp" label="Server IP" readonly />
|
||||
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
|
||||
|
||||
@@ -101,6 +101,18 @@
|
||||
/>
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||
</form>
|
||||
<form class="flex items-end gap-2" wire:submit.prevent='saveMaxRestartCount'>
|
||||
<x-forms.input
|
||||
type="number"
|
||||
min="0"
|
||||
helper="Maximum number of crash restarts before Coolify automatically stops the application and sends a notification. Set to 0 to disable the limit."
|
||||
id="maxRestartCount"
|
||||
label="Max Restart Count"
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
/>
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||
</form>
|
||||
<h3 class="pt-4">Logs</h3>
|
||||
<x-forms.checkbox helper="Drain logs to your configured log drain endpoint in your Server settings."
|
||||
instantSave id="isLogDrainEnabled" label="Drain Logs" canGate="update" :canResource="$application" />
|
||||
|
||||
@@ -115,10 +115,6 @@
|
||||
const range = selection.getRangeAt(0);
|
||||
return logsContainer.contains(range.commonAncestorContainer);
|
||||
},
|
||||
decodeHtml(text) {
|
||||
const doc = new DOMParser().parseFromString(text, 'text/html');
|
||||
return doc.documentElement.textContent;
|
||||
},
|
||||
highlightText(el, text, query) {
|
||||
if (this.hasActiveLogSelection()) return;
|
||||
|
||||
@@ -159,7 +155,7 @@
|
||||
if (matches && query) count++;
|
||||
|
||||
if (textSpan) {
|
||||
const originalText = this.decodeHtml(textSpan.dataset.lineText || '');
|
||||
const originalText = textSpan.dataset.lineText || '';
|
||||
if (!query) {
|
||||
textSpan.textContent = originalText;
|
||||
} else if (matches) {
|
||||
@@ -186,8 +182,15 @@
|
||||
copyLogs() {
|
||||
const content = this.collectVisibleLogs();
|
||||
if (!content) return;
|
||||
navigator.clipboard.writeText(content);
|
||||
Livewire.dispatch('success', ['Logs copied to clipboard.']);
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);
|
||||
return;
|
||||
}
|
||||
navigator.clipboard?.writeText(content).then(() => {
|
||||
Livewire.dispatch('success', ['Logs copied to clipboard.']);
|
||||
}).catch(() => {
|
||||
Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);
|
||||
});
|
||||
},
|
||||
downloadLogs() {
|
||||
const content = this.collectVisibleLogs();
|
||||
@@ -433,14 +436,14 @@
|
||||
$lineContent = (isset($line['command']) && $line['command'] ? '[CMD]: ' : '') . trim($line['line']);
|
||||
$searchableContent = $line['timestamp'] . ' ' . $lineContent;
|
||||
@endphp
|
||||
<div data-log-line data-log-content="{{ htmlspecialchars($searchableContent) }}"
|
||||
<div data-log-line data-log-content="{{ $searchableContent }}"
|
||||
@class([
|
||||
'mt-2' => isset($line['command']) && $line['command'],
|
||||
'flex gap-2 log-line',
|
||||
])>
|
||||
<span x-show="showTimestamps"
|
||||
class="shrink-0 text-gray-500">{{ $line['timestamp'] }}</span>
|
||||
<span data-line-text="{{ htmlspecialchars($lineContent) }}"
|
||||
<span data-line-text="{{ $lineContent }}"
|
||||
@class([
|
||||
'text-success dark:text-warning' => $line['hidden'],
|
||||
'text-red-500' => $line['stderr'],
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<div>{{ $application->compose_parsing_version }}</div>
|
||||
@endif
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||
<x-modal-input title="Resource Details" buttonTitle="Details">
|
||||
<livewire:project.shared.resource-details :resource="$application" />
|
||||
</x-modal-input>
|
||||
@if ($buildPack === 'dockercompose')
|
||||
<x-forms.button canGate="update" :canResource="$application" wire:target='initLoadingCompose'
|
||||
x-on:click="$wire.dispatch('loadCompose', false)">
|
||||
@@ -497,6 +500,13 @@
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
@if ((empty($portsExposes) || $portsExposes === '0') && !empty($fqdn))
|
||||
<x-callout type="info" title="No ports exposed" class="mb-4">
|
||||
This application does not expose any ports and will not be reachable through the proxy or your domains.
|
||||
This behavior is normal for background workers, bots, or scheduled tasks.
|
||||
If your application needs to handle HTTP traffic, please specify the port(s) it listens on.
|
||||
</x-callout>
|
||||
@endif
|
||||
<div class="flex flex-col gap-2 xl:flex-row">
|
||||
@if ($isStatic || $buildPack === 'static')
|
||||
<x-forms.input id="portsExposes" label="Ports Exposes" readonly
|
||||
@@ -507,7 +517,7 @@
|
||||
helper="Readonly labels are disabled. You can set the ports manually in the labels section."
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@else
|
||||
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports Exposes" required
|
||||
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports Exposes"
|
||||
helper="A comma separated list of ports your application uses. The first port will be used as default healthcheck port if nothing defined in the Healthcheck menu. Be sure to set this correctly."
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@endif
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
href="{{ route('project.application.logs', $parameters) }}">
|
||||
<div class="flex items-center gap-1">
|
||||
Logs
|
||||
@if ($application->restart_count > 0 && !str($application->status)->startsWith('exited'))
|
||||
@if ($application->restart_count > 0 && (!str($application->status)->startsWith('exited') || $application->stoppedAfterRestartLimit()))
|
||||
<svg class="w-4 h-4 dark:text-warning" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" title="Container has restarted {{ $application->restart_count }} time{{ $application->restart_count > 1 ? 's' : '' }}">
|
||||
<path d="M12 2L1 21h22L12 2zm0 4l7.53 13H4.47L12 6zm-1 5v4h2v-4h-2zm0 5v2h2v-2h-2z"/>
|
||||
</svg>
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$database">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-modal-input title="Resource Details" buttonTitle="Details">
|
||||
<livewire:project.shared.resource-details :resource="$database" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input label="Name" id="name" canGate="update" :canResource="$database" />
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$database">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-modal-input title="Resource Details" buttonTitle="Details">
|
||||
<livewire:project.shared.resource-details :resource="$database" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input label="Name" id="name" canGate="update" :canResource="$database" />
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$database">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-modal-input title="Resource Details" buttonTitle="Details">
|
||||
<livewire:project.shared.resource-details :resource="$database" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input label="Name" id="name" canGate="update" :canResource="$database" />
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$database">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-modal-input title="Resource Details" buttonTitle="Details">
|
||||
<livewire:project.shared.resource-details :resource="$database" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input label="Name" id="name" canGate="update" :canResource="$database" />
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$database">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-modal-input title="Resource Details" buttonTitle="Details">
|
||||
<livewire:project.shared.resource-details :resource="$database" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input label="Name" id="name" canGate="update" :canResource="$database" />
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<x-forms.button type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-modal-input title="Resource Details" buttonTitle="Details">
|
||||
<livewire:project.shared.resource-details :resource="$database" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input label="Name" id="name" canGate="update" :canResource="$database" />
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$database">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-modal-input title="Resource Details" buttonTitle="Details">
|
||||
<livewire:project.shared.resource-details :resource="$database" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
|
||||
<x-forms.input label="Name" id="name" canGate="update" :canResource="$database" />
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$database">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-modal-input title="Resource Details" buttonTitle="Details">
|
||||
<livewire:project.shared.resource-details :resource="$database" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input label="Name" id="name" canGate="update" :canResource="$database" />
|
||||
|
||||
@@ -138,9 +138,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="filteredServices.length > 0" class="mt-8">
|
||||
<div class="flex items-center gap-4" x-init="loadResources">
|
||||
<div class="flex flex-wrap items-center gap-4" x-init="loadResources">
|
||||
<h2>Services</h2>
|
||||
<x-forms.button x-on:click="loadResources">Reload List</x-forms.button>
|
||||
<div x-show="serviceTemplatesLastUpdated"
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Last Updated on Service Templates:
|
||||
<span x-text="serviceTemplatesLastUpdated"></span>
|
||||
</div>
|
||||
</div>
|
||||
<x-callout type="info" title="Trademarks Policy" class="mt-4 mb-6">
|
||||
The respective trademarks mentioned here are owned by the respective companies, and use of them
|
||||
@@ -154,7 +159,14 @@
|
||||
<x-resource-view>
|
||||
<x-slot:title>
|
||||
<template x-if="service.name">
|
||||
<span x-text="service.name"></span>
|
||||
<div>
|
||||
<span x-text="service.name"></span>
|
||||
<template x-if="service.templateLastUpdated">
|
||||
<div class="mt-1 text-[0.7rem] font-normal text-neutral-500 dark:text-neutral-500">
|
||||
Updated: <span x-text="service.templateLastUpdated"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</x-slot>
|
||||
<x-slot:description>
|
||||
@@ -237,6 +249,7 @@
|
||||
isSticky: false,
|
||||
selecting: false,
|
||||
services: [],
|
||||
serviceTemplatesLastUpdated: null,
|
||||
gitBasedApplications: [],
|
||||
dockerBasedApplications: [],
|
||||
databases: [],
|
||||
@@ -251,12 +264,14 @@
|
||||
this.loading = true;
|
||||
const {
|
||||
services,
|
||||
serviceTemplatesLastUpdated,
|
||||
categories,
|
||||
gitBasedApplications,
|
||||
dockerBasedApplications,
|
||||
databases
|
||||
} = await this.$wire.loadServices();
|
||||
this.services = services;
|
||||
this.serviceTemplatesLastUpdated = serviceTemplatesLastUpdated;
|
||||
this.categories = categories || [];
|
||||
this.gitBasedApplications = gitBasedApplications;
|
||||
this.dockerBasedApplications = dockerBasedApplications;
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<livewire:project.service.edit-compose serviceId="{{ $service->id }}" />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
<x-modal-input title="Resource Details" buttonTitle="Details">
|
||||
<livewire:project.shared.resource-details :resource="$service" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
<div>Configuration</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div>
|
||||
@if ($isConfigurationChanged && !is_null($resource->config_hash) && !$resource->isExited())
|
||||
<div x-data="{ configurationDiffModalOpen: false }">
|
||||
<div x-data="{ configurationDiffModalOpen: false, expandedRows: {} }">
|
||||
<x-popup-small>
|
||||
<x-slot:title>
|
||||
The latest configuration has not been applied
|
||||
|
||||
@@ -43,38 +43,69 @@
|
||||
@endif
|
||||
</div>
|
||||
@if ($view === 'normal')
|
||||
<div>
|
||||
<h3>Production Environment Variables</h3>
|
||||
<div>Environment (secrets) variables for Production.</div>
|
||||
</div>
|
||||
@forelse ($this->environmentVariables as $env)
|
||||
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
|
||||
:type="$resource->type()" />
|
||||
@empty
|
||||
<div>No environment variables found.</div>
|
||||
@endforelse
|
||||
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
|
||||
@foreach ($this->hardcodedEnvironmentVariables as $index => $env)
|
||||
<livewire:project.shared.environment-variable.show-hardcoded
|
||||
wire:key="hardcoded-prod-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
|
||||
:env="$env" />
|
||||
@endforeach
|
||||
@endif
|
||||
@if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview)
|
||||
<div>
|
||||
<h3>Preview Deployments Environment Variables</h3>
|
||||
<div>Environment (secrets) variables for Preview Deployments.</div>
|
||||
<div class="w-full md:w-96">
|
||||
<div class="relative">
|
||||
<input type="search" placeholder="Search" aria-label="Search environment variables"
|
||||
wire:model.live.debounce.300ms="search" class="w-full input pl-10" />
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<div class="relative w-4 h-4">
|
||||
<svg wire:loading.remove wire:target="search" aria-hidden="true"
|
||||
class="absolute inset-0 w-4 h-4 dark:text-neutral-400" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<svg wire:loading wire:target="search" aria-hidden="true"
|
||||
class="absolute inset-0 w-4 h-4 text-coollabs dark:text-warning animate-spin" fill="none"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@foreach ($this->environmentVariablesPreview as $env)
|
||||
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
|
||||
:type="$resource->type()" />
|
||||
@endforeach
|
||||
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
|
||||
@foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
|
||||
<livewire:project.shared.environment-variable.show-hardcoded
|
||||
wire:key="hardcoded-preview-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
|
||||
:env="$env" />
|
||||
</div>
|
||||
@if ($this->isSearchActive && ! $this->hasEnvironmentVariables)
|
||||
<div>No environment variables found.</div>
|
||||
@else
|
||||
@if ($this->environmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty())
|
||||
<div>
|
||||
<h3>Production Environment Variables</h3>
|
||||
<div>Environment (secrets) variables for Production.</div>
|
||||
</div>
|
||||
@foreach ($this->environmentVariables as $env)
|
||||
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
|
||||
:type="$resource->type()" />
|
||||
@endforeach
|
||||
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
|
||||
@foreach ($this->hardcodedEnvironmentVariables as $index => $env)
|
||||
<livewire:project.shared.environment-variable.show-hardcoded
|
||||
wire:key="hardcoded-prod-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}" :env="$env" />
|
||||
@endforeach
|
||||
@endif
|
||||
@endif
|
||||
@if (
|
||||
$resource->type() === 'application' &&
|
||||
$showPreview &&
|
||||
($this->environmentVariablesPreview->isNotEmpty() || $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
|
||||
)
|
||||
<div>
|
||||
<h3>Preview Deployments Environment Variables</h3>
|
||||
<div>Environment (secrets) variables for Preview Deployments.</div>
|
||||
</div>
|
||||
@foreach ($this->environmentVariablesPreview as $env)
|
||||
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
|
||||
:type="$resource->type()" />
|
||||
@endforeach
|
||||
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
|
||||
@foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
|
||||
<livewire:project.shared.environment-variable.show-hardcoded
|
||||
wire:key="hardcoded-preview-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
|
||||
:env="$env" />
|
||||
@endforeach
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@else
|
||||
@@ -88,8 +119,9 @@
|
||||
label="Production Environment Variables"></x-forms.textarea>
|
||||
|
||||
@if ($showPreview)
|
||||
<x-forms.textarea rows="10" class="whitespace-pre-wrap font-sans" label="Preview Deployments Environment Variables"
|
||||
id="variablesPreview" wire:model="variablesPreview"></x-forms.textarea>
|
||||
<x-forms.textarea rows="10" class="whitespace-pre-wrap font-sans"
|
||||
label="Preview Deployments Environment Variables" id="variablesPreview"
|
||||
wire:model="variablesPreview"></x-forms.textarea>
|
||||
@endif
|
||||
|
||||
<x-forms.button type="submit" class="btn btn-primary">Save All Environment Variables</x-forms.button>
|
||||
@@ -98,8 +130,9 @@
|
||||
label="Production Environment Variables" disabled></x-forms.textarea>
|
||||
|
||||
@if ($showPreview)
|
||||
<x-forms.textarea rows="10" class="whitespace-pre-wrap font-sans" label="Preview Deployments Environment Variables"
|
||||
id="variablesPreview" wire:model="variablesPreview" disabled></x-forms.textarea>
|
||||
<x-forms.textarea rows="10" class="whitespace-pre-wrap font-sans"
|
||||
label="Preview Deployments Environment Variables" id="variablesPreview" wire:model="variablesPreview"
|
||||
disabled></x-forms.textarea>
|
||||
@endif
|
||||
@endcan
|
||||
</form>
|
||||
|
||||
@@ -139,10 +139,6 @@
|
||||
const range = selection.getRangeAt(0);
|
||||
return logsContainer.contains(range.commonAncestorContainer);
|
||||
},
|
||||
decodeHtml(text) {
|
||||
const doc = new DOMParser().parseFromString(text, 'text/html');
|
||||
return doc.documentElement.textContent;
|
||||
},
|
||||
applySearch() {
|
||||
const logs = document.getElementById('logs');
|
||||
if (!logs) return;
|
||||
@@ -163,7 +159,7 @@
|
||||
|
||||
// Update highlighting
|
||||
if (textSpan) {
|
||||
const originalText = this.decodeHtml(textSpan.dataset.lineText || '');
|
||||
const originalText = textSpan.dataset.lineText || '';
|
||||
if (!query) {
|
||||
textSpan.textContent = originalText;
|
||||
} else if (matches) {
|
||||
@@ -346,8 +342,17 @@
|
||||
<button
|
||||
x-on:click="
|
||||
$wire.copyLogs().then(logs => {
|
||||
navigator.clipboard.writeText(logs);
|
||||
Livewire.dispatch('success', ['Logs copied to clipboard.']);
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
Livewire.dispatch('error', ['Clipboard is not available. Please use HTTPS or localhost.']);
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(logs).then(() => {
|
||||
Livewire.dispatch('success', ['Logs copied to clipboard.']);
|
||||
}).catch(() => {
|
||||
Livewire.dispatch('error', ['Failed to copy logs to clipboard.']);
|
||||
});
|
||||
}).catch(() => {
|
||||
Livewire.dispatch('error', ['Failed to prepare logs for clipboard.']);
|
||||
});
|
||||
"
|
||||
title="Copy Logs"
|
||||
@@ -523,20 +528,19 @@
|
||||
// Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z)
|
||||
$timestamp = '';
|
||||
$logContent = $line;
|
||||
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z?\s(.*)$/', $line, $matches)) {
|
||||
$year = $matches[1];
|
||||
$month = $matches[2];
|
||||
$day = $matches[3];
|
||||
$time = $matches[4];
|
||||
$microseconds = isset($matches[5]) ? substr($matches[5], 0, 6) : '000000';
|
||||
$logContent = $matches[6];
|
||||
if (preg_match('/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z?\s(.*)$/', $line, $matches)) {
|
||||
$microseconds = isset($matches[2]) ? substr($matches[2], 0, 6) : '000000';
|
||||
$logContent = $matches[3];
|
||||
|
||||
// Convert month number to abbreviated name
|
||||
$monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
$monthName = $monthNames[(int)$month - 1] ?? $month;
|
||||
|
||||
// Format for display: 2025-Dec-04 09:44:58
|
||||
$timestamp = "{$year}-{$monthName}-{$day} {$time}";
|
||||
// Convert UTC Docker timestamp to server timezone for display
|
||||
$carbonTs = \Carbon\Carbon::parse($matches[1], 'UTC');
|
||||
$serverTz = getServerTimezone($server);
|
||||
try {
|
||||
$carbonTs->setTimezone($serverTz);
|
||||
} catch (\Exception) {
|
||||
// keep UTC
|
||||
}
|
||||
$timestamp = $carbonTs->format('Y-M-d H:i:s');
|
||||
// Include microseconds in key for uniqueness
|
||||
$lineKey = "{$timestamp}.{$microseconds}";
|
||||
}
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
<div class="pb-4">Basic metrics for your application container.</div>
|
||||
<div>
|
||||
@if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose')
|
||||
<div class="alert alert-warning">Metrics are not available for Docker Compose applications yet!</div>
|
||||
<x-callout type="warning" title="Not Available">
|
||||
Metrics are not available for Docker Compose applications yet!
|
||||
</x-callout>
|
||||
@elseif(!$resource->destination->server->isMetricsEnabled())
|
||||
<div class="alert alert-warning pb-1">Metrics are only available for servers with Sentinel & Metrics enabled!</div>
|
||||
<div>Go to <a class="underline dark:text-white" href="{{ route('server.show', $resource->destination->server->uuid) }}/sentinel" {{ wireNavigate() }}>Server settings</a> to enable it.</div>
|
||||
<x-callout type="info" title="Metrics Not Enabled">
|
||||
Metrics are only available for servers with Sentinel & Metrics enabled.
|
||||
Go to <a class="underline font-semibold" href="{{ route('server.metrics', ['server_uuid' => $resource->destination->server->uuid]) }}" {{ wireNavigate() }}>Server Metrics</a> to enable it.
|
||||
</x-callout>
|
||||
@else
|
||||
@if (!str($resource->status)->contains('running'))
|
||||
<div class="alert alert-warning">Metrics are only available when the application container is running!</div>
|
||||
<x-callout type="warning" title="Container Not Running">
|
||||
Metrics are only available when the application container is running!
|
||||
</x-callout>
|
||||
@else
|
||||
<div>
|
||||
<x-forms.select label="Interval" wire:change="setInterval" id="interval">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<div class="w-full max-h-[70vh] overflow-y-auto pr-1 -mt-4">
|
||||
<div class="pb-4 text-sm dark:text-neutral-400">Identifiers for this resource. Read-only</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<h3>Resource</h3>
|
||||
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<x-forms.copy-button label="Name" :text="$resource->name ?? ''" />
|
||||
<x-forms.copy-button label="UUID" :text="$resource->uuid ?? ''" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($environment_uuid)
|
||||
<div>
|
||||
<h3>Environment</h3>
|
||||
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<x-forms.copy-button label="Name" :text="$environment_name ?? ''" />
|
||||
<x-forms.copy-button label="UUID" :text="$environment_uuid" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($project_uuid)
|
||||
<div>
|
||||
<h3>Project</h3>
|
||||
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<x-forms.copy-button label="Name" :text="$project_name ?? ''" />
|
||||
<x-forms.copy-button label="UUID" :text="$project_uuid" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($server_uuid)
|
||||
<div>
|
||||
<h3>Server</h3>
|
||||
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<x-forms.copy-button label="Name" :text="$server_name ?? ''" />
|
||||
<x-forms.copy-button label="UUID" :text="$server_uuid" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($stack_applications) || ! empty($stack_databases))
|
||||
<div>
|
||||
<h3>Stack Sub-Resources</h3>
|
||||
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
@foreach ($stack_applications as $item)
|
||||
<x-forms.copy-button :label="'Application — ' . $item['name']" :text="$item['uuid']" />
|
||||
@endforeach
|
||||
@foreach ($stack_databases as $item)
|
||||
<x-forms.copy-button :label="'Database — ' . $item['name']" :text="$item['uuid']" />
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,7 +6,18 @@
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="metrics" />
|
||||
<div class="w-full">
|
||||
<h2>Metrics</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Metrics</h2>
|
||||
@if ($server->isMetricsEnabled())
|
||||
<x-forms.button canGate="update" :canResource="$server" wire:click='toggleMetrics'>
|
||||
Disable Metrics
|
||||
</x-forms.button>
|
||||
@elseif ($server->isSentinelEnabled())
|
||||
<x-forms.button canGate="update" :canResource="$server" isHighlighted wire:click='toggleMetrics'>
|
||||
Enable Metrics
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="pb-4">Basic metrics for your server.</div>
|
||||
@if ($server->isMetricsEnabled())
|
||||
<div @if ($poll) wire:poll.5000ms='pollData' @endif x-init="$wire.loadData()">
|
||||
@@ -288,8 +299,16 @@
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div>Metrics are disabled for this server. Enable them in <a class="underline dark:text-white"
|
||||
href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}/sentinel" {{ wireNavigate() }}>Sentinel</a> settings.</div>
|
||||
@if ($server->isSentinelEnabled())
|
||||
<x-callout type="info" title="Metrics Disabled">
|
||||
Metrics are disabled for this server. Click "Enable Metrics" above to start collecting metrics.
|
||||
</x-callout>
|
||||
@else
|
||||
<x-callout type="info" title="Sentinel Required">
|
||||
Metrics require Sentinel to be enabled.
|
||||
Please <a class="underline font-semibold" href="{{ route('server.sentinel', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}>enable Sentinel</a> first.
|
||||
</x-callout>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user