feat(domains): support per-domain internal port overrides (#11594)

This commit is contained in:
Andras Bacsai
2026-09-02 19:39:19 +02:00
committed by GitHub
parent 7a7564e6b3
commit e2e91fbb85
70 changed files with 4025 additions and 297 deletions
@@ -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;