mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Actions\Shared\MigrateResourceToDestination;
|
||||
use App\Enums\BuildPackTypes;
|
||||
use App\Enums\HttpBasicAuthHashAlgorithm;
|
||||
use App\Enums\RedirectTypes;
|
||||
use App\Enums\StaticImageTypes;
|
||||
use App\Models\Application;
|
||||
@@ -202,6 +203,7 @@ function sharedDataApplications(?Application $application = null): array
|
||||
'inject_build_args_to_dockerfile' => 'boolean',
|
||||
'include_source_commit_in_build' => 'boolean',
|
||||
'is_env_sorting_enabled' => 'boolean',
|
||||
'redact_all_env_values_in_logs' => 'boolean',
|
||||
'is_pr_deployments_public_enabled' => 'boolean',
|
||||
'is_gzip_enabled' => 'boolean',
|
||||
'is_stripprefix_enabled' => 'boolean',
|
||||
@@ -211,9 +213,13 @@ function sharedDataApplications(?Application $application = null): array
|
||||
'is_log_drain_enabled' => 'boolean',
|
||||
'is_gpu_enabled' => 'boolean',
|
||||
'gpu_driver' => 'string|nullable',
|
||||
'gpu_count' => 'string|nullable',
|
||||
'gpu_count' => ['string', 'nullable', 'regex:'.ApplicationSetting::GPU_COUNT_PATTERN],
|
||||
'gpu_device_ids' => 'string|nullable',
|
||||
'gpu_options' => 'string|nullable',
|
||||
'gpu_options' => ['string', 'nullable', function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if (ApplicationSetting::parseGpuOptions($value) === null) {
|
||||
$fail('The gpu_options must be key=value pairs or a YAML mapping.');
|
||||
}
|
||||
}],
|
||||
'is_consistent_container_name_enabled' => 'boolean',
|
||||
'custom_internal_name' => ['nullable', ...ValidationPatterns::containerNameRules()],
|
||||
'custom_container_name_prefix' => 'string|nullable|max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH,
|
||||
@@ -283,6 +289,10 @@ function sharedDataApplications(?Application $application = null): array
|
||||
'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'is_container_label_escape_enabled' => 'boolean',
|
||||
'is_preserve_repository_enabled' => 'boolean',
|
||||
'http_basic_auth_hash_algorithm' => Rule::enum(HttpBasicAuthHashAlgorithm::class),
|
||||
'http_basic_auth_bcrypt_cost' => 'integer|min:4|max:14',
|
||||
'http_basic_auth_argon2id_memory_cost' => 'integer|min:8192|max:262144',
|
||||
'http_basic_auth_argon2id_time_cost' => 'integer|min:1|max:12',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -474,6 +484,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
|
||||
$request->offsetUnset('disable_build_cache');
|
||||
$request->offsetUnset('inject_build_args_to_dockerfile');
|
||||
$request->offsetUnset('is_env_sorting_enabled');
|
||||
$request->offsetUnset('redact_all_env_values_in_logs');
|
||||
$request->offsetUnset('is_pr_deployments_public_enabled');
|
||||
$request->offsetUnset('stop_grace_period');
|
||||
$request->offsetUnset('docker_images_to_keep');
|
||||
|
||||
+114
-134
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Models\DatabaseBackupS3Replica;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
@@ -314,110 +316,96 @@ function deleteEmptyBackupFolder($folderPath, Server $server): void
|
||||
|
||||
function removeOldBackups($backup): void
|
||||
{
|
||||
try {
|
||||
if ($backup->executions) {
|
||||
// Delete old local backups (only if local backup is NOT disabled)
|
||||
// Note: When disable_local_backup is enabled, each execution already marks its own
|
||||
// local_storage_deleted status at the time of backup, so we don't need to retroactively
|
||||
// update old executions
|
||||
if (! $backup->disable_local_backup) {
|
||||
$localBackupsToDelete = deleteOldBackupsLocally($backup);
|
||||
if ($localBackupsToDelete->isNotEmpty()) {
|
||||
$backup->executions()
|
||||
->whereIn('id', $localBackupsToDelete->pluck('id'))
|
||||
->update(['local_storage_deleted' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($backup->save_s3 && $backup->executions) {
|
||||
$s3BackupsToDelete = deleteOldBackupsFromS3($backup);
|
||||
if ($s3BackupsToDelete->isNotEmpty()) {
|
||||
$backup->executions()
|
||||
->whereIn('id', $s3BackupsToDelete->pluck('id'))
|
||||
->update(['s3_storage_deleted' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete execution records where all backup copies are gone
|
||||
// Case 1: Both local and S3 backups are deleted
|
||||
// When local backups are disabled, retain the local file until an S3 copy is available.
|
||||
$localBackupsToDelete = deleteOldBackupsLocally($backup);
|
||||
if ($localBackupsToDelete->isNotEmpty()) {
|
||||
$backup->executions()
|
||||
->where('local_storage_deleted', true)
|
||||
->where('s3_storage_deleted', true)
|
||||
->delete();
|
||||
|
||||
// Case 2: Local backup is deleted and S3 was never used (s3_uploaded is null)
|
||||
$backup->executions()
|
||||
->where('local_storage_deleted', true)
|
||||
->whereNull('s3_uploaded')
|
||||
->delete();
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw $e;
|
||||
->whereIn('id', $localBackupsToDelete->pluck('id'))
|
||||
->update(['local_storage_deleted' => true]);
|
||||
}
|
||||
|
||||
if ($backup->save_s3) {
|
||||
deleteOldBackupsFromS3($backup);
|
||||
}
|
||||
|
||||
// Delete execution records when no local file and no S3 copy is left
|
||||
$backup->executions()
|
||||
->where('local_storage_deleted', true)
|
||||
->withoutLiveS3Copies()
|
||||
->delete();
|
||||
}
|
||||
|
||||
function deleteOldBackupsLocally($backup): Collection
|
||||
/**
|
||||
* Successful executions that fall outside the retention settings, newest execution first.
|
||||
*
|
||||
* @param Collection<int, ScheduledDatabaseBackupExecution> $executions Sorted newest first.
|
||||
* @return Collection<int, ScheduledDatabaseBackupExecution>
|
||||
*/
|
||||
function backupExecutionsOutsideRetention(Collection $executions, ?int $retentionAmount, ?int $retentionDays, int|float|null $maxStorageGB): Collection
|
||||
{
|
||||
if (! $backup || ! $backup->executions) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$successfulBackups = $backup->executions()
|
||||
->where('status', 'success')
|
||||
->where('local_storage_deleted', false)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if ($successfulBackups->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$retentionAmount = $backup->database_backup_retention_amount_locally;
|
||||
$retentionDays = $backup->database_backup_retention_days_locally;
|
||||
$maxStorageGB = $backup->database_backup_retention_max_storage_locally;
|
||||
|
||||
if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) {
|
||||
if ($executions->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$backupsToDelete = collect();
|
||||
|
||||
if ($retentionAmount > 0) {
|
||||
$byAmount = $successfulBackups->skip($retentionAmount);
|
||||
$backupsToDelete = $backupsToDelete->merge($byAmount);
|
||||
$backupsToDelete = $backupsToDelete->merge($executions->skip($retentionAmount));
|
||||
}
|
||||
|
||||
if ($retentionDays > 0) {
|
||||
$oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays);
|
||||
$oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate);
|
||||
$backupsToDelete = $backupsToDelete->merge($oldBackups);
|
||||
$oldestAllowedDate = $executions->first()->created_at->clone()->utc()->subDays($retentionDays);
|
||||
$backupsToDelete = $backupsToDelete->merge(
|
||||
$executions->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate)
|
||||
);
|
||||
}
|
||||
|
||||
if ($maxStorageGB > 0) {
|
||||
$maxStorageBytes = $maxStorageGB * pow(1024, 3);
|
||||
$totalSize = 0;
|
||||
$backupsOverLimit = collect();
|
||||
|
||||
$backupsToCheck = $successfulBackups->skip(1);
|
||||
|
||||
foreach ($backupsToCheck as $backupExecution) {
|
||||
foreach ($executions->skip(1) as $backupExecution) {
|
||||
$totalSize += (int) $backupExecution->size;
|
||||
if ($totalSize > $maxStorageBytes) {
|
||||
$backupsOverLimit = $successfulBackups->filter(
|
||||
$backupsToDelete = $backupsToDelete->merge($executions->filter(
|
||||
fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc()
|
||||
)->skip(1);
|
||||
)->skip(1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->merge($backupsOverLimit);
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->unique('id');
|
||||
$processedBackups = collect();
|
||||
return $backupsToDelete->unique('id')->values();
|
||||
}
|
||||
|
||||
function deleteOldBackupsLocally($backup): Collection
|
||||
{
|
||||
if (! $backup) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$successfulBackups = $backup->executions()
|
||||
->where('status', 'success')
|
||||
->where('local_storage_deleted', false)
|
||||
->when($backup->disable_local_backup, fn ($query) => $query
|
||||
->whereHas('s3Replicas', fn ($query) => $query
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false)))
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
$backupsToDelete = backupExecutionsOutsideRetention(
|
||||
$successfulBackups,
|
||||
$backup->database_backup_retention_amount_locally,
|
||||
$backup->database_backup_retention_days_locally,
|
||||
$backup->database_backup_retention_max_storage_locally,
|
||||
);
|
||||
|
||||
if ($backupsToDelete->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$server = null;
|
||||
if ($backup->database_type === ServiceDatabase::class) {
|
||||
$server = $backup->database->service->server;
|
||||
} else {
|
||||
@@ -433,89 +421,81 @@ function deleteOldBackupsLocally($backup): Collection
|
||||
->pluck('filename')
|
||||
->all();
|
||||
|
||||
if (! empty($filesToDelete)) {
|
||||
deleteBackupsLocally($filesToDelete, $server);
|
||||
$processedBackups = $backupsToDelete;
|
||||
if (empty($filesToDelete)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $processedBackups;
|
||||
deleteBackupsLocally($filesToDelete, $server);
|
||||
|
||||
return $backupsToDelete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the S3 retention settings to each destination separately. Copies in a storage that no longer exists are
|
||||
* marked as deleted.
|
||||
*
|
||||
* @return Collection<int, ScheduledDatabaseBackupExecution> The executions that lost at least one S3 copy.
|
||||
*/
|
||||
function deleteOldBackupsFromS3($backup): Collection
|
||||
{
|
||||
if (! $backup || ! $backup->executions || ! $backup->s3) {
|
||||
if (! $backup) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$successfulBackups = $backup->executions()
|
||||
->where('status', 'success')
|
||||
$liveReplicas = DatabaseBackupS3Replica::query()
|
||||
->with('s3')
|
||||
->whereHas('execution', fn ($query) => $query
|
||||
->where('scheduled_database_backup_id', $backup->id)
|
||||
->where('status', 'success'))
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if ($successfulBackups->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
$deletedReplicas = $liveReplicas->filter(fn (DatabaseBackupS3Replica $replica) => ! $replica->s3);
|
||||
|
||||
$retentionAmount = $backup->database_backup_retention_amount_s3;
|
||||
$retentionDays = $backup->database_backup_retention_days_s3;
|
||||
$maxStorageGB = $backup->database_backup_retention_max_storage_s3;
|
||||
foreach ($liveReplicas->filter(fn (DatabaseBackupS3Replica $replica) => $replica->s3)->groupBy('s3_storage_id') as $replicas) {
|
||||
$successfulBackups = $backup->executions()
|
||||
->whereIn('id', $replicas->pluck('execution_id'))
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) {
|
||||
return collect();
|
||||
}
|
||||
$backupsToDelete = backupExecutionsOutsideRetention(
|
||||
$successfulBackups,
|
||||
$backup->database_backup_retention_amount_s3,
|
||||
$backup->database_backup_retention_days_s3,
|
||||
$backup->database_backup_retention_max_storage_s3,
|
||||
);
|
||||
|
||||
$backupsToDelete = collect();
|
||||
$filesToDelete = $backupsToDelete
|
||||
->filter(fn ($execution) => ! empty($execution->filename))
|
||||
->pluck('filename')
|
||||
->all();
|
||||
|
||||
if ($retentionAmount > 0) {
|
||||
$byAmount = $successfulBackups->skip($retentionAmount);
|
||||
$backupsToDelete = $backupsToDelete->merge($byAmount);
|
||||
}
|
||||
|
||||
if ($retentionDays > 0) {
|
||||
$oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays);
|
||||
$oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate);
|
||||
$backupsToDelete = $backupsToDelete->merge($oldBackups);
|
||||
}
|
||||
|
||||
if ($maxStorageGB > 0) {
|
||||
$maxStorageBytes = $maxStorageGB * pow(1024, 3);
|
||||
$totalSize = 0;
|
||||
$backupsOverLimit = collect();
|
||||
|
||||
$backupsToCheck = $successfulBackups->skip(1);
|
||||
|
||||
foreach ($backupsToCheck as $backupExecution) {
|
||||
$totalSize += (int) $backupExecution->size;
|
||||
if ($totalSize > $maxStorageBytes) {
|
||||
$backupsOverLimit = $successfulBackups->filter(
|
||||
fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc()
|
||||
)->skip(1);
|
||||
break;
|
||||
}
|
||||
if (empty($filesToDelete)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->merge($backupsOverLimit);
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->unique('id');
|
||||
$processedBackups = collect();
|
||||
|
||||
$filesToDelete = $backupsToDelete
|
||||
->filter(fn ($execution) => ! empty($execution->filename))
|
||||
->pluck('filename')
|
||||
->all();
|
||||
|
||||
if (! empty($filesToDelete)) {
|
||||
try {
|
||||
deleteBackupsS3($filesToDelete, $backup->s3);
|
||||
$processedBackups = $backupsToDelete;
|
||||
deleteBackupsS3($filesToDelete, $replicas->first()->s3);
|
||||
$deletedReplicas = $deletedReplicas->merge($replicas->whereIn('execution_id', $backupsToDelete->pluck('id')));
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
return $processedBackups;
|
||||
if ($deletedReplicas->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
DatabaseBackupS3Replica::query()
|
||||
->whereKey($deletedReplicas->pluck('id'))
|
||||
->update(['s3_storage_deleted' => true]);
|
||||
|
||||
$executions = $backup->executions()->whereIn('id', $deletedReplicas->pluck('execution_id')->unique())->get();
|
||||
$executions->each(fn (ScheduledDatabaseBackupExecution $execution) => $execution->refreshS3Summary());
|
||||
|
||||
return $executions;
|
||||
}
|
||||
|
||||
function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool
|
||||
|
||||
@@ -774,7 +774,20 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool
|
||||
->contains($normalize($domain));
|
||||
}
|
||||
|
||||
function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, bool $is_permanent_redirect_enabled = false)
|
||||
/**
|
||||
* Caddy site address of a domain. Maintenance blocks in the base Caddyfile must use the same
|
||||
* address as the labels, so caddy-docker-proxy merges them into one site block.
|
||||
*/
|
||||
function caddySiteAddress(string $scheme, string $host, bool $isForceHttpsEnabled): string
|
||||
{
|
||||
if ($scheme === 'https' && ! $isForceHttpsEnabled) {
|
||||
return "http://{$host}, https://{$host}";
|
||||
}
|
||||
|
||||
return "{$scheme}://{$host}";
|
||||
}
|
||||
|
||||
function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, ?int $http_basic_auth_bcrypt_cost = null, array $http_basic_auth_argon2id_options = [], bool $is_permanent_redirect_enabled = false)
|
||||
{
|
||||
$labels = collect([]);
|
||||
if ($serviceLabels) {
|
||||
@@ -785,7 +798,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
|
||||
|
||||
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
|
||||
if ($is_http_basic_auth_enabled) {
|
||||
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]);
|
||||
$hashedPassword = $http_basic_auth_argon2id_options
|
||||
? password_hash($http_basic_auth_password, PASSWORD_ARGON2ID, $http_basic_auth_argon2id_options)
|
||||
: password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]);
|
||||
}
|
||||
|
||||
$trafficAppKey = caddyTrafficAppKey($uuid, $service_name);
|
||||
@@ -796,10 +811,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
|
||||
$path = $url->getPath();
|
||||
$host_without_www = str($host)->replace('www.', '');
|
||||
$schema = $url->getScheme();
|
||||
$siteAddress = "{$schema}://{$host}";
|
||||
if ($schema === 'https' && ! $is_force_https_enabled) {
|
||||
$siteAddress = "http://{$host}, https://{$host}";
|
||||
}
|
||||
$siteAddress = caddySiteAddress($schema, $host, $is_force_https_enabled);
|
||||
$portlessDomain = ServiceApplication::withoutPort($domain);
|
||||
$port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null);
|
||||
$handle = 'handle_path';
|
||||
@@ -843,6 +855,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
|
||||
if ($is_http_basic_auth_enabled) {
|
||||
// Caddy 2.8 renamed basicauth to basic_auth; see Server::caddySupportsBasicAuthDirective().
|
||||
$basicAuthDirective = $supports_basic_auth_directive ? 'basic_auth' : 'basicauth';
|
||||
if ($http_basic_auth_argon2id_options) {
|
||||
$labels->push("caddy_{$loop}.{$basicAuthDirective}=argon2id");
|
||||
}
|
||||
$labels->push("caddy_{$loop}.{$basicAuthDirective}.{$http_basic_auth_username}=\"{$hashedPassword}\"");
|
||||
}
|
||||
if ($is_traffic_analytics_enabled) {
|
||||
@@ -929,7 +944,7 @@ function traefikHostRulesWithoutIpv6Brackets(Collection $labels): Collection
|
||||
) : $label);
|
||||
}
|
||||
|
||||
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?string $certificate_resolver = 'letsencrypt', bool $is_permanent_redirect_enabled = false)
|
||||
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?string $certificate_resolver = 'letsencrypt', ?int $http_basic_auth_bcrypt_cost = null, bool $is_permanent_redirect_enabled = false)
|
||||
{
|
||||
$labels = collect([]);
|
||||
$labels->push('traefik.enable=true');
|
||||
@@ -951,7 +966,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
|
||||
$http_basic_auth_label = "http-basic-auth-{$uuid}";
|
||||
if ($is_http_basic_auth_enabled) {
|
||||
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]);
|
||||
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]);
|
||||
}
|
||||
|
||||
if ($is_http_basic_auth_enabled) {
|
||||
@@ -1222,6 +1237,10 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
$appUuid = $appUuid.'-pr-'.$pull_request_id;
|
||||
}
|
||||
$labels = collect([]);
|
||||
$httpBasicAuthArgon2idOptions = $application->usesArgon2idBasicAuth() ? [
|
||||
'memory_cost' => $application->http_basic_auth_argon2id_memory_cost,
|
||||
'time_cost' => $application->http_basic_auth_argon2id_time_cost,
|
||||
] : [];
|
||||
if ($pull_request_id === 0) {
|
||||
if ($application->fqdn) {
|
||||
$domains = str(data_get($application, 'fqdn'))->explode(',');
|
||||
@@ -1242,6 +1261,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
noindex_domains: $noindexDomains,
|
||||
domainPortOverrides: $application->domain_port_overrides ?? [],
|
||||
certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver),
|
||||
@@ -1261,6 +1281,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
||||
noindex_domains: $noindexDomains,
|
||||
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
||||
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
||||
@@ -1282,6 +1304,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
noindex_domains: $noindexDomains,
|
||||
escape_redirect_replacement_for_compose: false,
|
||||
domainPortOverrides: $application->domain_port_overrides ?? [],
|
||||
@@ -1300,6 +1323,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
||||
noindex_domains: $noindexDomains,
|
||||
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
||||
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
||||
@@ -1331,6 +1356,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
noindex_domains: $noindexDomains,
|
||||
escape_redirect_replacement_for_compose: false,
|
||||
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
||||
@@ -1350,6 +1376,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
||||
noindex_domains: $noindexDomains,
|
||||
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
||||
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
||||
@@ -1370,6 +1398,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
noindex_domains: $noindexDomains,
|
||||
escape_redirect_replacement_for_compose: false,
|
||||
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
||||
@@ -1387,6 +1416,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
||||
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
||||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
||||
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
||||
noindex_domains: $noindexDomains,
|
||||
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
||||
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
||||
@@ -1752,6 +1783,27 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
|
||||
}
|
||||
}
|
||||
|
||||
// Short interactive/TTY flags (-i, -t, -it, -ti) and their long forms are not matched by
|
||||
// the long-option regex above. Map them to compose so a single-image app can keep STDIN
|
||||
// open (docker -i) and/or allocate a TTY (docker -t) — required for `docker attach` to an
|
||||
// interactive main process, e.g. game-server consoles.
|
||||
// Keep quoted option values together so flags inside an entrypoint are not options.
|
||||
preg_match_all('/(?:[^\\s\'"\\\\]+|\\\\.|"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')+/', (string) $custom_docker_run_options, $tokens);
|
||||
foreach ($tokens[0] as $token) {
|
||||
if ($token === '--interactive') {
|
||||
$compose_options->put('stdin_open', true);
|
||||
} elseif ($token === '--tty') {
|
||||
$compose_options->put('tty', true);
|
||||
} elseif (preg_match('/^-[dit]+$/', $token)) {
|
||||
if (str_contains($token, 'i')) {
|
||||
$compose_options->put('stdin_open', true);
|
||||
}
|
||||
if (str_contains($token, 't')) {
|
||||
$compose_options->put('tty', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $compose_options->toArray();
|
||||
}
|
||||
|
||||
@@ -1769,6 +1821,10 @@ function unsupportedDockerRunOptions(?string $custom_docker_run_options): array
|
||||
if (! str_starts_with($word, '-') || preg_match('/^-\d/', $word)) {
|
||||
continue;
|
||||
}
|
||||
// convertDockerRunToCompose() maps these outside DOCKER_RUN_OPTION_MAPPING
|
||||
if (preg_match('/^(-[dit]+|--interactive|--tty)$/', $word)) {
|
||||
continue;
|
||||
}
|
||||
$option = str_starts_with($word, '--') ? '--'.ltrim(strtok($word, '='), '-') : strtok($word, '=');
|
||||
if (! array_key_exists($option, DOCKER_RUN_OPTION_MAPPING)) {
|
||||
$unsupported[] = $option;
|
||||
@@ -2171,3 +2227,37 @@ function injectDockerComposeBuildArgs(string $command, string $buildArgsString):
|
||||
|
||||
return $modifiedCommand ?? $command;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command that uses the Railpack BuildKit builder, then stop the builder when no other
|
||||
* process uses it. An idle buildkitd keeps the memory of earlier builds until it restarts.
|
||||
*
|
||||
* The lock file is in the buildx metadata directory, which all helper containers of a server share.
|
||||
* The script has no `&&`, `||`, pipe, or `$(`, because the sudo parser would put sudo into it.
|
||||
*/
|
||||
function railpackBuilderLockedScript(string $command): string
|
||||
{
|
||||
return 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; '
|
||||
."{$command}; status=\$?; "
|
||||
.'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi; '
|
||||
.'exit $status';
|
||||
}
|
||||
|
||||
/**
|
||||
* The buildx metadata of a server, as the `docker run -v` source that the deployment helper uses.
|
||||
*/
|
||||
function railpackBuildxMetadataVolume(Server $server): string
|
||||
{
|
||||
return isDev() && $server->isLocalhost() ? 'coolify-buildx' : '$HOME/.docker/buildx';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command for the Railpack builder from a temporary helper container, then stop the builder
|
||||
* when no build uses it.
|
||||
*/
|
||||
function railpackBuilderHelperCommand(string $buildxMetadataVolume, string $helperImage, string $command): string
|
||||
{
|
||||
$script = railpackBuilderLockedScript($command);
|
||||
|
||||
return "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImage} bash -c '{$script}' 2>/dev/null || true";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GiteaApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Gitea caps page sizes at its MAX_RESPONSE_ITEMS setting (default 50).
|
||||
*/
|
||||
const GITEA_PAGE_SIZE = 50;
|
||||
|
||||
function refreshGiteaToken(GiteaApp $source): void
|
||||
{
|
||||
if (! $source->refresh_token) {
|
||||
throw new RuntimeException('Gitea source has no refresh token. Please reconnect the Gitea app.');
|
||||
}
|
||||
|
||||
$safetyMargin = 60;
|
||||
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lock = Cache::lock("gitea_token_refresh_{$source->id}", 20);
|
||||
if (! $lock->block(20)) {
|
||||
$source->refresh();
|
||||
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('Gitea token refresh timed out. Please try again.');
|
||||
}
|
||||
|
||||
try {
|
||||
$source->refresh();
|
||||
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
|
||||
return;
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($source->html_url, '/');
|
||||
|
||||
$response = Http::GitSource($baseUrl)->asForm()->post("{$baseUrl}/login/oauth/access_token", [
|
||||
'client_id' => $source->client_id,
|
||||
'client_secret' => $source->client_secret,
|
||||
'refresh_token' => $source->refresh_token,
|
||||
'grant_type' => 'refresh_token',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$error = data_get($response->json(), 'error_description', $response->body());
|
||||
throw new RuntimeException("Failed to refresh Gitea token: {$error}");
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$source->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'] ?? $source->refresh_token,
|
||||
'expires_at' => time() + ($data['expires_in'] ?? 3600),
|
||||
]);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
function giteaApi(GiteaApp $source, string $endpoint, string $method = 'get', ?array $data = null): array
|
||||
{
|
||||
refreshGiteaToken($source);
|
||||
|
||||
$client = Http::Gitea($source->apiUrlBase(), $source->access_token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false);
|
||||
|
||||
if ($data && in_array(strtolower($method), ['post', 'patch', 'put'])) {
|
||||
$response = $client->$method($endpoint, $data);
|
||||
} else {
|
||||
$response = $client->$method($endpoint);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
$errorMessage = data_get($response->json(), 'message', $response->body());
|
||||
throw new RuntimeException("Gitea API call failed: {$errorMessage}");
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => collect($response->json()),
|
||||
'total' => (int) $response->header('x-total-count', 0),
|
||||
];
|
||||
}
|
||||
|
||||
function giteaAppPrivateKey(GiteaApp $source): ?PrivateKey
|
||||
{
|
||||
$privateKey = $source->privateKey;
|
||||
|
||||
return $privateKey?->team_id === $source->team_id ? $privateKey : null;
|
||||
}
|
||||
|
||||
function generateGiteaCloneToken(GiteaApp $source): string
|
||||
{
|
||||
refreshGiteaToken($source);
|
||||
|
||||
return $source->access_token;
|
||||
}
|
||||
|
||||
function loadGiteaRepositories(GiteaApp $source, int $page = 1): array
|
||||
{
|
||||
refreshGiteaToken($source);
|
||||
|
||||
$response = Http::Gitea($source->apiUrlBase(), $source->access_token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get('/user/repos', [
|
||||
'limit' => GITEA_PAGE_SIZE,
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return ['total_count' => 0, 'has_more' => false, 'repositories' => []];
|
||||
}
|
||||
|
||||
$repositories = collect($response->json());
|
||||
$hasMore = count($response->json()) === GITEA_PAGE_SIZE;
|
||||
|
||||
$organizations = collect(explode(',', (string) $source->organization))
|
||||
->map(fn ($organization) => strtolower(trim($organization)))
|
||||
->filter();
|
||||
if ($organizations->isNotEmpty()) {
|
||||
$repositories = $repositories->filter(
|
||||
fn ($repository) => $organizations->contains(strtolower((string) data_get($repository, 'owner.login')))
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'total_count' => $repositories->count(),
|
||||
'has_more' => $hasMore,
|
||||
'repositories' => $repositories->map(fn ($repository) => [
|
||||
'id' => data_get($repository, 'id'),
|
||||
'name' => data_get($repository, 'name'),
|
||||
'full_name' => data_get($repository, 'full_name'),
|
||||
'default_branch' => data_get($repository, 'default_branch', 'main'),
|
||||
'html_url' => data_get($repository, 'html_url'),
|
||||
'owner' => data_get($repository, 'owner.login'),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
function loadGiteaBranches(GiteaApp $source, string $fullName, int $page = 1): array
|
||||
{
|
||||
refreshGiteaToken($source);
|
||||
|
||||
$repositoryPath = collect(explode('/', $fullName, 2))->map(fn ($part) => rawurlencode($part))->implode('/');
|
||||
$response = Http::Gitea($source->apiUrlBase(), $source->access_token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get("/repos/{$repositoryPath}/branches", [
|
||||
'limit' => GITEA_PAGE_SIZE,
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
@@ -1462,7 +1462,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
|
||||
if ($server->isLogDrainEnabled()) {
|
||||
if ($resource->isLogDrainEnabled()) {
|
||||
$logging = generate_fluentd_configuration();
|
||||
$logging = generate_log_drain_configuration($server);
|
||||
}
|
||||
}
|
||||
$volumes = collect(data_get($service, 'volumes', []));
|
||||
@@ -2848,7 +2848,7 @@ function serviceParser(Service $resource): Collection
|
||||
|
||||
if ($server->isLogDrainEnabled()) {
|
||||
if ($serviceAppsLogDrainEnabledMap->get($serviceName)) {
|
||||
$logging = generate_fluentd_configuration();
|
||||
$logging = generate_log_drain_configuration($server);
|
||||
}
|
||||
}
|
||||
$volumes = collect(data_get($service, 'volumes', []));
|
||||
|
||||
@@ -950,3 +950,16 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation rule for an HTML page that Coolify sends to the proxy in one shell command
|
||||
* (see Server::PROXY_ERROR_PAGE_MAX_BYTES).
|
||||
*/
|
||||
function proxyPageSizeRule(string $name): Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, Closure $fail) use ($name): void {
|
||||
if (strlen((string) $value) > Server::PROXY_ERROR_PAGE_MAX_BYTES) {
|
||||
$fail("The {$name} must not be larger than ".(Server::PROXY_ERROR_PAGE_MAX_BYTES / 1024).' KB.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Services\Security\SensitiveDataRedactor;
|
||||
use App\Support\RemoteProcessCommand;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Process\ProcessResult;
|
||||
@@ -370,46 +371,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
||||
|
||||
function remove_iip($text)
|
||||
{
|
||||
// Ensure the input is valid UTF-8 before processing
|
||||
$text = sanitize_utf8_text($text);
|
||||
|
||||
// Git access tokens
|
||||
$text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text);
|
||||
$text = preg_replace('/oauth2:.*?(?=@)/', 'oauth2:'.REDACTED, $text);
|
||||
|
||||
// ANSI color codes
|
||||
$text = preg_replace('/\x1b\[[0-9;]*m/', '', $text);
|
||||
|
||||
// Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, git basic auth, etc.)
|
||||
// (protocol://user:password@host → protocol://user:<REDACTED>@host)
|
||||
// User and password cannot contain whitespace, so a match never spans log lines.
|
||||
$text = preg_replace('/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:\s@\/]+:)[^@\s]+(@)/i', '$1'.REDACTED.'$2', $text);
|
||||
|
||||
// Email addresses
|
||||
$text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text);
|
||||
|
||||
// Bearer/JWT tokens
|
||||
$text = preg_replace('/Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/i', 'Bearer '.REDACTED, $text);
|
||||
|
||||
// GitHub tokens (ghp_ = personal, gho_ = OAuth, ghu_ = user-to-server, ghs_ = server-to-server, ghr_ = refresh)
|
||||
$text = preg_replace('/\bgh[pousr]_[A-Za-z0-9.\-_]{36,}(?![A-Za-z0-9.\-_])/', REDACTED, $text);
|
||||
|
||||
// GitLab tokens (glpat- = personal access token, glcbt- = CI build token, glrt- = runner token)
|
||||
$text = preg_replace('/\b(gl(?:pat|cbt|rt)-[A-Za-z0-9\-_]{20,})\b/', REDACTED, $text);
|
||||
|
||||
// AWS credentials (Access Key ID starts with AKIA, ABIA, ACCA, ASIA)
|
||||
$text = preg_replace('/\b(A(?:KIA|BIA|CCA|SIA)[A-Z0-9]{16})\b/', REDACTED, $text);
|
||||
|
||||
// AWS Secret Access Key (40 character base64-ish string, typically follows access key)
|
||||
$text = preg_replace('/(aws_secret_access_key|AWS_SECRET_ACCESS_KEY)[=:]\s*[\'"]?([A-Za-z0-9\/+=]{40})[\'"]?/i', '$1='.REDACTED, $text);
|
||||
|
||||
// API keys (common patterns)
|
||||
$text = preg_replace('/(api[_-]?key|apikey|api[_-]?secret|secret[_-]?key)[=:]\s*[\'"]?[A-Za-z0-9\-_]{16,}[\'"]?/i', '$1='.REDACTED, $text);
|
||||
|
||||
// Private key blocks
|
||||
$text = preg_replace('/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/', REDACTED, $text);
|
||||
|
||||
return $text;
|
||||
return app(SensitiveDataRedactor::class)->redactText((string) $text);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,15 +26,111 @@ function service_logo_urls(mixed $logo): array
|
||||
}
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Stringable;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* The URL of a logo in public/svgs/resources. The file's modification time is the version, so
|
||||
* browsers load a changed logo instead of their cached copy.
|
||||
*/
|
||||
function resource_logo_url(string $name): string
|
||||
{
|
||||
$path = "svgs/resources/{$name}.svg";
|
||||
$modifiedAt = is_file(public_path($path)) ? filemtime(public_path($path)) : false;
|
||||
|
||||
return asset($path).($modifiedAt ? "?v={$modifiedAt}" : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The logo of a resource, the same one shown on the "New resource" page: the database engine,
|
||||
* the service template, or how the application is built. Null when there is no specific logo.
|
||||
*
|
||||
* Applications with a domain also get the favicon URL of their first domain. The browser loads it
|
||||
* and shows it in place of the logo when it loads. Coolify itself never requests it.
|
||||
*
|
||||
* @return array{src: string, dark: ?string, fallback: ?string, padded: bool, favicon: ?string}|null
|
||||
*/
|
||||
function resource_logo(Model $resource): ?array
|
||||
{
|
||||
$resourceLogo = function (string $name, bool $hasDarkVariant = false): array {
|
||||
return [
|
||||
'src' => resource_logo_url($name),
|
||||
'dark' => $hasDarkVariant ? resource_logo_url("{$name}-dark") : null,
|
||||
'fallback' => null,
|
||||
'padded' => false,
|
||||
'favicon' => null,
|
||||
];
|
||||
};
|
||||
|
||||
if ($resource instanceof Service) {
|
||||
$templates = once(fn () => get_service_templates());
|
||||
$type = filled($resource->service_type) ? $resource->service_type : str($resource->name)->beforeLast('-')->value();
|
||||
$logo = data_get($templates->get(resolve_service_template_key($type, $templates)), 'logo');
|
||||
if (! is_string($logo) || $logo === '' || basename($logo) === 'default.webp') {
|
||||
return null;
|
||||
}
|
||||
$urls = service_logo_urls($logo);
|
||||
|
||||
return ['src' => $urls['logo'], 'dark' => null, 'fallback' => $urls['logo_cdn_url'], 'padded' => true, 'favicon' => null];
|
||||
}
|
||||
|
||||
if ($resource instanceof Application) {
|
||||
$logo = match (true) {
|
||||
$resource->build_pack === 'dockerimage' => $resourceLogo('docker-image'),
|
||||
$resource->build_pack === 'dockerfile' => $resourceLogo('dockerfile'),
|
||||
$resource->build_pack === 'dockercompose' => $resourceLogo('docker-compose'),
|
||||
$resource->source_type === GithubApp::class => $resourceLogo('github-app', true),
|
||||
$resource->source_type === GitlabApp::class => $resourceLogo('gitlab-app'),
|
||||
default => $resourceLogo('public-repo'),
|
||||
};
|
||||
|
||||
return [...$logo, 'favicon' => resource_favicon_url($resource->fqdn)];
|
||||
}
|
||||
|
||||
if (! method_exists($resource, 'type')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (str($resource->type())->after('standalone-')->value()) {
|
||||
'postgresql' => $resourceLogo('postgres'),
|
||||
'mysql' => $resourceLogo('mysql'),
|
||||
'mariadb' => $resourceLogo('mariadb'),
|
||||
'mongodb' => $resourceLogo('mongodb'),
|
||||
'redis' => $resourceLogo('redis'),
|
||||
'keydb' => $resourceLogo('keydb', true),
|
||||
'dragonfly' => $resourceLogo('dragonfly', true),
|
||||
'clickhouse' => $resourceLogo('clickhouse'),
|
||||
'sqlite' => $resourceLogo('sqlite'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The favicon URL of the first domain in a comma-separated domain list, or null when there is no usable domain.
|
||||
*/
|
||||
function resource_favicon_url(?string $fqdn): ?string
|
||||
{
|
||||
$domain = str((string) $fqdn)->explode(',')->map(fn (string $url) => trim($url))->first(fn (string $url) => $url !== '');
|
||||
$parts = parse_url((string) $domain);
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
$host = (string) ($parts['host'] ?? '');
|
||||
if (! in_array($scheme, ['http', 'https'], true) || $host === '' || str_contains($host, '*')) {
|
||||
return null;
|
||||
}
|
||||
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
|
||||
|
||||
return "{$scheme}://{$host}{$port}/favicon.ico";
|
||||
}
|
||||
|
||||
function replaceRegex(?string $name = null)
|
||||
{
|
||||
return "/\\\${?{$name}[^}]*}?|\\\${$name}\w+/";
|
||||
|
||||
+186
-14
@@ -9,6 +9,7 @@ use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GiteaApp;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -38,6 +39,7 @@ use App\Models\StandaloneSqlite;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\Security\SensitiveDataRedactor;
|
||||
use App\Support\DnsRecordHints;
|
||||
use Carbon\CarbonImmutable;
|
||||
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
|
||||
@@ -701,14 +703,8 @@ function get_route_parameters(): array
|
||||
|
||||
function get_latest_sentinel_version(): string
|
||||
{
|
||||
try {
|
||||
$response = Http::get(config('constants.coolify.versions_url'));
|
||||
$versions = $response->json();
|
||||
|
||||
return data_get($versions, 'coolify.sentinel.version');
|
||||
} catch (Throwable) {
|
||||
return '0.0.0';
|
||||
}
|
||||
// Read the local versions.json, which CheckForUpdatesJob refreshes every hour. Do not ask the CDN for each server.
|
||||
return data_get(get_versions_data(), 'coolify.sentinel.version') ?? '0.0.0';
|
||||
}
|
||||
function get_latest_version_of_coolify(): string
|
||||
{
|
||||
@@ -891,6 +887,10 @@ function profile_avatar_url(User $user): string
|
||||
}
|
||||
}
|
||||
|
||||
if (auth()->check() && $user->id !== auth()->id()) {
|
||||
return route('team.member.avatar', ['user_id' => $user->id, 'v' => $user->updated_at->timestamp]);
|
||||
}
|
||||
|
||||
return route('profile.avatar', ['v' => $user->updated_at->timestamp]);
|
||||
}
|
||||
|
||||
@@ -909,6 +909,21 @@ function project_icon_url(Project $project): string
|
||||
]);
|
||||
}
|
||||
|
||||
function team_avatar_url(Team $team): string
|
||||
{
|
||||
if ($team->avatar_storage_type === 's3') {
|
||||
$url = s3_image_url($team->avatar_s3_storage_id, $team->avatar_path, $team->updated_at->timestamp);
|
||||
if ($url) {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
return route('team.avatar', [
|
||||
'team_id' => $team->id,
|
||||
'v' => $team->updated_at->timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
function s3_image_url(?int $storageId, ?string $path, int $version): ?string
|
||||
{
|
||||
if (! $storageId || blank($path)) {
|
||||
@@ -1741,10 +1756,14 @@ function removeAnsiColors($text)
|
||||
return preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $text);
|
||||
}
|
||||
|
||||
function sanitizeLogsForExport(string $text): string
|
||||
/**
|
||||
* Redact log text before it leaves Coolify (copy, download, API, MCP).
|
||||
*
|
||||
* @param array{known?: array<int, string>, locked?: array<int, string>} $secrets Resource values to hide, usually from EnvironmentVariable::logRedactionSecretsFor().
|
||||
*/
|
||||
function sanitizeLogsForExport(string $text, array $secrets = []): string
|
||||
{
|
||||
// All sanitization is now handled by remove_iip()
|
||||
return remove_iip($text);
|
||||
return app(SensitiveDataRedactor::class)->redactText($text, $secrets['known'] ?? [], $secrets['locked'] ?? []);
|
||||
}
|
||||
|
||||
function getTopLevelNetworks(Service|Application $resource): Collection
|
||||
@@ -3588,7 +3607,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
}
|
||||
}
|
||||
if ($resource->server->isLogDrainEnabled() && $savedService->isLogDrainEnabled()) {
|
||||
data_set($service, 'logging', generate_fluentd_configuration());
|
||||
data_set($service, 'logging', generate_log_drain_configuration($resource->server));
|
||||
}
|
||||
if ($serviceLabels->count() > 0) {
|
||||
if ($resource->is_container_label_escape_enabled) {
|
||||
@@ -4413,7 +4432,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
|
||||
if ($server->isLogDrainEnabled()) {
|
||||
if ($resource instanceof Application && $resource->isLogDrainEnabled()) {
|
||||
data_set($service, 'logging', generate_fluentd_configuration());
|
||||
data_set($service, 'logging', generate_log_drain_configuration($server));
|
||||
}
|
||||
}
|
||||
if ($serviceLabels->count() > 0) {
|
||||
@@ -4473,6 +4492,31 @@ function generate_fluentd_configuration(): array
|
||||
];
|
||||
}
|
||||
|
||||
function generate_log_drain_configuration(Server $server): array
|
||||
{
|
||||
if ($server->isFluentBitLogDrainEnabled()) {
|
||||
return generate_fluentd_configuration();
|
||||
}
|
||||
|
||||
$settings = $server->settings;
|
||||
$options = [
|
||||
'awslogs-region' => $settings->logdrain_cloudwatch_region,
|
||||
'awslogs-group' => $settings->logdrain_cloudwatch_group,
|
||||
'awslogs-create-group' => 'true',
|
||||
// Like fluentd-async: a CloudWatch outage or missing credentials must not block or fail the container.
|
||||
'mode' => 'non-blocking',
|
||||
// The stream is named after the container; awslogs-stream is never set because it would override this tag.
|
||||
'tag' => ($settings->logdrain_cloudwatch_stream_prefix ?? '').'{{.Name}}',
|
||||
];
|
||||
|
||||
return [
|
||||
'driver' => 'awslogs',
|
||||
'options' => collect($options)
|
||||
->reject(fn ($value) => blank($value))
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
function isAssociativeArray($array)
|
||||
{
|
||||
if ($array instanceof Collection) {
|
||||
@@ -4578,6 +4622,20 @@ function wireNavigate(): string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a resource menu that the main sidebar shows in place of its main menu on wide screens
|
||||
* (see components/navbar.blade.php). The menu teleports itself into #main-nav-context-{depth}.
|
||||
* Back on a menu shows the menu one depth lower, and Back at depth 0 shows the main menu.
|
||||
*/
|
||||
function registerMainNavContext(int $depth, string $label, string $backTitle): void
|
||||
{
|
||||
$levels = request()->attributes->get('mainNavContext', []);
|
||||
$levels[$depth] = ['label' => $label, 'backTitle' => $backTitle];
|
||||
ksort($levels);
|
||||
|
||||
request()->attributes->set('mainNavContext', $levels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a grouped settings sidebar into the items that the command palette
|
||||
* shows for the current page: every page, its child pages, and its in-page sections.
|
||||
@@ -4603,6 +4661,10 @@ function settingsSearchItems(iterable $groupedItems, array $routeParameters, arr
|
||||
|
||||
foreach ($groupedItems as $groupLabel => $groupItems) {
|
||||
foreach ($groupItems as $item) {
|
||||
if ($item['disabled'] ?? false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$href = route($item['route'], $routeParameters);
|
||||
$add($item['label'], $groupLabel, $href, $item['navigate'] ?? true);
|
||||
|
||||
@@ -4856,7 +4918,7 @@ function gitRepositorySlug(?string $gitRepository): string
|
||||
return str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString();
|
||||
}
|
||||
|
||||
function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|null $source = null): array
|
||||
function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|GiteaApp|null $source = null): array
|
||||
{
|
||||
$repository = $gitRepository;
|
||||
$providerInfo = [
|
||||
@@ -4876,6 +4938,7 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|
|
||||
switch ($source->getMorphClass()) {
|
||||
case GithubApp::class:
|
||||
case GitlabApp::class:
|
||||
case GiteaApp::class:
|
||||
$providerInfo['host'] = Url::fromString($source->html_url)->getHost();
|
||||
$providerInfo['port'] = $source->custom_port;
|
||||
$providerInfo['user'] = $source->custom_user;
|
||||
@@ -5714,6 +5777,115 @@ function deviceLabel(?string $device): string
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare aggregated `city` breakdown rows (`CC|subdivision|City`, from
|
||||
* TrafficAnalyticsAggregator::breakdowns(), not raw Sentinel rows) for display,
|
||||
* largest first. `name` and `detail` are the two parts of `label` ("Berlin" and
|
||||
* "BE, Germany"). `CC||` (sent by older Sentinel builds) is an unknown city of
|
||||
* that country. `__other__` and malformed values are merged into one "Other"
|
||||
* row, which stays last. Returns [] when no row has a real city.
|
||||
*
|
||||
* @param iterable<int, array{value?: string, requests?: int, bytesOut?: int, lat?: float, lon?: float}> $rows
|
||||
* @return array<int, array{key: string, country: string, label: string, name: string, detail: string, requests: int, bytesOut: int, lat: ?float, lon: ?float}>
|
||||
*/
|
||||
function trafficCityRows(iterable $rows): array
|
||||
{
|
||||
$out = [];
|
||||
$hasCity = false;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$requests = (int) data_get($row, 'requests', 0);
|
||||
if ($requests <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = (string) data_get($row, 'value', '');
|
||||
$parts = explode('|', $value, 3);
|
||||
$parts[0] = strtoupper($parts[0]);
|
||||
$isCityKey = count($parts) === 3 && preg_match('/^[A-Z]{2}$/', $parts[0]) === 1;
|
||||
|
||||
$key = '__other__';
|
||||
$country = '';
|
||||
$label = 'Other';
|
||||
$name = 'Other';
|
||||
$detail = '';
|
||||
$lat = null;
|
||||
$lon = null;
|
||||
|
||||
if ($isCityKey) {
|
||||
[$country, $subdivision, $city] = array_map('trim', $parts);
|
||||
$key = $value;
|
||||
|
||||
if ($city === '') {
|
||||
$label = 'Unknown, '.$country;
|
||||
$name = 'Unknown';
|
||||
$detail = countryName($country);
|
||||
} else {
|
||||
$hasCity = true;
|
||||
$region = preg_match('/^[A-Z]{2,3}$/', $subdivision) === 1 ? $subdivision.', ' : '';
|
||||
$label = $city.', '.$region.$country;
|
||||
$name = $city;
|
||||
$detail = $region.countryName($country);
|
||||
|
||||
$rawLat = data_get($row, 'lat');
|
||||
$rawLon = data_get($row, 'lon');
|
||||
if (is_numeric($rawLat) && is_numeric($rawLon)) {
|
||||
$lat = (float) $rawLat;
|
||||
$lon = (float) $rawLon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out[$key] ??= ['key' => $key, 'country' => $country, 'label' => $label, 'name' => $name, 'detail' => $detail, 'requests' => 0, 'bytesOut' => 0, 'lat' => $lat, 'lon' => $lon];
|
||||
$out[$key]['requests'] += $requests;
|
||||
$out[$key]['bytesOut'] += (int) data_get($row, 'bytesOut', 0);
|
||||
}
|
||||
|
||||
if (! $hasCity) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$other = $out['__other__'] ?? null;
|
||||
unset($out['__other__']);
|
||||
$out = array_values($out);
|
||||
usort($out, fn (array $a, array $b): int => $b['requests'] <=> $a['requests']);
|
||||
|
||||
return $other === null ? $out : [...$out, $other];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker data for the analytics globe, one set for each view of the Locations
|
||||
* card: a marker per country with a valid ISO-A2 code, and a marker per city
|
||||
* that has coordinates.
|
||||
*
|
||||
* @param iterable<int, mixed> $countryRows
|
||||
* @param iterable<int, mixed> $cityRows
|
||||
* @return array{countries: array<int, array{code: string, requests: int}>, cities: array<int, array{lat: float, lon: float, requests: int}>}
|
||||
*/
|
||||
function trafficGeoMarkers(iterable $countryRows, iterable $cityRows): array
|
||||
{
|
||||
$countries = [];
|
||||
foreach ($countryRows as $row) {
|
||||
$code = strtoupper((string) data_get($row, 'value', ''));
|
||||
$requests = (int) data_get($row, 'requests', 0);
|
||||
if ($requests > 0 && preg_match('/^[A-Z]{2}$/', $code) === 1) {
|
||||
$countries[] = ['code' => $code, 'requests' => $requests];
|
||||
}
|
||||
}
|
||||
|
||||
$cities = [];
|
||||
foreach ($cityRows as $row) {
|
||||
$requests = (int) data_get($row, 'requests', 0);
|
||||
$lat = data_get($row, 'lat');
|
||||
$lon = data_get($row, 'lon');
|
||||
if ($requests > 0 && is_numeric($lat) && is_numeric($lon)) {
|
||||
$cities[] = ['lat' => (float) $lat, 'lon' => (float) $lon, 'requests' => $requests];
|
||||
}
|
||||
}
|
||||
|
||||
return ['countries' => $countries, 'cities' => $cities];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an ISO 3166-1 alpha-2 country code to its English country name.
|
||||
*
|
||||
|
||||
@@ -85,6 +85,12 @@ function allowedPathsForUnsubscribedAccounts()
|
||||
// Account basics stay available without a paid plan.
|
||||
'profile',
|
||||
'profile/appearance',
|
||||
'profile/passkeys/confirm',
|
||||
'user/confirm-password',
|
||||
'user/passkeys',
|
||||
'user/passkeys/options',
|
||||
'passkeys/confirm',
|
||||
'passkeys/confirm/options',
|
||||
];
|
||||
}
|
||||
function allowedPathsForBoardingAccounts()
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Get cached versions data from versions.json.
|
||||
@@ -51,3 +56,103 @@ function invalidate_versions_cache(): void
|
||||
{
|
||||
Cache::forget('coolify:versions:all');
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP client for the Coolify CDN. It retries only transient failures (connection errors, 429, 5xx)
|
||||
* and does not throw for a failed response.
|
||||
*/
|
||||
function cdn_http(int $tries = 3): PendingRequest
|
||||
{
|
||||
return Http::timeout(10)
|
||||
->connectTimeout(10)
|
||||
->retry(
|
||||
$tries,
|
||||
fn (int $attempt, Throwable $exception) => cdn_retry_delay_ms($attempt, $exception),
|
||||
fn (Throwable $exception) => $exception instanceof ConnectionException
|
||||
|| ($exception instanceof RequestException
|
||||
&& ($exception->response->status() === 429 || $exception->response->serverError())),
|
||||
throw: false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait as long as the Retry-After header says (Cloudflare sends 60 seconds with error 1200),
|
||||
* or 2^attempt seconds without it. A random part of up to the same length is added, so instances
|
||||
* that failed in the same burst do not retry in the same second.
|
||||
*/
|
||||
function cdn_retry_delay_ms(int $attempt, Throwable $exception): int
|
||||
{
|
||||
$retryAfter = $exception instanceof RequestException ? (int) $exception->response->header('Retry-After') : 0;
|
||||
$seconds = $retryAfter > 0 ? min($retryAfter, 60) : 2 ** $attempt;
|
||||
|
||||
return ($seconds + random_int(0, $seconds)) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch versions.json from the CDN and write it to the local file of this node.
|
||||
* The Coolify version never goes below the cached or the running version.
|
||||
* When the CDN fails, the local file stays as it is.
|
||||
*
|
||||
* @return array|null The written versions data, or null if the CDN fetch failed
|
||||
*/
|
||||
function refresh_versions_file(int $tries = 3): ?array
|
||||
{
|
||||
$response = cdn_http($tries)->get(config('constants.coolify.versions_url'));
|
||||
$versions = $response->json();
|
||||
if ($response->failed() || ! is_array($versions)) {
|
||||
Log::warning('Could not fetch versions.json from the CDN, using the local file', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$latestVersion = data_get($versions, 'coolify.v4.version');
|
||||
$currentVersion = config('constants.coolify.version');
|
||||
|
||||
$existingCoolifyVersion = null;
|
||||
if (File::exists(base_path('versions.json'))) {
|
||||
$existingVersions = json_decode(File::get(base_path('versions.json')), true);
|
||||
$existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version');
|
||||
}
|
||||
|
||||
$bestVersion = $latestVersion;
|
||||
|
||||
if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $bestVersion, '>')) {
|
||||
Log::warning('CDN served older Coolify version than cache', [
|
||||
'cdn_version' => $latestVersion,
|
||||
'cached_version' => $existingCoolifyVersion,
|
||||
'current_version' => $currentVersion,
|
||||
]);
|
||||
$bestVersion = $existingCoolifyVersion;
|
||||
}
|
||||
|
||||
if (version_compare($bestVersion, $currentVersion, '<')) {
|
||||
Log::warning('Version downgrade prevented in CheckForUpdatesJob', [
|
||||
'cdn_version' => $latestVersion,
|
||||
'cached_version' => $existingCoolifyVersion,
|
||||
'current_version' => $currentVersion,
|
||||
'attempted_best' => $bestVersion,
|
||||
'using' => $currentVersion,
|
||||
]);
|
||||
$bestVersion = $currentVersion;
|
||||
}
|
||||
|
||||
data_set($versions, 'coolify.v4.version', $bestVersion);
|
||||
|
||||
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
|
||||
invalidate_versions_cache();
|
||||
update_helper_version($versions);
|
||||
|
||||
return $versions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the helper image version of the versions data when it is newer than the stored one.
|
||||
*/
|
||||
function update_helper_version(?array $versions): void
|
||||
{
|
||||
$latestVersion = data_get($versions, 'coolify.helper.version');
|
||||
$settings = instanceSettings();
|
||||
if ($latestVersion && version_compare($latestVersion, (string) $settings->helper_version, '>')) {
|
||||
$settings->update(['helper_version' => $latestVersion]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user