Merge remote-tracking branch 'origin/next' into feat/noindex-domains

This commit is contained in:
Andras Bacsai committed 2026-08-08 11:26:20 +02:00
commit 003e44d123
1274 files changed
+156179 -25249

No files matched your search

Regular → Executable
View File
File mode changed.
+96
View File
@@ -1,9 +1,12 @@
<?php
use App\Actions\Shared\MigrateResourceToDestination;
use App\Enums\BuildPackTypes;
use App\Enums\RedirectTypes;
use App\Enums\StaticImageTypes;
use App\Models\Environment;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Collection;
@@ -13,6 +16,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
function getTeamIdFromToken()
{
@@ -129,6 +133,16 @@ function sharedDataApplications()
'is_gzip_enabled' => 'boolean',
'is_stripprefix_enabled' => 'boolean',
'is_raw_compose_deployment_enabled' => 'boolean',
'is_log_drain_enabled' => 'boolean',
'is_gpu_enabled' => 'boolean',
'gpu_driver' => 'string|nullable',
'gpu_count' => 'string|nullable',
'gpu_device_ids' => 'string|nullable',
'gpu_options' => 'string|nullable',
'is_consistent_container_name_enabled' => 'boolean',
'custom_internal_name' => 'string|nullable',
'preview_url_template' => 'string',
'max_restart_count' => 'integer|min:0',
'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS,
'docker_images_to_keep' => 'integer|min:0|max:100',
'static_image' => Rule::enum(StaticImageTypes::class),
@@ -250,6 +264,80 @@ function moveResourceToEnvironment(Request $request, $resource, string $resource
]);
}
function migrateResourceToDestination(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
{
if (! isDev()) {
abort(404);
}
$validator = Validator::make($request->all(), [
'destination_uuid' => 'required|string',
'migrate_volumes' => 'boolean',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$allowedFields = ['destination_uuid', 'migrate_volumes'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if (! empty($extraFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(),
], 422);
}
Gate::authorize('update', $resource);
$destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first()
?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first();
if (! $destination || ! $destination->server?->canHostResources()) {
return response()->json(['message' => 'Destination not found.'], 404);
}
$sourceDestination = $resource->destination;
$migrateVolumes = $request->boolean('migrate_volumes', true);
try {
$result = MigrateResourceToDestination::run(
$resource,
$destination,
$migrateVolumes,
);
} catch (ValidationException $e) {
return response()->json([
'message' => collect($e->errors())->flatten()->first() ?? $e->getMessage(),
'errors' => $e->errors(),
], 422);
}
auditLog('api.'.str($resourceType)->lower()->value().'.migrated', [
'team_id' => $teamId,
'resource_uuid' => $resource->uuid,
'resource_type' => str($resourceType)->lower()->value(),
'from_destination_uuid' => $sourceDestination?->uuid,
'to_destination_uuid' => $destination->uuid,
'from_server_id' => $sourceDestination?->server_id,
'to_server_id' => $destination->server_id,
'migrate_volumes' => $migrateVolumes,
'async' => $result['async'],
'volume_jobs' => $result['volume_jobs'],
]);
return response()->json([
'message' => $result['message'],
'uuid' => $resource->uuid,
'destination_uuid' => $destination->uuid,
'async' => $result['async'],
'volume_jobs' => $result['volume_jobs'],
]);
}
function validateIncomingRequest(Request $request)
{
// check if request is json
@@ -312,6 +400,14 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('is_gzip_enabled');
$request->offsetUnset('is_stripprefix_enabled');
$request->offsetUnset('is_raw_compose_deployment_enabled');
$request->offsetUnset('is_log_drain_enabled');
$request->offsetUnset('is_gpu_enabled');
$request->offsetUnset('gpu_driver');
$request->offsetUnset('gpu_count');
$request->offsetUnset('gpu_device_ids');
$request->offsetUnset('gpu_options');
$request->offsetUnset('is_consistent_container_name_enabled');
$request->offsetUnset('custom_internal_name');
$request->offsetUnset('docker_compose_raw');
$request->offsetUnset('tags');
}
+1
View File
@@ -220,6 +220,7 @@ function clone_application(Application $source, $destination, array $overrides =
'fqdn' => $url,
'status' => 'exited',
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
], $overrides));
$newApplication->save();
+49 -6
View File
@@ -17,6 +17,8 @@ use App\Models\SwarmDocker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
use Symfony\Component\HttpFoundation\StreamedResponse;
function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
{
@@ -179,7 +181,7 @@ function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDoc
return $database;
}
function deleteBackupsLocally(string|array|null $filenames, Server $server): void
function deleteBackupsLocally(string|array|null $filenames, Server $server, bool $throwError = false): void
{
if (empty($filenames)) {
return;
@@ -187,13 +189,48 @@ function deleteBackupsLocally(string|array|null $filenames, Server $server): voi
if (is_string($filenames)) {
$filenames = [$filenames];
}
$quotedFiles = array_map(fn ($file) => "\"$file\"", $filenames);
instant_remote_process(['rm -f '.implode(' ', $quotedFiles)], $server, throwError: false);
$quotedFiles = array_map(fn ($file) => escapeshellarg($file), $filenames);
instant_remote_process(['rm -f '.implode(' ', $quotedFiles)], $server, throwError: $throwError);
$foldersToCheck = collect($filenames)->map(fn ($file) => dirname($file))->unique();
$foldersToCheck->each(fn ($folder) => deleteEmptyBackupFolder($folder, $server));
}
function streamBackupFromServer(Server $server, string $filename, string $contentType): StreamedResponse
{
$disk = Storage::build([
'driver' => 'sftp',
'host' => $server->ip,
'port' => (int) $server->port,
'username' => $server->user,
'privateKey' => $server->privateKey->getKeyLocation(),
'root' => '/',
]);
if (! $disk->exists($filename)) {
throw new FileNotFoundException($filename);
}
return new StreamedResponse(function () use ($disk, $filename) {
if (ob_get_level()) {
ob_end_clean();
}
$stream = $disk->readStream($filename);
if ($stream === false || is_null($stream)) {
abort(500, 'Failed to open stream for the requested file.');
}
while (! feof($stream)) {
echo fread($stream, 2048);
flush();
}
fclose($stream);
}, 200, [
'Content-Type' => $contentType,
'Content-Disposition' => 'attachment; filename="'.basename($filename).'"',
]);
}
function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void
{
if (empty($filenames) || ! $s3) {
@@ -214,7 +251,9 @@ function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void
'aws_url' => $s3->awsUrl(),
]);
$disk->delete($filenames);
if (! $disk->delete($filenames)) {
throw new RuntimeException('One or more S3 backup files could not be deleted.');
}
}
function deleteEmptyBackupFolder($folderPath, Server $server): void
@@ -428,8 +467,12 @@ function deleteOldBackupsFromS3($backup): Collection
->all();
if (! empty($filesToDelete)) {
deleteBackupsS3($filesToDelete, $backup->s3);
$processedBackups = $backupsToDelete;
try {
deleteBackupsS3($filesToDelete, $backup->s3);
$processedBackups = $backupsToDelete;
} catch (Throwable $e) {
report($e);
}
}
return $processedBackups;
+37 -4
View File
@@ -11,6 +11,37 @@ use Illuminate\Support\Str;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
/**
* Stable short hash for compose service names used in Traefik router/service IDs.
* Keeps api.test vs api-test distinct after label-safe normalization.
*/
function traefikServiceNameHash(string $serviceName): string
{
return substr(md5($serviceName), 0, 4);
}
/**
* Label-safe Traefik router/service name segment for a compose service.
* Dots (and other non [a-zA-Z0-9-]) break Docker/Traefik label paths; always append a hash
* so normalized names like api.test and api-test never collide.
*/
function traefikSafeServiceNameSegment(string $serviceName): string
{
$normalized = str($serviceName)
->replace('.', '-')
->replace('_', '-')
->replaceMatches('/[^a-zA-Z0-9-]+/', '-')
->replaceMatches('/-+/', '-')
->trim('-')
->toString();
if ($normalized === '') {
$normalized = 'service';
}
return $normalized.'-'.traefikServiceNameHash($serviceName);
}
function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection
{
$containers = collect([]);
@@ -529,8 +560,10 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$http_label = "http-{$loop}-{$uuid}";
$https_label = "https-{$loop}-{$uuid}";
if ($service_name) {
$http_label = "http-{$loop}-{$uuid}-{$service_name}";
$https_label = "https-{$loop}-{$uuid}-{$service_name}";
// Dots in service names split Traefik label paths; hash avoids api.test/api-test collisions.
$safeServiceName = traefikSafeServiceNameSegment($service_name);
$http_label = "http-{$loop}-{$uuid}-{$safeServiceName}";
$https_label = "https-{$loop}-{$uuid}-{$safeServiceName}";
}
if (str($image)->contains('ghost')) {
$labels->push("traefik.http.middlewares.redir-ghost-{$uuid}.redirectregex.regex=^{$path}/(.*)");
@@ -550,12 +583,12 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$to_non_www_name = "{$loop}-{$uuid}-to-non-www";
$redirect_to_non_www = [
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.regex=^(http|https)://www\.(.+)",
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement=\${1}://\${2}",
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement=\$\${1}://\$\${2}",
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.permanent=false",
];
$redirect_to_www = [
"traefik.http.middlewares.{$to_www_name}.redirectregex.regex=^(http|https)://(?:www\.)?(.+)",
"traefik.http.middlewares.{$to_www_name}.redirectregex.replacement=\${1}://www.\${2}",
"traefik.http.middlewares.{$to_www_name}.redirectregex.replacement=\$\${1}://www.\$\${2}",
"traefik.http.middlewares.{$to_www_name}.redirectregex.permanent=false",
];
if ($schema === 'https') {
+294
View File
@@ -1,9 +1,25 @@
<?php
use App\Models\Application;
use App\Models\Service;
use App\Models\ServiceApplication;
use Illuminate\Support\Collection;
/**
* Count domains in a comma-separated FQDN string (e.g. "https://a.com,https://b.com").
*/
function countDomains(?string $fqdn): int
{
if (! filled($fqdn)) {
return 0;
}
return collect(explode(',', $fqdn))
->map(fn ($d) => trim($d))
->filter()
->count();
}
function isValidDomainUrl(string $url): bool
{
$components = parse_url($url);
@@ -310,3 +326,281 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
'hasConflicts' => count($conflicts) > 0,
];
}
/**
* Normalize a compose service name the way Coolify historically did for env var keys
* (hyphens and dots → underscores). Used for comparison and SERVICE_* env names only —
* docker_compose_domains keys should use the original compose service name.
*/
function normalizeComposeServiceName(string $serviceName): string
{
return str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
}
/**
* Resolve a candidate key (original or legacy-normalized) to the original compose service name.
*
* @param iterable<int|string, mixed> $serviceNames
*/
function findComposeServiceName(string $candidate, iterable $serviceNames): ?string
{
$names = collect($serviceNames)->map(fn ($name) => (string) $name)->values();
if ($names->containsStrict($candidate)) {
return $candidate;
}
$normalized = normalizeComposeServiceName($candidate);
$matches = $names->filter(
fn ($name) => normalizeComposeServiceName($name) === $normalized
)->values();
return $matches->count() === 1 ? $matches->first() : null;
}
/**
* Collapse domain-map keys that only differ by hyphen/dot/underscore into one preferred name.
* Prefers a key that still has `-` or `.` over a fully underscore-normalized twin.
*
* @param iterable<int|string, mixed> $domainKeys
* @return list<string>
*/
function preferredComposeServiceNamesFromDomainKeys(iterable $domainKeys): array
{
$groups = [];
foreach ($domainKeys as $key) {
$key = (string) $key;
$groups[normalizeComposeServiceName($key)][] = $key;
}
$preferred = [];
foreach ($groups as $normalized => $keys) {
$keys = array_values(array_unique($keys));
$withSeparators = array_values(array_filter(
$keys,
fn (string $key) => $key !== $normalized
));
$preferred[] = $withSeparators[0] ?? $keys[0];
}
return $preferred;
}
/**
* Read domain string for a compose service from docker_compose_domains.
* Prefers a filled domain on the requested key; falls back to any filled twin
* (legacy underscore keys). Blank entries do not shadow filled twins.
* Uses collection key access (not dotted data_get) so names like "api.test" work.
*
* @param array<string, mixed>|Collection<string, mixed> $domains
*/
function getComposeServiceDomainString(array|Collection $domains, string $serviceName): ?string
{
$domains = collect($domains);
$normalized = normalizeComposeServiceName($serviceName);
$matches = [];
foreach ($domains as $key => $entry) {
$key = (string) $key;
if ($key !== $serviceName
&& $key !== $normalized
&& normalizeComposeServiceName($key) !== $normalized) {
continue;
}
$matches[] = [
'key' => $key,
'domain' => composeDomainEntryString($entry),
'is_requested' => $key === $serviceName,
];
}
if ($matches === []) {
return null;
}
$filled = array_values(array_filter(
$matches,
fn (array $match) => filled($match['domain'])
));
if ($filled !== []) {
foreach ($filled as $match) {
if ($match['is_requested']) {
return $match['domain'];
}
}
return $filled[0]['domain'];
}
foreach ($matches as $match) {
if ($match['is_requested']) {
return $match['domain'];
}
}
return $matches[0]['domain'];
}
function composeDomainEntryString(mixed $entry): ?string
{
if (is_object($entry)) {
$entry = (array) $entry;
}
if (! is_array($entry)) {
return is_string($entry) ? $entry : null;
}
$domain = $entry['domain'] ?? null;
return is_string($domain) ? $domain : null;
}
/**
* Choose which domain string wins when merging twin compose domain keys.
* Filled values always beat blanks; when both are filled, prefer the canonical key.
*/
function preferComposeDomainValue(
mixed $existingDomain,
bool $existingIsCanonical,
mixed $incomingDomain,
bool $incomingIsCanonical,
): mixed {
$existingFilled = filled($existingDomain);
$incomingFilled = filled($incomingDomain);
if ($existingFilled && ! $incomingFilled) {
return $existingDomain;
}
if ($incomingFilled && ! $existingFilled) {
return $incomingDomain;
}
if ($existingFilled && $incomingFilled) {
if ($incomingIsCanonical && ! $existingIsCanonical) {
return $incomingDomain;
}
return $existingDomain;
}
// Both blank: keep canonical slot when possible.
if ($incomingIsCanonical) {
return $incomingDomain;
}
return $existingDomain;
}
/**
* Rekey docker_compose_domains to original compose service names.
* Merges legacy underscore/dot twin keys onto the canonical compose name.
* Never lets an empty canonical key wipe a filled legacy twin.
*
* @param array<string, mixed>|Collection<string, mixed> $domains
* @param iterable<int|string, mixed> $serviceNames
* @return array<string, mixed>
*/
function rekeyComposeDomainsToServiceNames(array|Collection $domains, iterable $serviceNames): array
{
$domains = collect($domains);
$serviceNames = collect($serviceNames)->map(fn ($name) => (string) $name)->values();
$rekeyed = [];
$canonicalDomainSources = [];
foreach ($domains as $key => $value) {
$key = (string) $key;
$original = findComposeServiceName($key, $serviceNames) ?? $key;
$isCanonical = $key === $original;
if (is_object($value)) {
$value = (array) $value;
}
if (! is_array($value)) {
$value = ['domain' => $value];
}
if (! isset($rekeyed[$original])) {
$rekeyed[$original] = $value;
$canonicalDomainSources[$original] = $isCanonical;
continue;
}
$existingDomain = $rekeyed[$original]['domain'] ?? null;
$incomingDomain = $value['domain'] ?? null;
$rekeyed[$original] = array_merge($rekeyed[$original], $value);
$rekeyed[$original]['domain'] = preferComposeDomainValue(
$existingDomain,
$canonicalDomainSources[$original],
$incomingDomain,
$isCanonical,
);
$canonicalDomainSources[$original] = $canonicalDomainSources[$original] || $isCanonical;
}
return $rekeyed;
}
/**
* Resolve a SERVICE_FQDN_ / SERVICE_URL_ env fragment to a ServiceApplication by compose name.
* Handles legacy underscore fragments and dotted compose names (api.test vs API_TEST).
*/
function findServiceApplicationForEnvName(Service $resource, string $envServiceName): ?ServiceApplication
{
$names = $resource->applications()->pluck('name');
$resolved = findComposeServiceName($envServiceName, $names)
?? findComposeServiceName(str($envServiceName)->replace('_', '-')->toString(), $names);
if ($resolved === null) {
return null;
}
return $resource->applications()->where('name', $resolved)->first();
}
/**
* Put/update a service domain entry under the original compose service name,
* removing legacy twin keys that resolve unambiguously to the same name.
*
* @param array<string, mixed>|Collection<string, mixed> $domains
* @param iterable<int|string, mixed> $serviceNames
* @return array<string, mixed>
*/
function putComposeServiceDomain(
array|Collection $domains,
string $serviceName,
?string $domainString,
iterable $serviceNames = [],
): array {
$domains = collect($domains)->all();
$names = collect($serviceNames)->map(fn ($name) => (string) $name);
$storageKey = findComposeServiceName($serviceName, $names) ?? $serviceName;
$merged = ['domain' => $domainString];
foreach (array_keys($domains) as $key) {
$key = (string) $key;
if ($key !== $storageKey && findComposeServiceName($key, $names) !== $storageKey) {
continue;
}
$existing = $domains[$key];
if (is_object($existing)) {
$existing = (array) $existing;
}
if (is_array($existing)) {
$merged = array_merge($existing, $merged);
}
if ($key !== $storageKey) {
unset($domains[$key]);
}
}
$domains[$storageKey] = $merged;
return $domains;
}
+6 -3
View File
@@ -8,9 +8,12 @@ function normalize_email_identity(?string $email): ?string
return null;
}
[$localPart, $domain] = explode('@', Str::lower($email), 2);
$localPart = Str::before($localPart, '+');
$localPart = str_replace('.', '', $localPart);
[$localPart, $domain] = explode('@', Str::lower(trim($email)), 2);
if (in_array($domain, ['gmail.com', 'googlemail.com'], true)) {
$localPart = Str::before($localPart, '+');
$localPart = str_replace('.', '', $localPart);
}
if (blank($localPart) || blank($domain)) {
return null;
+1 -2
View File
@@ -1,7 +1,6 @@
<?php
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
@@ -216,7 +215,7 @@ function generateGithubJwt(GithubApp $source)
return generateGithubToken($source, 'jwt');
}
function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
function githubApi(?GithubApp $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
{
if (is_null($source)) {
throw new Exception('Source is required for API calls');
+162
View File
@@ -0,0 +1,162 @@
<?php
use App\Models\GitlabApp;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
function refreshGitlabToken(GitlabApp $source): void
{
if (! $source->refresh_token) {
throw new RuntimeException('GitLab source has no refresh token. Please reconnect the GitLab app.');
}
$safetyMargin = 60;
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
return;
}
$lock = Cache::lock("gitlab_token_refresh_{$source->id}", 20);
if (! $lock->block(20)) {
$source->refresh();
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
return;
}
throw new RuntimeException('GitLab 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::asForm()->post("{$baseUrl}/oauth/token", [
'client_id' => $source->client_id,
'client_secret' => $source->client_secret,
'refresh_token' => $source->refresh_token,
'grant_type' => 'refresh_token',
'redirect_uri' => $source->redirect_uri,
]);
if (! $response->successful()) {
$error = data_get($response->json(), 'error_description', $response->body());
throw new RuntimeException("Failed to refresh GitLab token: {$error}");
}
$data = $response->json();
$source->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'],
'expires_at' => time() + ($data['expires_in'] ?? 7200),
]);
} finally {
$lock->release();
}
}
function gitlabApi(GitlabApp $source, string $endpoint, string $method = 'get', ?array $data = null): array
{
refreshGitlabToken($source);
$apiUrl = $source->apiUrlBase();
$client = Http::GitLab($apiUrl, $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("GitLab API call failed: {$errorMessage}");
}
return [
'data' => collect($response->json()),
'total' => (int) $response->header('x-total', 0),
];
}
function generateGitlabCloneToken(GitlabApp $source): string
{
refreshGitlabToken($source);
return $source->access_token;
}
function loadGitlabRepositories(GitlabApp $source, int $page = 1): array
{
refreshGitlabToken($source);
$apiUrl = $source->apiUrlBase();
$response = Http::GitLab($apiUrl, $source->access_token)
->timeout(20)
->retry(3, 200, throw: false)
->get('/projects', [
'membership' => 'true',
'per_page' => 100,
'page' => $page,
'order_by' => 'name',
'sort' => 'asc',
]);
if (! $response->successful()) {
return ['total_count' => 0, 'has_more' => false, 'repositories' => []];
}
$projects = collect($response->json());
$rawCount = count($response->json());
$hasMore = $rawCount === 100;
$groupName = $source->group_name;
if (! empty($groupName)) {
$groups = collect(explode(',', $groupName))->map(fn ($g) => strtolower(trim($g)))->filter();
$projects = $projects->filter(function ($project) use ($groups) {
$namespacePath = strtolower(data_get($project, 'namespace.full_path', ''));
return $groups->contains(fn ($group) => $namespacePath === $group || str_starts_with($namespacePath, $group.'/'));
});
}
return [
'total_count' => $projects->count(),
'has_more' => $hasMore,
'repositories' => $projects->map(fn ($project) => [
'id' => data_get($project, 'id'),
'name' => data_get($project, 'name'),
'path_with_namespace' => data_get($project, 'path_with_namespace'),
'default_branch' => data_get($project, 'default_branch', 'main'),
'web_url' => data_get($project, 'web_url'),
'namespace' => [
'full_path' => data_get($project, 'namespace.full_path'),
'kind' => data_get($project, 'namespace.kind'),
],
])->values()->all(),
];
}
function loadGitlabBranches(GitlabApp $source, int $projectId, int $page = 1): array
{
refreshGitlabToken($source);
$apiUrl = $source->apiUrlBase();
$response = Http::GitLab($apiUrl, $source->access_token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/projects/{$projectId}/repository/branches", [
'per_page' => 100,
'page' => $page,
]);
if (! $response->successful()) {
return [];
}
return $response->json();
}
+76 -76
View File
@@ -12,6 +12,7 @@ use App\Models\ServiceDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
@@ -462,7 +463,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
}
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$fqdn = "$fqdn$path";
@@ -507,21 +508,15 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations.
if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) {
$parsed = parseServiceEnvironmentVariable($key->value());
$normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value();
$serviceExists = false;
foreach (array_keys($services) as $serviceNameKey) {
if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) {
$serviceExists = true;
break;
}
}
if ($serviceExists) {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
$domainExists = data_get($domains->get($normalizedServiceName), 'domain');
$normalizedServiceName = normalizeComposeServiceName((string) $parsed['service_name']);
$originalServiceName = findComposeServiceName($normalizedServiceName, array_keys($services));
if ($originalServiceName !== null) {
$domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: [];
$domainExists = getComposeServiceDomainString($domains, $originalServiceName);
if (is_null($domainExists)) {
$serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value();
$domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid");
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$domainValue = "$domainValue$path";
@@ -530,8 +525,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($parsed['port'] && is_numeric($parsed['port'])) {
$domainValue = "$domainValue:{$parsed['port']}";
}
$domains->put($normalizedServiceName, ['domain' => $domainValue]);
$resource->docker_compose_domains = $domains->toJson();
$resource->docker_compose_domains = json_encode(putComposeServiceDomain(
$domains,
$originalServiceName,
$domainValue,
array_keys($services),
));
$resource->save();
}
}
@@ -568,8 +567,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
$originalServiceName = str($serviceName)->replace('_', '-')->value();
// Always normalize service names to match docker_compose_domains lookup
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
// Env var SERVICE_* names still use underscores; domain map keys use original compose names.
$serviceName = normalizeComposeServiceName((string) $serviceName);
// Generate BOTH FQDN & URL
$fqdn = generateFqdn(server: $server, random: "$originalServiceName-$uuid", parserVersion: $resource->compose_parsing_version);
@@ -630,29 +629,24 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
if ($resource->build_pack === 'dockercompose') {
// Check if a service with this name actually exists
$serviceExists = false;
foreach ($services as $serviceNameKey => $service) {
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
if ($transformedServiceName === $serviceName) {
$serviceExists = true;
break;
}
}
// Match env-derived name to the real compose service key (hyphens/dots preserved).
$composeServiceName = findComposeServiceName($serviceName, array_keys($services));
// Only add domain if the service exists
if ($serviceExists) {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
$domainExists = data_get($domains->get($serviceName), 'domain');
if ($composeServiceName !== null) {
$domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: [];
$domainExists = getComposeServiceDomainString($domains, $composeServiceName);
// Update domain using URL with port if applicable
$domainValue = $port ? $urlWithPort : $url;
if (is_null($domainExists)) {
$domains->put($serviceName, [
'domain' => $domainValue,
]);
$resource->docker_compose_domains = $domains->toJson();
$resource->docker_compose_domains = json_encode(putComposeServiceDomain(
$domains,
$composeServiceName,
$domainValue,
array_keys($services),
));
$resource->save();
}
}
@@ -1186,21 +1180,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($isPullRequest) {
$preview = $resource->previews()->find($preview_id);
$domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))) ?? collect([]);
$domains = collect(json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []);
} else {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []);
}
// Only process domains for dockercompose applications to prevent SERVICE variable recreation
if ($resource->build_pack !== 'dockercompose') {
$domains = collect([]);
}
$changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
$fqdns = data_get($domains, "$changedServiceName.domain");
// Prefer original compose service key; fall back to legacy underscore storage keys.
$fqdns = getComposeServiceDomainString($domains, (string) $serviceName);
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
if ($resource->build_pack === 'dockercompose') {
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
$parsedDomain = composeDomainEntryString($domain);
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
if (filled($parsedDomain)) {
@@ -1209,12 +1203,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyUrl->__toString());
$coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyFqdn);
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
$coolifyEnvironments->put('SERVICE_URL_'.$serviceEnvKey, $coolifyUrl->__toString());
$coolifyEnvironments->put('SERVICE_FQDN_'.$serviceEnvKey, $coolifyFqdn);
$resource->environment_variables()->updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $resource->id,
'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
'key' => 'SERVICE_URL_'.$serviceEnvKey,
], [
'value' => $coolifyUrl->__toString(),
'is_preview' => false,
@@ -1222,7 +1217,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$resource->environment_variables()->updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $resource->id,
'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
'key' => 'SERVICE_FQDN_'.$serviceEnvKey,
], [
'value' => $coolifyFqdn,
'is_preview' => false,
@@ -1248,9 +1243,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$fqdns = str($fqdns)->explode(',');
if ($isPullRequest) {
$preview = $resource->previews()->find($preview_id);
$docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains')));
$docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []);
if ($docker_compose_domains->count() > 0) {
$found_fqdn = data_get($docker_compose_domains, "$changedServiceName.domain");
$found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName);
if ($found_fqdn) {
$fqdns = collect($found_fqdn);
} else {
@@ -1291,15 +1286,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$isDatabase = isDatabaseImage($image, $service);
// Add COOLIFY_FQDN & COOLIFY_URL to environment
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
});
$coolifyEnvironments->put('COOLIFY_URL', $fqdnsWithoutPort->implode(','));
$urls = $fqdns->map(function ($fqdn) {
return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
});
$coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(','));
$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);
if ($environment->count() > 0) {
@@ -1353,6 +1341,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
}
$noindexDomains = $isPullRequest ? $fqdns : $originalResource->noindexDomains();
$domainServiceName = findComposeServiceName((string) $serviceName, $domains->keys());
$composeRedirect = data_get($domains->get($domainServiceName), 'redirect');
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
? $composeRedirect
: 'both';
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@@ -1365,7 +1358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
break;
case ProxyTypes::CADDY->value:
@@ -1380,7 +1374,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
service_name: $serviceName,
image: $image,
predefinedPort: $predefinedPort,
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
break;
}
@@ -1394,7 +1389,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $labelNetwork,
@@ -1407,7 +1403,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
service_name: $serviceName,
image: $image,
predefinedPort: $predefinedPort,
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
}
}
@@ -1793,8 +1790,10 @@ function serviceParser(Service $resource): Collection
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
$url = generateUrl($server, "$fqdnFor-$uuid");
} elseif ($isServiceApplication) {
$fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
$url = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
// FQDN may be a comma-separated list; use the first entry (same as updateCompose).
$firstFqdn = firstDomainFromList($savedService->fqdn);
$fqdn = getFqdnWithoutPort($firstFqdn);
$url = $fqdn;
} else {
// For ServiceDatabase, generate fqdn/url without saving to the model
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
@@ -1806,7 +1805,7 @@ function serviceParser(Service $resource): Collection
// Strip scheme for environment variable values
$fqdnValueForEnv = str($fqdn)->after('://')->value();
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
// Only add path if it's not already present (prevents duplication on subsequent parse() calls)
@@ -1910,12 +1909,12 @@ function serviceParser(Service $resource): Collection
->where('key', 'LIKE', $key->value().'_%')
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
->exists();
$serviceExists = ServiceApplication::where('name', str($fqdnFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
$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+$/');
// Only set FQDN if it's for the current service being processed (prevent race conditions)
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService) {
// Save URL otherwise it won't work.
$serviceExists->fqdn = $url;
$serviceExists->save();
@@ -1955,12 +1954,12 @@ function serviceParser(Service $resource): Collection
->where('key', 'LIKE', $key->value().'_%')
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
->exists();
$serviceExists = ServiceApplication::where('name', str($urlFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
$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+$/');
// Only set FQDN if it's for the current service being processed (prevent race conditions)
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService) {
$serviceExists->fqdn = $url;
$serviceExists->save();
}
@@ -2570,14 +2569,8 @@ function serviceParser(Service $resource): Collection
// Add COOLIFY_FQDN & COOLIFY_URL to environment
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
});
$coolifyEnvironments->put('COOLIFY_FQDN', $fqdnsWithoutPort->implode(','));
$urls = $fqdns->map(function ($fqdn): Stringable {
return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
});
$coolifyEnvironments->put('COOLIFY_URL', $urls->implode(','));
$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);
if ($environment->count() > 0) {
@@ -2624,6 +2617,9 @@ function serviceParser(Service $resource): Collection
$shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
$uuid = $resource->uuid;
$network = data_get($resource, 'destination.network');
$redirectDirection = in_array(data_get($originalResource, 'redirect'), ['www', 'non-www', 'both'], true)
? data_get($originalResource, 'redirect')
: 'both';
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@@ -2636,7 +2632,8 @@ function serviceParser(Service $resource): Collection
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
break;
case ProxyTypes::CADDY->value:
@@ -2651,7 +2648,8 @@ function serviceParser(Service $resource): Collection
service_name: $serviceName,
image: $image,
predefinedPort: $predefinedPort,
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
break;
}
@@ -2665,7 +2663,8 @@ function serviceParser(Service $resource): Collection
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $network,
@@ -2678,7 +2677,8 @@ function serviceParser(Service $resource): Collection
service_name: $serviceName,
image: $image,
predefinedPort: $predefinedPort,
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
}
}
+31 -1
View File
@@ -102,6 +102,35 @@ function instant_scp(string $source, string $dest, Server $server, $throwError =
);
}
/**
* Download a remote file from a managed server onto the Coolify host via SCP.
*/
function instant_scp_from_server(string $remoteSource, string $localDest, Server $server, $throwError = true)
{
return SshRetryHandler::retry(
function () use ($remoteSource, $localDest, $server) {
$scp_command = SshMultiplexingHelper::generateScpDownloadCommand($server, $remoteSource, $localDest);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
$output = trim($process->output());
$exitCode = $process->exitCode();
if ($exitCode !== 0) {
excludeCertainErrors($process->errorOutput(), $exitCode);
}
return $output === 'null' ? null : $output;
},
[
'server' => $server->ip,
'source' => $remoteSource,
'dest' => $localDest,
'function' => 'instant_scp_from_server',
],
$throwError
);
}
function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
{
$command = $command instanceof Collection ? $command->toArray() : $command;
@@ -253,7 +282,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
} catch (Exception) {
$timestamp->setTimezone('UTC');
}
data_set($i, 'timestamp', $timestamp->format('Y-M-d H:i:s.u'));
data_set($i, 'timestamp', $timestamp->format('Y-M-d H:i:s'));
return $i;
})
@@ -301,6 +330,7 @@ function remove_iip($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);
+10 -19
View File
@@ -139,7 +139,7 @@ function replaceVariables(string $variable): Stringable
function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false)
{
try {
if ($oneService->getMorphClass() === \App\Models\Application::class) {
if ($oneService->getMorphClass() === Application::class) {
$workdir = $oneService->workdir();
$server = $oneService->destination->server;
} else {
@@ -204,7 +204,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
instant_remote_process(["mkdir -p $fileLocation"], $server);
}
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
return handleError($e);
}
}
@@ -214,7 +214,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$name = data_get($resource, 'name');
$dockerComposeRaw = data_get($resource, 'service.docker_compose_raw');
if (! $dockerComposeRaw) {
throw new \Exception('No compose file found or not a valid YAML file.');
throw new Exception('No compose file found or not a valid YAML file.');
}
$dockerCompose = Yaml::parse($dockerComposeRaw);
@@ -325,22 +325,13 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
}
if ($resource->fqdn) {
$resourceFqdns = str($resource->fqdn)->explode(',');
$resourceFqdns = $resourceFqdns->first();
$url = Url::fromString($resourceFqdns);
$firstFqdn = firstDomainFromList($resource->fqdn);
$url = Url::fromString($firstFqdn);
$port = $url->getPort();
$path = $url->getPath();
// Prepare URL value (with scheme and host)
$urlValue = $url->getScheme().'://'.$url->getHost();
$urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
// Prepare FQDN value (host only, no scheme)
$fqdnHost = $url->getHost();
$fqdnValue = str($fqdnHost)->after('://');
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
// Same helpers as application/service parsers (COOLIFY_URL / COOLIFY_FQDN).
$urlValue = getFqdnWithoutPort($firstFqdn);
$fqdnValue = getHostWithoutPort($firstFqdn);
// For each service name found in template, create BOTH SERVICE_URL and SERVICE_FQDN pairs
foreach ($serviceNamesToProcess as $serviceInfo) {
@@ -396,7 +387,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
}
}
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
return handleError($e);
}
}
@@ -495,7 +486,7 @@ function applyServiceApplicationPrerequisites(Service $service): void
}
}
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Log error but don't throw - prerequisites are nice-to-have, not critical
Log::error('Failed to apply service application prerequisites', [
'service_id' => $service->id,
+402 -33
View File
@@ -535,6 +535,17 @@ function find_destination_for_current_team(?string $uuid): StandaloneDocker|Swar
?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first();
}
function find_resource_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null
{
$destination = find_destination_for_current_team($uuid);
if (! $destination?->server?->canHostResources()) {
return null;
}
return $destination;
}
function showBoarding(): bool
{
if (isDev()) {
@@ -738,18 +749,71 @@ function base_ip(): string
return 'localhost';
}
function getFqdnWithoutPort(string $fqdn)
/**
* Parse a domain URL into scheme/host/path pieces used by COOLIFY_* and SERVICE_* env builders.
* Omits a bare "/" path so port re-append stays valid ("http://host:80" not "http://host/:80").
*
* @return array{scheme: string, host: string, path: string}|null
*/
function parseDomainUrlParts(string $fqdn): ?array
{
try {
$url = Url::fromString($fqdn);
$host = $url->getHost();
$scheme = $url->getScheme();
$path = $url->getPath();
if ($host === '') {
return null;
}
return "$scheme://$host$path";
$path = $url->getPath();
if ($path === '' || $path === '/') {
$path = '';
}
return [
'scheme' => $url->getScheme(),
'host' => $host,
'path' => $path,
];
} catch (Throwable) {
return null;
}
}
/**
* Absolute URL without port (and without a bare trailing slash).
* Used for COOLIFY_URL / SERVICE_URL base values.
*/
function getFqdnWithoutPort(string $fqdn): string
{
$parts = parseDomainUrlParts($fqdn);
if ($parts === null || $parts['scheme'] === '') {
// Spatie accepts bare hostnames (empty scheme). Do not invent "://host".
return $fqdn;
}
return $parts['scheme'].'://'.$parts['host'].$parts['path'];
}
/**
* Host (+ optional path) without scheme or port.
* Used for COOLIFY_FQDN / SERVICE_FQDN base values.
*/
function getHostWithoutPort(string $fqdn): string
{
$parts = parseDomainUrlParts($fqdn);
if ($parts === null) {
return $fqdn;
}
return $parts['host'].$parts['path'];
}
/**
* First entry from a comma-separated FQDN list (service apps may store multiple).
*/
function firstDomainFromList(?string $fqdns): string
{
return trim((string) str($fqdns ?? '')->explode(',')->first());
}
/**
* If fqdn is set, return it, otherwise return public ip.
@@ -1252,23 +1316,92 @@ function sslip(Server $server)
return "http://{$server->ip}.sslip.io";
}
function service_templates_cache_key(): string
{
return (string) config('constants.services.cache_key', 'coolify:service-templates-bundle');
}
function service_templates_path(): string
{
return base_path('templates/'.config('constants.services.file_name'));
}
/**
* Persist the CDN service-templates bundle to local disk and shared cache.
*
* The shared cache entry is what multi-node Cloud relies on: Horizon (or any
* single node) pulls once; every HTTP node reads the same Redis payload.
*/
function store_service_templates_bundle(string $json, ?string $fetchedAt = null): bool
{
$fetchedAt ??= now()->toIso8601String();
$path = service_templates_path();
$written = File::put($path, $json) !== false;
Cache::forever(service_templates_cache_key(), [
'fetched_at' => $fetchedAt,
'json' => $json,
]);
return $written;
}
function get_service_templates_fetched_at(): ?CarbonImmutable
{
$bundle = Cache::get(service_templates_cache_key());
if (is_array($bundle) && filled(data_get($bundle, 'fetched_at'))) {
try {
return CarbonImmutable::parse((string) data_get($bundle, 'fetched_at'));
} catch (Throwable) {
// fall through to local file mtime
}
}
$path = service_templates_path();
if (File::exists($path)) {
$mtime = filemtime($path);
if ($mtime !== false) {
return CarbonImmutable::createFromTimestamp($mtime);
}
}
return null;
}
function get_service_templates(bool $force = false): Collection
{
if ($force) {
try {
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
$response = Http::retry(3, 1000, throw: false)
->timeout(60)
->connectTimeout(10)
->get(config('constants.services.official'));
if ($response->failed()) {
return collect([]);
}
$services = $response->json();
store_service_templates_bundle($response->body());
return collect($services);
return collect(json_decode($response->body()))->sortKeys();
} catch (Throwable) {
return get_service_templates();
}
}
$path = base_path('templates/'.config('constants.services.file_name'));
$bundle = Cache::get(service_templates_cache_key());
if (is_array($bundle) && is_string(data_get($bundle, 'json')) && data_get($bundle, 'json') !== '') {
$fetchedAt = (string) data_get($bundle, 'fetched_at', '0');
return Cache::remember("service-templates:shared:{$fetchedAt}", now()->addDay(), function () use ($bundle) {
return collect(json_decode((string) data_get($bundle, 'json')))->sortKeys();
});
}
$path = service_templates_path();
if (! File::exists($path)) {
return collect([]);
}
$mtime = filemtime($path) ?: 0;
return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) {
@@ -1735,11 +1868,189 @@ function getRealtime()
}
}
/**
* Resolve a server IP or hostname to an IP address for DNS comparison/display.
*
* When a server uses a hostname (e.g. coolify-testing-host) instead of a literal
* IP, A-record validation and UI copy need the actual IP the user should point DNS to.
*
* Successful hostname resolutions are cached briefly so Livewire mounts/domain state
* loads do not re-query DNS on every request.
*
* @return array{ip: ?string, configured: ?string, resolved_from_hostname: bool}
*/
function resolveServerIpAddress(?string $ipOrHost): array
{
$configured = filled($ipOrHost) ? trim((string) $ipOrHost) : null;
if ($configured === null || $configured === '') {
return [
'ip' => null,
'configured' => null,
'resolved_from_hostname' => false,
];
}
// Strip IPv6 brackets if present: [2001:db8::1]
$candidate = str($configured)->trim('[]')->toString();
if (filter_var($candidate, FILTER_VALIDATE_IP) !== false) {
return [
'ip' => $candidate,
'configured' => $configured,
'resolved_from_hostname' => false,
];
}
$cacheKey = 'server-ip-resolve:'.mb_strtolower($candidate);
$cachedIp = Cache::get($cacheKey);
if (is_string($cachedIp) && filter_var($cachedIp, FILTER_VALIDATE_IP) !== false) {
return [
'ip' => $cachedIp,
'configured' => $configured,
'resolved_from_hostname' => true,
];
}
$resolvedIp = null;
try {
$aRecords = @dns_get_record($candidate, DNS_A);
if (is_array($aRecords)) {
foreach ($aRecords as $record) {
$ip = $record['ip'] ?? null;
if (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$resolvedIp = $ip;
break;
}
}
}
} catch (Throwable) {
}
if ($resolvedIp === null) {
try {
$aaaaRecords = @dns_get_record($candidate, DNS_AAAA);
if (is_array($aaaaRecords)) {
foreach ($aaaaRecords as $record) {
$ip = $record['ipv6'] ?? null;
if (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$resolvedIp = $ip;
break;
}
}
}
} catch (Throwable) {
}
}
if ($resolvedIp === null) {
$resolved = @gethostbyname($candidate);
if (is_string($resolved) && $resolved !== $candidate && filter_var($resolved, FILTER_VALIDATE_IP) !== false) {
$resolvedIp = $resolved;
}
}
if ($resolvedIp !== null) {
Cache::put($cacheKey, $resolvedIp, now()->addSeconds(60));
}
return [
'ip' => $resolvedIp,
'configured' => $configured,
'resolved_from_hostname' => $resolvedIp !== null,
];
}
/**
* Preferred server address for DNS validation: public instance IPs for localhost,
* otherwise the server IP/hostname resolved to a real IP when possible.
*/
function serverDnsTargetIp(Server $server): ?string
{
$settings = instanceSettings();
if ($server->id === 0) {
$configured = data_get($settings, 'public_ipv4')
?: data_get($settings, 'public_ipv6')
?: $server->ip;
} else {
$configured = $server->ip;
}
$resolved = resolveServerIpAddress(is_string($configured) ? $configured : null);
// Prefer resolved IP; fall back to configured value so existing hostname-based
// comparisons still have something to show if DNS resolution fails.
return $resolved['ip'] ?? $resolved['configured'];
}
/**
* DNS record type users should create for a server address: A (IPv4) or AAAA (IPv6).
*/
function dnsRecordTypeForIp(?string $ipOrHost): string
{
if (! is_string($ipOrHost) || trim($ipOrHost) === '') {
return 'A';
}
// Accept labels like "2001:db8::1 (hostname)" or bracketed IPv6.
$candidate = trim($ipOrHost);
if (preg_match('/^(\S+)/', $candidate, $matches) === 1) {
$candidate = $matches[1];
}
$candidate = str($candidate)->trim('[]')->toString();
if (filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
return 'AAAA';
}
return 'A';
}
/**
* Bare address for DNS guidance (first token of a label, brackets stripped).
*/
function dnsGuidanceTargetAddress(?string $ipOrLabel): ?string
{
if (! is_string($ipOrLabel) || trim($ipOrLabel) === '') {
return null;
}
$candidate = trim($ipOrLabel);
if (preg_match('/^(\S+)/', $candidate, $matches) === 1) {
$candidate = $matches[1];
}
$candidate = str($candidate)->trim('[]')->toString();
return $candidate !== '' ? $candidate : null;
}
/**
* User-facing guidance when a hostname does not resolve to the server.
* Format: "Required DNS record type A pointing to 1.2.3.4"
* or "Required DNS record type AAAA pointing to 2001:db8::1".
*
* @param ?string $targetLabel Display target (IP, or "IP (hostname)") used as fallback.
* @param ?string $ipForRecordType Preferred IP for type + display (defaults to $targetLabel).
*/
function dnsMismatchGuidanceMessage(?string $targetLabel, ?string $ipForRecordType = null): string
{
$address = dnsGuidanceTargetAddress($ipForRecordType)
?? dnsGuidanceTargetAddress($targetLabel);
if ($address === null) {
return 'DNS validation failed. Check your DNS records.';
}
$recordType = dnsRecordTypeForIp($address);
return "Required DNS record type {$recordType} pointing to {$address}";
}
function validateDNSEntry(string $fqdn, Server $server)
{
// https://www.cloudflare.com/ips-v4/#
$cloudflare_ips = collect(['173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '172.64.0.0/13', '131.0.72.0/22']);
$url = Url::fromString($fqdn);
$host = $url->getHost();
if (str($host)->contains('sslip.io')) {
@@ -1752,13 +2063,9 @@ function validateDNSEntry(string $fqdn, Server $server)
}
$dns_servers = data_get($settings, 'custom_dns_servers');
$dns_servers = str($dns_servers)->explode(',');
if ($server->id === 0) {
$ip = data_get($settings, 'public_ipv4', data_get($settings, 'public_ipv6', $server->ip));
} else {
$ip = $server->ip;
}
$ip = serverDnsTargetIp($server);
$found_matching_ip = false;
$type = DNSTypes::NAME_A;
$type = dnsRecordTypeForIp($ip) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
foreach ($dns_servers as $dns_server) {
try {
$query = new DNSQuery($dns_server);
@@ -1767,11 +2074,11 @@ function validateDNSEntry(string $fqdn, Server $server)
} else {
foreach ($results as $result) {
if ($result->getType() == $type) {
if (ipMatch($result->getData(), $cloudflare_ips->toArray(), $match)) {
if (isCloudflareIp($result->getData())) {
$found_matching_ip = true;
break;
}
if ($result->getData() === $ip) {
if ($ip && $result->getData() === $ip) {
$found_matching_ip = true;
break;
}
@@ -1785,11 +2092,48 @@ function validateDNSEntry(string $fqdn, Server $server)
return $found_matching_ip;
}
function isCloudflareIp(string $ip): bool
{
// https://www.cloudflare.com/ips/
$cloudflareIps = [
'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
'141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
'197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
'172.64.0.0/13', '131.0.72.0/22', '2400:cb00::/32', '2606:4700::/32',
'2803:f800::/32', '2405:b500::/32', '2405:8100::/32', '2a06:98c0::/29',
'2c0f:f248::/32',
];
return ipMatch($ip, $cloudflareIps);
}
function ipMatch($ip, $cidrs, &$match = null)
{
foreach ((array) $cidrs as $cidr) {
[$subnet, $mask] = explode('/', $cidr);
if (((ip2long($ip) & ($mask = ~((1 << (32 - $mask)) - 1))) == (ip2long($subnet) & $mask))) {
[$subnet, $prefixLength] = explode('/', $cidr);
$packedIp = inet_pton($ip);
$packedSubnet = inet_pton($subnet);
if ($packedIp === false || $packedSubnet === false || strlen($packedIp) !== strlen($packedSubnet)) {
continue;
}
$addressBits = strlen($packedIp) * 8;
$prefixLength = (int) $prefixLength;
if ($prefixLength < 0 || $prefixLength > $addressBits) {
continue;
}
$fullBytes = intdiv($prefixLength, 8);
$remainingBits = $prefixLength % 8;
$matches = substr($packedIp, 0, $fullBytes) === substr($packedSubnet, 0, $fullBytes);
if ($matches && $remainingBits > 0) {
$mask = (0xFF << (8 - $remainingBits)) & 0xFF;
$matches = (ord($packedIp[$fullBytes]) & $mask) === (ord($packedSubnet[$fullBytes]) & $mask);
}
if ($matches) {
$match = $cidr;
return true;
@@ -2527,7 +2871,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($env) {
$env_url = Url::fromString($savedService->fqdn);
$env_port = $env_url->getPort();
if ($env_port !== $predefinedPort) {
if ((int) $env_port !== (int) $predefinedPort) {
$env_url = $env_url->withPort($predefinedPort);
$savedService->fqdn = $env_url->__toString();
$savedService->save();
@@ -2612,7 +2956,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($env) {
$env_url = Url::fromString($env->value);
$env_port = $env_url->getPort();
if ($env_port !== $predefinedPort) {
if ((int) $env_port !== (int) $predefinedPort) {
$env_url = $env_url->withPort($predefinedPort);
$savedService->fqdn = $env_url->__toString();
$savedService->save();
@@ -2697,6 +3041,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if (! $isDatabase && $fqdns->count() > 0) {
if ($fqdns) {
$shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
$redirectDirection = in_array(data_get($savedService, 'redirect'), ['www', 'non-www', 'both'], true)
? data_get($savedService, 'redirect')
: 'both';
if ($shouldGenerateLabelsExactly) {
switch ($resource->server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@@ -2709,7 +3056,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image'),
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
break;
case ProxyTypes::CADDY->value:
@@ -2723,7 +3071,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image'),
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
break;
}
@@ -2737,7 +3086,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image'),
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $resource->destination->network,
@@ -2749,7 +3099,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image'),
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
}
}
@@ -3440,16 +3791,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($resource->serviceType()) {
$fqdns = generateServiceSpecificFqdns($resource);
} else {
$domains = collect(json_decode($resource->docker_compose_domains)) ?? [];
$domains = json_decode($resource->docker_compose_domains ?: '[]', true) ?: [];
if ($domains) {
$fqdns = data_get($domains, "$serviceName.domain");
// Dual-read: original compose name or legacy underscore key.
$fqdns = getComposeServiceDomainString($domains, (string) $serviceName);
if ($fqdns) {
$fqdns = str($fqdns)->explode(',');
if ($pull_request_id !== 0) {
$preview = $resource->previews()->find($preview_id);
$docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains')));
if ($docker_compose_domains->count() > 0) {
$found_fqdn = data_get($docker_compose_domains, "$serviceName.domain");
$docker_compose_domains = json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: [];
if (count($docker_compose_domains) > 0) {
$found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName);
if ($found_fqdn) {
$fqdns = collect($found_fqdn);
} else {
@@ -3476,6 +3828,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
$noindexDomains = $pull_request_id !== 0 ? $fqdns : $resource->noindexDomains();
$shouldGenerateLabelsExactly = $server->settings->generate_exact_labels;
$composeRedirect = data_get($domains, "$serviceName.redirect");
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
? $composeRedirect
: 'both';
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@@ -3490,6 +3846,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
)
);
break;
@@ -3505,6 +3862,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
)
);
break;
@@ -3521,6 +3879,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
)
);
$serviceLabels = $serviceLabels->merge(
@@ -3534,6 +3893,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
)
);
}
@@ -3804,8 +4164,17 @@ function loggy($message = null, array $context = [])
return app('log')->debug($message, $context);
}
function sslipDomainWarning(string $domains)
/**
* Warn when any domain uses HTTPS with an sslip hostname.
*
* Empty/null domain lists are valid (domains removed) and produce no warning.
*/
function sslipDomainWarning(?string $domains): bool
{
if (blank($domains)) {
return false;
}
$domains = str($domains)->trim()->explode(',');
$showSslipHttpsWarning = false;
$domains->each(function ($domain) use (&$showSslipHttpsWarning) {
+7 -2
View File
@@ -1,6 +1,8 @@
<?php
use App\Models\Team;
use Stripe\BillingPortal\Session;
use Stripe\Customer;
use Stripe\Stripe;
function isSubscriptionActive()
@@ -65,7 +67,7 @@ function getStripeCustomerPortalSession(Team $team)
return null;
}
return \Stripe\BillingPortal\Session::create([
return Session::create([
'customer' => $stripe_customer_id,
'return_url' => $return_url,
]);
@@ -80,6 +82,9 @@ function allowedPathsForUnsubscribedAccounts()
'two-factor-challenge',
'livewire/update',
'admin',
// Account basics stay available without a paid plan.
'profile',
'profile/appearance',
];
}
function allowedPathsForBoardingAccounts()
@@ -114,7 +119,7 @@ function updateStripeCustomerEmail(Team $team, string $newEmail): void
Stripe::setApiKey(config('subscription.stripe_api_key'));
\Stripe\Customer::update(
Customer::update(
$stripe_customer_id,
['email' => $newEmail]
);