mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-27 01:40:26 -04:00
feat(domains): support per-domain internal port overrides (#11594)
This commit is contained in:
@@ -23,6 +23,7 @@ use App\Rules\DockerImageFormat;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Services\DockerImageParser;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -2487,6 +2488,256 @@ class ApplicationsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Preview Domains',
|
||||
description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.',
|
||||
path: '/applications/{uuid}/previews/{pull_request_id}',
|
||||
operationId: 'update-preview-domains-by-pull-request-id',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'pull_request_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'domains', type: 'string', nullable: true, example: 'https://pr.example.com:3000'),
|
||||
new OA\Property(
|
||||
property: 'docker_compose_domains',
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: new OA\Items(properties: [
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'domain', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'redirect', type: 'string', nullable: true, enum: ['www', 'non-www', 'both']),
|
||||
], type: 'object'),
|
||||
),
|
||||
new OA\Property(property: 'force_domain_override', type: 'boolean', default: false),
|
||||
],
|
||||
)),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Preview domains updated.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, ref: '#/components/responses/403'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'Domain conflict.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function update_preview_by_pull_request_id(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$pullRequestIdRaw = $request->route('pull_request_id');
|
||||
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
|
||||
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
|
||||
}
|
||||
|
||||
$preview = ApplicationPreview::where('application_id', $application->id)
|
||||
->where('pull_request_id', (int) $pullRequestIdRaw)
|
||||
->first();
|
||||
if (! $preview) {
|
||||
return response()->json(['message' => 'Preview not found.'], 404);
|
||||
}
|
||||
|
||||
$isCompose = $application->build_pack === BuildPackTypes::DOCKERCOMPOSE->value;
|
||||
$validationRules = ['force_domain_override' => 'boolean'];
|
||||
if ($isCompose) {
|
||||
$validationRules = array_merge($validationRules, [
|
||||
'domains' => 'missing',
|
||||
'docker_compose_domains' => 'present|array',
|
||||
'docker_compose_domains.*' => 'array:name,domain,redirect',
|
||||
'docker_compose_domains.*.name' => 'required|string|distinct',
|
||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
|
||||
]);
|
||||
} else {
|
||||
$validationRules['domains'] = ['present', ...ValidationPatterns::applicationDomainRules()];
|
||||
$validationRules['docker_compose_domains'] = 'missing';
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), $validationRules);
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$dockerComposeDomains = null;
|
||||
$dockerComposeDomainsResponse = null;
|
||||
if ($isCompose) {
|
||||
try {
|
||||
$compose = Yaml::parse($application->docker_compose_raw ?? '');
|
||||
} catch (\Throwable) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['docker_compose_domains' => 'The Docker Compose configuration could not be parsed.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$services = data_get($compose, 'services');
|
||||
if (! is_array($services) || $services === []) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['docker_compose_domains' => 'The Docker Compose configuration must define at least one service.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$composeServices = collect($services)
|
||||
->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image')))
|
||||
->keys()
|
||||
->map(fn (mixed $name): string => (string) $name)
|
||||
->values();
|
||||
$requestedServices = collect($request->input('docker_compose_domains'))->pluck('name');
|
||||
if ($requestedServices->diff($composeServices)->isNotEmpty()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['docker_compose_domains' => 'One or more Docker Compose services are invalid.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$existingComposeDomains = json_decode($preview->docker_compose_domains ?? '[]', true) ?: [];
|
||||
$dockerComposeDomains = $composeServices
|
||||
->mapWithKeys(function (string $service) use ($existingComposeDomains): array {
|
||||
$entry = ['domain' => ''];
|
||||
$redirect = $existingComposeDomains[$service]['redirect'] ?? null;
|
||||
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
|
||||
$entry['redirect'] = $redirect;
|
||||
}
|
||||
|
||||
return [$service => $entry];
|
||||
})
|
||||
->all();
|
||||
foreach ($request->input('docker_compose_domains') as $item) {
|
||||
$entry = ['domain' => ValidationPatterns::normalizeApplicationDomains(data_get($item, 'domain')) ?? ''];
|
||||
$redirect = array_key_exists('redirect', $item)
|
||||
? data_get($item, 'redirect')
|
||||
: ($existingComposeDomains[data_get($item, 'name')]['redirect'] ?? null);
|
||||
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
|
||||
$entry['redirect'] = $redirect;
|
||||
}
|
||||
$dockerComposeDomains[data_get($item, 'name')] = $entry;
|
||||
}
|
||||
$domains = collect($dockerComposeDomains)
|
||||
->pluck('domain')
|
||||
->filter()
|
||||
->implode(',') ?: null;
|
||||
} else {
|
||||
$domains = ValidationPatterns::normalizeApplicationDomains($request->input('domains'));
|
||||
}
|
||||
|
||||
$submittedUrls = collect(ValidationPatterns::applicationDomainList($domains))
|
||||
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain));
|
||||
if ($submittedUrls->duplicates()->isNotEmpty()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
$isCompose ? 'docker_compose_domains' : 'domains' => 'The same domain cannot be configured more than once.',
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$normalized = DomainPortOverrides::normalize($domains, null);
|
||||
$portlessDomains = $normalized['fqdn'];
|
||||
if ($isCompose) {
|
||||
foreach ($dockerComposeDomains as $service => $entry) {
|
||||
$dockerComposeDomains[$service]['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain']))
|
||||
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
|
||||
->implode(',');
|
||||
}
|
||||
$dockerComposeDomainsResponse = collect($dockerComposeDomains)
|
||||
->map(fn (array $entry, string $name): array => ['name' => $name, ...$entry])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
$urls = collect(ValidationPatterns::applicationDomainList($portlessDomains));
|
||||
$conflicts = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId);
|
||||
if (isset($conflicts['error'])) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$isCompose ? 'docker_compose_domains' : 'domains' => $conflicts['error']],
|
||||
], 422);
|
||||
}
|
||||
if ($conflicts['hasConflicts'] && ! $request->boolean('force_domain_override')) {
|
||||
return response()->json([
|
||||
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
|
||||
'conflicts' => $conflicts['conflicts'],
|
||||
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$hostCandidates = $urls
|
||||
->map(fn (string $url): string => (string) parse_url($url, PHP_URL_HOST))
|
||||
->filter();
|
||||
$conflictingPreview = null;
|
||||
if ($hostCandidates->isNotEmpty()) {
|
||||
$conflictingPreview = ApplicationPreview::query()
|
||||
->whereIn('application_id', Application::ownedByCurrentTeamAPI($teamId)
|
||||
->withoutGlobalScope('withRelations')
|
||||
->reorder()
|
||||
->select('applications.id'))
|
||||
->whereKeyNot($preview->id)
|
||||
->whereNotNull('fqdn')
|
||||
->where(function ($query) use ($hostCandidates): void {
|
||||
foreach ($hostCandidates as $host) {
|
||||
$query->orWhere('fqdn', 'like', '%'.$host.'%');
|
||||
}
|
||||
})
|
||||
->get(['uuid', 'pull_request_id', 'fqdn'])
|
||||
->first(fn (ApplicationPreview $otherPreview): bool => collect(ValidationPatterns::applicationDomainList($otherPreview->fqdn))
|
||||
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
|
||||
->intersect($urls)
|
||||
->isNotEmpty());
|
||||
}
|
||||
|
||||
if ($conflictingPreview && ! $request->boolean('force_domain_override')) {
|
||||
return response()->json([
|
||||
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
|
||||
'conflicts' => [[
|
||||
'domain' => collect(ValidationPatterns::applicationDomainList($conflictingPreview->fqdn))
|
||||
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
|
||||
->intersect($urls)
|
||||
->first(),
|
||||
'resource_name' => 'Preview deployment #'.$conflictingPreview->pull_request_id,
|
||||
'resource_uuid' => $conflictingPreview->uuid,
|
||||
'resource_type' => 'application',
|
||||
'message' => 'Domain is already in use by another preview deployment.',
|
||||
]],
|
||||
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$preview->domain_port_overrides = $normalized['overrides'];
|
||||
$preview->fqdn = $portlessDomains;
|
||||
if ($isCompose) {
|
||||
$preview->docker_compose_domains = json_encode($dockerComposeDomains);
|
||||
}
|
||||
$preview->save();
|
||||
|
||||
auditLog('api.application.preview_updated', [
|
||||
'team_id' => $teamId,
|
||||
'application_uuid' => $application->uuid,
|
||||
'pull_request_id' => $preview->pull_request_id,
|
||||
'changed_fields' => [$isCompose ? 'docker_compose_domains' : 'domains'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $preview->uuid,
|
||||
'pull_request_id' => $preview->pull_request_id,
|
||||
'domains' => $preview->fqdn,
|
||||
'docker_compose_domains' => $dockerComposeDomainsResponse,
|
||||
'domain_port_overrides' => $preview->domain_port_overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete',
|
||||
description: 'Delete application by UUID.',
|
||||
@@ -2818,6 +3069,7 @@ class ApplicationsController extends Controller
|
||||
'http_basic_auth_username' => 'string',
|
||||
'http_basic_auth_password' => 'string',
|
||||
'include_source_commit_in_build' => 'boolean',
|
||||
'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/',
|
||||
];
|
||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||
$validationMessages = [
|
||||
@@ -2826,10 +3078,10 @@ class ApplicationsController extends Controller
|
||||
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
|
||||
|
||||
// Validate ports_exposes
|
||||
if ($request->has('ports_exposes')) {
|
||||
if ($request->filled('ports_exposes')) {
|
||||
$ports = explode(',', $request->ports_exposes);
|
||||
foreach ($ports as $port) {
|
||||
if (! is_numeric($port)) {
|
||||
if (! is_numeric($port) || (int) $port < 1 || (int) $port > 65535) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
@@ -5152,7 +5404,7 @@ class ApplicationsController extends Controller
|
||||
$this->authorize('delete', $application);
|
||||
|
||||
$pullRequestIdRaw = $request->route('pull_request_id');
|
||||
if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
|
||||
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
|
||||
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
|
||||
}
|
||||
$pullRequestId = (int) $pullRequestIdRaw;
|
||||
|
||||
Reference in New Issue
Block a user