Merge remote-tracking branch 'origin/release/v4.5' into pr-12202-next

# Conflicts:
#	.ai/lessons.md
#	app/Actions/Server/CleanupDocker.php
#	app/Http/Controllers/Api/DatabasesController.php
#	app/Http/Controllers/Api/ServerCanvasController.php
#	app/Http/Controllers/Api/ServicesController.php
#	app/Http/Middleware/ApiIpAllowed.php
#	app/Jobs/VolumeBackupJob.php
#	app/Livewire/Concerns/RemembersCanvasView.php
#	app/Livewire/Fleet/Metrics.php
#	app/Livewire/Project/Database/BackupEdit.php
#	app/Livewire/Project/Service/BackupExecutions.php
#	app/Livewire/Project/Shared/EnvironmentVariable/All.php
#	app/Livewire/Project/Shared/Storages/VolumeBackups.php
#	app/Livewire/Security/ApiTokens.php
#	app/Livewire/Tags/Show.php
#	app/Models/ApplicationSetting.php
#	app/Models/Service.php
#	app/Rules/ValidGitRepositoryUrl.php
#	app/Services/EnvironmentCanvas.php
#	app/Services/ProjectCanvas.php
#	app/Services/ServerCanvas.php
#	app/Services/ServerTransfer/ServerTransferExporter.php
#	app/Services/ServerTransfer/ServerTransferImporter.php
#	app/Services/ServerTransfer/ServerTransferMigrator.php
#	app/Services/SharedVariableCanvas.php
#	app/Traits/ExecutesDatabaseStartCommands.php
#	app/Traits/HasS3Replicas.php
#	bootstrap/helpers/shared.php
#	config/constants.php
#	other/nightly/versions.json
#	resources/css/app.css
#	resources/js/canvas-core.js
#	resources/js/canvas/deploy/DeployCanvas.svelte
#	resources/js/canvas/deploy/state.svelte.js
#	resources/js/canvas/deploy/styles.js
#	resources/js/canvas/environment/Card.svelte
#	resources/js/canvas/environment/EnvironmentCanvas.svelte
#	resources/js/canvas/environment/Panel.svelte
#	resources/js/canvas/environment/StatusDot.svelte
#	resources/js/canvas/environment/Toolbar.svelte
#	resources/js/canvas/environment/Zone.svelte
#	resources/js/deploy-canvas.test.js
#	resources/js/environment-canvas.js
#	resources/js/environment-canvas.test.js
#	resources/views/components/canvas-view-toggle.blade.php
#	resources/views/components/navbar.blade.php
#	resources/views/components/server/sidebar.blade.php
#	resources/views/components/shared-variables/layout.blade.php
#	resources/views/components/shared-variables/sidebar.blade.php
#	resources/views/components/top-user-menu.blade.php
#	resources/views/layouts/app.blade.php
#	resources/views/livewire/fleet/metrics.blade.php
#	resources/views/livewire/project/database/backup-edit/s3.blade.php
#	resources/views/livewire/project/database/backup-executions.blade.php
#	resources/views/livewire/project/environment-canvas.blade.php
#	resources/views/livewire/project/new/select.blade.php
#	resources/views/livewire/project/project-canvas.blade.php
#	resources/views/livewire/project/projects-canvas.blade.php
#	resources/views/livewire/project/shared/environment-variable/all.blade.php
#	resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php
#	resources/views/livewire/security/api-tokens.blade.php
#	resources/views/livewire/server/canvas.blade.php
#	resources/views/livewire/server/index.blade.php
#	resources/views/livewire/server/navbar.blade.php
#	resources/views/livewire/server/partials/localhost-general.blade.php
#	resources/views/livewire/server/show.blade.php
#	resources/views/livewire/settings/advanced.blade.php
#	resources/views/livewire/shared-variables/canvas.blade.php
#	resources/views/livewire/tags/canvas.blade.php
#	resources/views/livewire/tags/show.blade.php
#	routes/web.php
#	tests/Feature/ApiDisabledCalloutTest.php
#	tests/Feature/BackupS3DestinationsFormTest.php
#	tests/Feature/DatabaseStartContentFilesTest.php
#	tests/Feature/DockerCleanupJobTest.php
#	tests/Feature/EnvironmentCanvasTest.php
#	tests/Feature/InstanceBackupMultipleS3DestinationsTest.php
#	tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php
#	tests/Feature/Livewire/Fleet/MetricsDataTest.php
#	tests/Feature/Mcp/McpEndpointTest.php
#	tests/Feature/Mcp/McpToggleApiTest.php
#	tests/Feature/ProjectCanvasTest.php
#	tests/Feature/ServerCanvasTest.php
#	tests/Feature/SharedVariableCanvasTest.php
#	tests/Feature/VolumeBackupTest.php
#	tests/Unit/ScpStyleGitUrlTest.php
#	tests/Unit/ServerTransfer/ServerTransferS3DestinationsTest.php
#	tests/v4/Browser/DeployCanvasTest.php
#	versions.json
This commit is contained in:
Andras Bacsai
2026-10-19 10:00:21 +02:00
525 changed files with 51706 additions and 5132 deletions
+2
View File
@@ -81,6 +81,8 @@ const SPECIFIC_SERVICES = [
'minio/minio',
'ghcr.io/coollabsio/minio',
'coollabsio/minio',
'pgsty/silo',
'docker.io/pgsty/silo',
'svhd/logto',
'dxflrs/garage',
];
+7 -3
View File
@@ -7,6 +7,7 @@ use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Support\DomainPortOverrides;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
@@ -675,7 +676,7 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
$variables = collect($environment_variables);
$payload = collect([]);
switch ($type) {
case $type?->contains('minio'):
case $type?->contains(['minio', 'pgsty/silo']):
$MINIO_BROWSER_REDIRECT_URL = $variables->where('key', 'MINIO_BROWSER_REDIRECT_URL')->first();
$MINIO_SERVER_URL = $variables->where('key', 'MINIO_SERVER_URL')->first();
@@ -765,9 +766,12 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool
return false;
}
// Flags are stored without a port, so compare portless: https://app.example.com:3000 matches https://app.example.com.
$normalize = fn (string $url) => DomainPortOverrides::withoutPort(ValidationPatterns::normalizeApplicationDomainUrl($url));
return $noindex_domains
->map(fn (string $noindex_domain) => ValidationPatterns::normalizeApplicationDomainUrl($noindex_domain))
->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain));
->map($normalize)
->contains($normalize($domain));
}
/**
+42
View File
@@ -3,6 +3,7 @@
use App\Models\Application;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Support\Collection;
/**
@@ -591,6 +592,47 @@ function findServiceApplicationForEnvName(Service $resource, string $envServiceN
return $resource->applications()->where('name', $resolved)->first();
}
/**
* Check if a service copies a SERVICE_FQDN_ / SERVICE_URL_ declaration of the application named in it.
* The copy (same name, port and value; URL or FQDN) must not generate a domain or overwrite the variable (#11924).
* Declarations with another port or path share the host on purpose (for example Appwrite).
*
* @param array<string, mixed>|Collection<string, mixed> $services Parsed compose services
*/
function isCopiedServiceUrlDeclaration(
Service $resource,
array|Collection $services,
ServiceApplication|ServiceDatabase $current,
string $key,
): bool {
$owner = findServiceApplicationForEnvName($resource, parseServiceEnvironmentVariable($key)['service_name']);
if (! $owner || $owner->name === $current->name) {
return false;
}
// Same declaration sources as serviceParser: environment and build arguments.
$declarationsOf = fn (string $serviceName) => convertToKeyValueCollection(
collect(data_get($services, [$serviceName, 'environment'], []))
->merge(data_get($services, [$serviceName, 'build', 'args'], []))
);
$ownerDeclarations = $declarationsOf($owner->name);
$currentDeclarations = $declarationsOf($current->name);
if (! $currentDeclarations->has($key)) {
return false;
}
// SERVICE_URL_X_3000 and SERVICE_FQDN_X_3000 write the same variable pair, so either one counts.
$twinKey = str_starts_with($key, 'SERVICE_URL_')
? 'SERVICE_FQDN_'.substr($key, strlen('SERVICE_URL_'))
: 'SERVICE_URL_'.substr($key, strlen('SERVICE_FQDN_'));
$value = (string) $currentDeclarations->get($key);
return collect([$key, $twinKey])->contains(
fn (string $ownerKey) => $ownerDeclarations->has($ownerKey) && (string) $ownerDeclarations->get($ownerKey) === $value
);
}
/**
* Put/update a service domain entry under the original compose service name,
* removing legacy twin keys that resolve unambiguously to the same name.
+47 -25
View File
@@ -4,6 +4,8 @@ use App\Models\GithubApp;
use App\Models\PrivateKey;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
@@ -195,13 +197,20 @@ function generateGithubToken(GithubApp $source, string $type)
return match ($type) {
'jwt' => $jwt,
'installation' => (function () use ($source, $jwt) {
// GitHub sometimes answers with a 5xx page or drops the connection; a new token request is harmless.
$response = Http::GitSource($source->api_url)->withHeaders([
'Authorization' => "Bearer $jwt",
'Accept' => 'application/vnd.github.machine-man-preview+json',
])->post("{$source->api_url}/app/installations/{$source->installation_id}/access_tokens");
])->retry(
3,
fn (int $attempt) => $attempt * 1000,
fn (Throwable $exception) => $exception instanceof ConnectionException
|| ($exception instanceof RequestException && $exception->response->serverError()),
throw: false,
)->post("{$source->api_url}/app/installations/{$source->installation_id}/access_tokens");
if (! $response->successful()) {
$error = data_get($response->json(), 'message', 'no error message found');
$error = data_get($response->json(), 'message', "HTTP {$response->status()}");
if ($error === 'Not Found') {
$error = 'Repository not found. Is it moved or deleted?';
}
@@ -216,7 +225,7 @@ function generateGithubToken(GithubApp $source, string $type)
function generateGithubInstallationToken(GithubApp $source)
{
return generateGithubToken($source, 'installation');
return $source->installationToken();
}
function generateGithubJwt(GithubApp $source)
@@ -397,28 +406,34 @@ function loadRepositoryByPage(GithubApp $source, string $token, int $page)
'repositories' => $json['repositories'],
];
}
function getGithubCommitRangeFiles(?GithubApp $source, string $owner, string $repo, string $beforeSha, string $afterSha): array
/**
* Returns null when the full list of changed files is unknown (API error or truncated list).
*/
function getGithubCommitRangeFiles(?GithubApp $source, string $owner, string $repo, string $beforeSha, string $afterSha): ?array
{
try {
if (! $source) {
// Manual webhooks don't have GitHub App authentication
// Return empty array so watch paths are ignored (current behavior)
return [];
}
$endpoint = "/repos/{$owner}/{$repo}/compare/{$beforeSha}...{$afterSha}";
$response = githubApi($source, $endpoint, 'get', null, false);
if (! $response) {
return [];
}
$response = githubApi($source, $endpoint);
$files = collect(data_get($response, 'data.files', []));
return $files->pluck('filename')->filter()->values()->toArray();
} catch (Exception $e) {
// The compare API returns at most 300 files and cannot paginate them.
if ($files->count() >= 300) {
return null;
}
return [];
// A renamed file also changes its previous path.
return $files->flatMap(fn (mixed $file): array => [data_get($file, 'filename'), data_get($file, 'previous_filename')])
->filter(fn (mixed $filename): bool => is_string($filename) && $filename !== '')
->unique()
->values()
->toArray();
} catch (Exception $e) {
return null;
}
}
@@ -444,27 +459,34 @@ function getGithubCommitMessage(?GithubApp $source, string $owner, string $repo,
}
}
function getGithubPullRequestFiles(?GithubApp $source, string $owner, string $repo, int $pullRequestId): array
/**
* Returns null when the full list of changed files is unknown (API error or truncated list).
*/
function getGithubPullRequestFiles(?GithubApp $source, string $owner, string $repo, int $pullRequestId): ?array
{
try {
if (! $source) {
// Manual webhooks don't have GitHub App authentication
// Return empty array so watch paths are ignored (current behavior)
return [];
}
$endpoint = "/repos/{$owner}/{$repo}/pulls/{$pullRequestId}/files";
$response = githubApi($source, $endpoint, 'get', null, false);
// GitHub returns at most 100 files per page and 3000 files per pull request.
$perPage = 100;
$maxPages = 30;
$files = collect();
if (! $response) {
return [];
for ($page = 1; $page <= $maxPages; $page++) {
$endpoint = "/repos/{$owner}/{$repo}/pulls/{$pullRequestId}/files?per_page={$perPage}&page={$page}";
$pageFiles = collect(data_get(githubApi($source, $endpoint), 'data', []));
$files = $files->merge($pageFiles->pluck('filename'));
if ($pageFiles->count() < $perPage) {
return $files->filter()->values()->toArray();
}
}
$files = collect(data_get($response, 'data', []));
return $files->pluck('filename')->filter()->values()->toArray();
return null;
} catch (Exception $e) {
return [];
return null;
}
}
+14 -6
View File
@@ -2031,7 +2031,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$coolifyEnvironments->put('COOLIFY_URL', $fqdns->map(fn ($fqdn) => getFqdnWithoutPort($fqdn))->implode(','));
$coolifyEnvironments->put('COOLIFY_FQDN', $fqdns->map(fn ($fqdn) => getHostWithoutPort($fqdn))->implode(','));
}
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
$userDefinedKeys = $resource->environment_variables->pluck('key')->merge($environment->keys());
coolifyDefaultEnvironmentVariables($resource, $userDefinedKeys)
->each(fn ($value, $key) => $coolifyEnvironments->put($key, escapeDollarSign($value)));
if ($environment->count() > 0) {
$environment = $environment->filter(function ($value, $key) {
return ! str($key)->startsWith('SERVICE_FQDN_');
@@ -2547,6 +2549,10 @@ function serviceParser(Service $resource): Collection
$port = $parsed['port'];
$fqdnFor = $parsed['service_name'];
if (isCopiedServiceUrlDeclaration($resource, $services, $savedService, $key->value())) {
continue;
}
// Only ServiceApplication has fqdn column, ServiceDatabase does not
$isServiceApplication = $savedService instanceof ServiceApplication;
@@ -2667,8 +2673,8 @@ function serviceParser(Service $resource): Collection
// Also check if a port-suffixed version exists (e.g., SERVICE_FQDN_UMAMI_3000)
$portSuffixedExists = $resource->environment_variables()
->where('key', 'LIKE', $key->value().'_%')
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
->exists();
->pluck('key')
->contains(fn ($envKey) => preg_match('/^'.preg_quote($key->value(), '/').'_[0-9]+$/', $envKey) === 1);
$serviceExists = findServiceApplicationForEnvName($resource, (string) $fqdnFor);
// Check if FQDN already has a port set (contains ':' after the domain)
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
@@ -2712,8 +2718,8 @@ function serviceParser(Service $resource): Collection
// Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791)
$portSuffixedExists = $resource->environment_variables()
->where('key', 'LIKE', $key->value().'_%')
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
->exists();
->pluck('key')
->contains(fn ($envKey) => preg_match('/^'.preg_quote($key->value(), '/').'_[0-9]+$/', $envKey) === 1);
$serviceExists = findServiceApplicationForEnvName($resource, (string) $urlFor);
// Check if FQDN already has a port set (contains ':' after the domain)
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
@@ -3333,7 +3339,9 @@ function serviceParser(Service $resource): Collection
$coolifyEnvironments->put('COOLIFY_FQDN', $fqdns->map(fn ($fqdn) => getHostWithoutPort($fqdn))->implode(','));
$coolifyEnvironments->put('COOLIFY_URL', $fqdns->map(fn ($fqdn) => getFqdnWithoutPort($fqdn))->implode(','));
}
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
$userDefinedKeys = $resource->environment_variables->pluck('key')->merge($environment->keys());
coolifyDefaultEnvironmentVariables($resource, $userDefinedKeys)
->each(fn ($value, $key) => $coolifyEnvironments->put($key, escapeDollarSign($value)));
if ($environment->count() > 0) {
$environment = $environment->filter(function ($value, $key) {
return ! str($key)->startsWith('SERVICE_FQDN_');
+10 -2
View File
@@ -409,8 +409,16 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
// Those belong to other services and will be updated when THOSE services are updated
}
// Remove duplicates
$templateVariableNames = array_unique($templateVariableNames);
// Remove duplicates and copies of another application's declaration
$templateVariableNames = array_filter(
array_unique($templateVariableNames),
fn ($variableName) => ! isCopiedServiceUrlDeclaration(
$resource->service,
collect(data_get($dockerCompose, 'services', [])),
$resource,
$variableName,
),
);
// Extract unique service names to process (preserving the original case from template)
// This allows us to create both URL and FQDN pairs regardless of which one is in the template
+223 -94
View File
@@ -935,6 +935,15 @@ function s3_image_url(?int $storageId, ?string $path, int $version): ?string
return null;
}
// S3-compatible endpoints (e.g. Cloudflare R2) require authenticated requests for
// every object, including plain GETs, so an unauthenticated direct link only works
// when a public CDN has been configured in front of the bucket. Without one, callers
// must fall back to the app's own authenticated proxy route.
$cdnUrl = instanceSettings()->image_cdn_url;
if (blank($cdnUrl)) {
return null;
}
$storage = S3Storage::query()
->whereKey($storageId)
->whereTeamId(0)
@@ -945,9 +954,7 @@ function s3_image_url(?int $storageId, ?string $path, int $version): ?string
return null;
}
$baseUrl = instanceSettings()->image_cdn_url ?: $storage->awsUrl();
return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version;
return rtrim($cdnUrl, '/').'/'.ltrim($path, '/').'?v='.$version;
}
/**
@@ -1517,7 +1524,25 @@ function sslip(Server $server)
}
$ip = $server->ip === 'host.docker.internal' ? base_ip() : $server->ip;
return 'http://'.sslipHostLabel($ip).'.sslip.io';
return 'http://'.sslipHostLabel(resolveHostToIp($ip)).'.sslip.io';
}
/**
* Resolve a hostname to its IPv4 (or else IPv6) address. sslip.io only answers for names that
* contain an IP address. Returns the input unchanged if it is already an IP or does not resolve.
*/
function resolveHostToIp(string $host): string
{
if (filter_var(normalizeIpAddress($host), FILTER_VALIDATE_IP) !== false) {
return $host;
}
$ipv4 = gethostbyname($host);
if (filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
return $ipv4;
}
$ipv6 = data_get(@dns_get_record($host, DNS_AAAA), '0.ipv6');
return is_string($ipv6) && $ipv6 !== '' ? $ipv6 : $host;
}
function service_templates_cache_key(): string
@@ -2683,44 +2708,53 @@ function deduplicateAllowlist(array $entries): array
return $result;
}
function get_public_ips()
/**
* Detect the instance public IPs with ifconfig.io. Only empty settings are filled, so an address saved in Settings is kept.
*/
function get_public_ips(): void
{
try {
[$first, $second] = Process::concurrently(function (Pool $pool) {
$pool->path(__DIR__)->command('curl -4s https://ifconfig.io');
$pool->path(__DIR__)->command('curl -6s https://ifconfig.io');
});
$ipv4 = $first->output();
if ($ipv4) {
$ipv4 = trim($ipv4);
$validate_ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
if ($validate_ipv4 == false) {
echo "Invalid ipv4: $ipv4\n";
return;
}
InstanceSettings::get()->update(['public_ipv4' => $ipv4]);
$settings = InstanceSettings::get();
$columns = array_values(array_filter(['public_ipv4', 'public_ipv6'], fn (string $column) => blank($settings->$column)));
if ($columns === []) {
return;
}
} catch (Exception $e) {
echo "Error: {$e->getMessage()}\n";
}
try {
$ipv6 = $second->output();
if ($ipv6) {
$ipv6 = trim($ipv6);
$validate_ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
if ($validate_ipv6 == false) {
echo "Invalid ipv6: $ipv6\n";
return;
}
InstanceSettings::get()->update(['public_ipv6' => $ipv6]);
$detected = detect_public_ips($columns);
if ($detected !== []) {
$settings->update($detected);
}
} catch (Throwable $e) {
echo "Error: {$e->getMessage()}\n";
}
}
/**
* Look up the public IPs of this host with ifconfig.io, without saving them.
*
* @param list<'public_ipv4'|'public_ipv6'> $columns
* @return array<'public_ipv4'|'public_ipv6', string> valid addresses only
*/
function detect_public_ips(array $columns = ['public_ipv4', 'public_ipv6']): array
{
$versions = array_intersect_key(['public_ipv4' => 4, 'public_ipv6' => 6], array_flip($columns));
$results = Process::concurrently(function (Pool $pool) use ($versions) {
foreach ($versions as $column => $version) {
$pool->as($column)->path(__DIR__)->command("curl -{$version}s https://ifconfig.io");
}
});
$detected = [];
foreach ($versions as $column => $version) {
$ip = trim($results[$column]->output());
if (filter_var($ip, FILTER_VALIDATE_IP, $version === 4 ? FILTER_FLAG_IPV4 : FILTER_FLAG_IPV6) !== false) {
$detected[$column] = $ip;
}
}
return $detected;
}
function isAnyDeploymentInprogress(bool $showAll = false)
{
$runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get();
@@ -3624,19 +3658,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$parsedServiceVariables->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
$parsedServiceVariables->put('COOLIFY_CONTAINER_NAME', "$serviceName-{$resource->uuid}");
// TODO: move this in a shared function
if (! $parsedServiceVariables->has('COOLIFY_APP_NAME')) {
$parsedServiceVariables->put('COOLIFY_APP_NAME', "\"{$resource->name}\"");
}
if (! $parsedServiceVariables->has('COOLIFY_SERVER_IP')) {
$parsedServiceVariables->put('COOLIFY_SERVER_IP', "\"{$resource->destination->server->ip}\"");
}
if (! $parsedServiceVariables->has('COOLIFY_ENVIRONMENT_NAME')) {
$parsedServiceVariables->put('COOLIFY_ENVIRONMENT_NAME', "\"{$resource->environment->name}\"");
}
if (! $parsedServiceVariables->has('COOLIFY_PROJECT_NAME')) {
$parsedServiceVariables->put('COOLIFY_PROJECT_NAME', "\"{$resource->project()->name}\"");
}
coolifyDefaultEnvironmentVariables($resource, $parsedServiceVariables->keys())
->each(fn ($value, $key) => $parsedServiceVariables->put($key, escapeDollarSign($value)));
$parsedServiceVariables = $parsedServiceVariables->map(function ($value, $key) use ($envs_from_coolify) {
if (! str($value)->startsWith('$')) {
@@ -4509,56 +4532,32 @@ function isAssociativeArray($array)
}
/**
* This method adds the default environment variables to the resource.
* - COOLIFY_APP_NAME
* - COOLIFY_PROJECT_NAME
* - COOLIFY_SERVER_IP
* - COOLIFY_ENVIRONMENT_NAME
* The variables that tell a container where it runs. Values are raw: each caller escapes them
* for its output (compose file, .env file, or build arguments). Keys in $definedKeys are
* skipped, so a variable that the user defines wins. Pass $server when the container runs on
* another server than the main destination (an application on an additional server).
*
* Theses variables are added in place to the $where_to_add array.
* @param iterable<int, string> $definedKeys
* @return Collection<string, string>
*/
function add_coolify_default_environment_variables(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite|Application|Service $resource, Collection &$where_to_add, ?Collection $where_to_check = null)
function coolifyDefaultEnvironmentVariables(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite|Application|Service $resource, iterable $definedKeys = [], ?Server $server = null): Collection
{
// Currently disabled
return;
if ($resource instanceof Service) {
$ip = $resource->server->ip;
} else {
$ip = $resource->destination->server->ip;
}
if (isAssociativeArray($where_to_add)) {
$isAssociativeArray = true;
} else {
$isAssociativeArray = false;
}
if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_APP_NAME')->isEmpty()) {
if ($isAssociativeArray) {
$where_to_add->put('COOLIFY_APP_NAME', "\"{$resource->name}\"");
} else {
$where_to_add->push("COOLIFY_APP_NAME=\"{$resource->name}\"");
}
}
if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_SERVER_IP')->isEmpty()) {
if ($isAssociativeArray) {
$where_to_add->put('COOLIFY_SERVER_IP', "\"{$ip}\"");
} else {
$where_to_add->push("COOLIFY_SERVER_IP=\"{$ip}\"");
}
}
if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_ENVIRONMENT_NAME')->isEmpty()) {
if ($isAssociativeArray) {
$where_to_add->put('COOLIFY_ENVIRONMENT_NAME', "\"{$resource->environment->name}\"");
} else {
$where_to_add->push("COOLIFY_ENVIRONMENT_NAME=\"{$resource->environment->name}\"");
}
}
if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_PROJECT_NAME')->isEmpty()) {
if ($isAssociativeArray) {
$where_to_add->put('COOLIFY_PROJECT_NAME', "\"{$resource->project()->name}\"");
} else {
$where_to_add->push("COOLIFY_PROJECT_NAME=\"{$resource->project()->name}\"");
}
// A model created in this request has no column default loaded yet (null), which means on.
$isEnabled = $resource instanceof Application
? $resource->settings?->include_coolify_default_variables
: $resource->include_coolify_default_variables;
if ($isEnabled === false) {
return collect();
}
$server ??= $resource instanceof Service ? $resource->server : $resource->destination?->server;
return collect([
'COOLIFY_APP_NAME' => $resource->name,
'COOLIFY_PROJECT_NAME' => $resource->project()?->name,
'COOLIFY_ENVIRONMENT_NAME' => $resource->environment?->name,
'COOLIFY_SERVER_IP' => $server?->ip,
])->whereNotNull()->map(fn ($value) => (string) $value)->except(collect($definedKeys)->all());
}
function convertToKeyValueCollection($environment)
@@ -4620,6 +4619,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.
@@ -4869,8 +4882,15 @@ function parseScpStyleGitUrl(?string $gitRepository): ?array
];
}
function scpStyleGitUrlToHttps(?string $gitRepository): ?string
function sshGitUrlToHttps(?string $gitRepository): ?string
{
if (is_string($gitRepository) && str_starts_with($gitRepository, 'ssh://')) {
$host = parse_url($gitRepository, PHP_URL_HOST);
$path = ltrim((string) parse_url($gitRepository, PHP_URL_PATH), '/');
return filled($host) && $path !== '' ? "https://{$host}/{$path}" : null;
}
$parts = parseScpStyleGitUrl($gitRepository);
if ($parts === null) {
@@ -4886,8 +4906,8 @@ function gitRepositorySlug(?string $gitRepository): string
return '';
}
if (($scp = parseScpStyleGitUrl($gitRepository)) !== null) {
$gitRepository = $scp['path'];
if (($httpsRepository = sshGitUrlToHttps($gitRepository)) !== null) {
$gitRepository = (string) parse_url($httpsRepository, PHP_URL_PATH);
} elseif (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) {
$gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', '');
}
@@ -5753,6 +5773,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.
*