mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
@@ -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,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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_');
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user