feat(domains): support per-domain internal port overrides (#11594)
This commit is contained in:
parent
7a7564e6b3
commit
e2e91fbb85
70 changed files with 4025 additions and 297 deletions
|
|
@ -56,7 +56,7 @@ public function execute(ServiceApplication $serviceApplication, Request $request
|
|||
}
|
||||
}
|
||||
|
||||
$serviceApplication->fqdn = $parsed['normalized'];
|
||||
$serviceApplication->setEditableUrls($parsed['normalized']);
|
||||
}
|
||||
|
||||
if (array_key_exists('noindex_domains', $payload)) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
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 @@ public function logs_by_uuid(Request $request)
|
|||
]);
|
||||
}
|
||||
|
||||
#[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 @@ public function update_by_uuid(Request $request)
|
|||
'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 @@ public function update_by_uuid(Request $request)
|
|||
$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 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
|
|||
$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;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
|
@ -57,7 +58,7 @@ class Domains extends Component
|
|||
|
||||
public ?string $editingService = null;
|
||||
|
||||
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool}> */
|
||||
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool, internal_port?: ?int, has_port_override?: bool}> */
|
||||
public array $domainRows = [];
|
||||
|
||||
/** When set, the next addSuggestedDomain call for this index skips the DNS block. */
|
||||
|
|
@ -70,6 +71,14 @@ class Domains extends Component
|
|||
|
||||
public bool $showDomainConflictModal = false;
|
||||
|
||||
public bool $showPortWarningModal = false;
|
||||
|
||||
public bool $forceUseUnknownPort = false;
|
||||
|
||||
public ?int $unrecognizedPort = null;
|
||||
|
||||
public ?string $pendingPortAction = null;
|
||||
|
||||
public bool $forceSaveDomains = false;
|
||||
|
||||
public bool $forceSaveDns = false;
|
||||
|
|
@ -485,32 +494,19 @@ protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOv
|
|||
|
||||
/**
|
||||
* @param array<string, array{status?: string, message?: string, expected_ip?: ?string, checked_at?: ?string}> $stored
|
||||
* @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool}
|
||||
* @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool, internal_port: ?int, has_port_override: bool}
|
||||
*/
|
||||
protected function domainRowFromStored(string $url, ?string $service, array $stored): array
|
||||
{
|
||||
$key = $this->domainDnsStatusKey($url, $service);
|
||||
$entry = $stored[$key] ?? null;
|
||||
$port = $this->effectiveDomainInternalPort($url);
|
||||
|
||||
if (is_array($entry) && filled(data_get($entry, 'status'))) {
|
||||
return [
|
||||
'url' => $url,
|
||||
'service' => $service,
|
||||
'dns_status' => (string) data_get($entry, 'status', 'pending'),
|
||||
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
|
||||
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
|
||||
'checked_at' => data_get($entry, 'checked_at'),
|
||||
'check_id' => data_get($entry, 'check_id'),
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
'needs_force_add' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
$row = [
|
||||
'url' => $url,
|
||||
'service' => $service,
|
||||
'internal_port' => $port['internal_port'],
|
||||
'has_port_override' => $port['has_port_override'],
|
||||
'dns_status' => 'pending',
|
||||
'dns_message' => 'Not checked yet.',
|
||||
'expected_ip' => $this->serverIp,
|
||||
|
|
@ -521,6 +517,104 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto
|
|||
'suggestion_label' => null,
|
||||
'needs_force_add' => false,
|
||||
];
|
||||
|
||||
if (is_array($entry) && filled(data_get($entry, 'status'))) {
|
||||
$row['dns_status'] = (string) data_get($entry, 'status', 'pending');
|
||||
$row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.');
|
||||
$row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp;
|
||||
$row['checked_at'] = data_get($entry, 'checked_at');
|
||||
$row['check_id'] = data_get($entry, 'check_id');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{internal_port: ?int, has_port_override: bool}
|
||||
*/
|
||||
protected function effectiveDomainInternalPort(string $url): array
|
||||
{
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
$overrides = $this->application->domain_port_overrides ?? [];
|
||||
$legacyPortPart = DomainUrlParts::split($url)['port'] ?? '';
|
||||
$legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null;
|
||||
$hasMapEntry = array_key_exists($canonical, $overrides);
|
||||
|
||||
if ($hasMapEntry) {
|
||||
return [
|
||||
'internal_port' => (int) $overrides[$canonical],
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($legacyPort !== null) {
|
||||
return [
|
||||
'internal_port' => $legacyPort,
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->application->settings?->is_static) {
|
||||
return [
|
||||
'internal_port' => 80,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$exposed = $this->application->ports_exposes_array;
|
||||
$defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0
|
||||
? (int) $exposed[0]
|
||||
: null;
|
||||
|
||||
return [
|
||||
'internal_port' => $defaultPort,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{scheme: string, host: string, port: string, path: string} $parts
|
||||
*/
|
||||
protected function portFromParts(array $parts): ?int
|
||||
{
|
||||
$port = trim((string) ($parts['port'] ?? ''));
|
||||
if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $port;
|
||||
}
|
||||
|
||||
protected function currentRowPort(string $url): ?int
|
||||
{
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
$override = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null;
|
||||
if (filled($override) && (int) $override > 0) {
|
||||
return (int) $override;
|
||||
}
|
||||
|
||||
$legacy = DomainUrlParts::split($url)['port'] ?? '';
|
||||
|
||||
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
|
||||
}
|
||||
|
||||
protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool
|
||||
{
|
||||
if ($this->forceUseUnknownPort || $port === null) {
|
||||
return false;
|
||||
}
|
||||
if ($currentPort !== null && $port === $currentPort) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->application->portRequiresConfirmation($port);
|
||||
}
|
||||
|
||||
protected function openPortWarning(?int $port, string $action): void
|
||||
{
|
||||
$this->unrecognizedPort = $port;
|
||||
$this->pendingPortAction = $action;
|
||||
$this->showPortWarningModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -824,6 +918,31 @@ public function confirmDomainUsage(): void
|
|||
$this->addDomain();
|
||||
}
|
||||
|
||||
public function confirmUseUnknownPort(): void
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
$this->forceUseUnknownPort = true;
|
||||
$this->showPortWarningModal = false;
|
||||
$action = $this->pendingPortAction;
|
||||
$this->pendingPortAction = null;
|
||||
|
||||
if ($action === 'update') {
|
||||
$this->updateDomain();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addDomain();
|
||||
}
|
||||
|
||||
public function cancelUseUnknownPort(): void
|
||||
{
|
||||
$this->showPortWarningModal = false;
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->unrecognizedPort = null;
|
||||
$this->pendingPortAction = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending conflict state when the modal is dismissed without confirmation.
|
||||
* confirmDomainUsage sets forceSaveDomains before closing the modal.
|
||||
|
|
@ -848,7 +967,7 @@ public function addDomain(): void
|
|||
return;
|
||||
}
|
||||
|
||||
if ($this->newDomainPartsChanged) {
|
||||
if ($this->newDomainPartsChanged || filled($this->newDomainParts['host'] ?? null)) {
|
||||
$this->newDomain = DomainUrlParts::compose(...$this->newDomainParts);
|
||||
}
|
||||
$this->validateOnly('newDomain');
|
||||
|
|
@ -867,15 +986,24 @@ public function addDomain(): void
|
|||
->values()
|
||||
->all();
|
||||
$current = $this->currentDomainList($this->newDomainService);
|
||||
$currentCanonicalDomains = $current->map(
|
||||
fn (string $url): string => DomainPortOverrides::withoutPort($url)
|
||||
);
|
||||
|
||||
foreach ($newUrls as $url) {
|
||||
if ($current->contains($url)) {
|
||||
if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) {
|
||||
$this->addError('newDomain', "Domain {$url} is already configured.");
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) {
|
||||
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
|
||||
$this->pendingAction = 'add';
|
||||
if (! $this->saveDomainList($merged, $this->newDomainService)) {
|
||||
|
|
@ -884,6 +1012,7 @@ public function addDomain(): void
|
|||
|
||||
$this->forceSaveDomains = false;
|
||||
$this->pendingAction = null;
|
||||
$this->forceUseUnknownPort = false;
|
||||
$serviceForCheck = $this->newDomainService;
|
||||
$this->resetAddDomainForm();
|
||||
$this->dispatch('close-modal');
|
||||
|
|
@ -1110,6 +1239,11 @@ public function startEdit(int $index): void
|
|||
$this->editingIndex = $index;
|
||||
$this->editingDomain = $this->domainRows[$index]['url'];
|
||||
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
|
||||
$canonical = DomainPortOverrides::withoutPort($this->editingDomain);
|
||||
$savedPort = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null;
|
||||
if (filled($savedPort)) {
|
||||
$this->editingDomainParts['port'] = (string) $savedPort;
|
||||
}
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingService = $this->domainRows[$index]['service'];
|
||||
$this->resetEditDomainDnsGate();
|
||||
|
|
@ -1227,7 +1361,7 @@ public function updateDomain(): void
|
|||
return;
|
||||
}
|
||||
|
||||
if ($this->editingDomainPartsChanged) {
|
||||
if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) {
|
||||
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
|
||||
}
|
||||
$this->validateOnly('editingDomain');
|
||||
|
|
@ -1244,13 +1378,29 @@ public function updateDomain(): void
|
|||
$service = $this->editingService;
|
||||
$wasNoindexed = $this->application->isDomainNoindexed($oldUrl);
|
||||
|
||||
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
|
||||
$portOverrides = $this->application->domain_port_overrides ?? [];
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]);
|
||||
$this->application->domain_port_overrides = $portOverrides ?: null;
|
||||
}
|
||||
|
||||
$current = $this->currentDomainList($service);
|
||||
if ($newUrl !== $oldUrl && $current->contains($newUrl)) {
|
||||
$otherCanonicalDomains = $current
|
||||
->reject(fn (string $url): bool => $url === $oldUrl)
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url));
|
||||
if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) {
|
||||
$this->addError('editingDomain', "Domain {$newUrl} is already configured.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) {
|
||||
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage([$newUrl]);
|
||||
if ($dnsFailure !== null) {
|
||||
|
|
@ -1278,6 +1428,7 @@ public function updateDomain(): void
|
|||
|
||||
$this->forceSaveDomains = false;
|
||||
$this->pendingAction = null;
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->cancelEdit();
|
||||
$this->dispatch('edit-domain-saved');
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
|
|
@ -1823,6 +1974,8 @@ protected function saveDomainList(
|
|||
}
|
||||
}
|
||||
|
||||
$intendedComposeOverrides = null;
|
||||
|
||||
if ($this->isCompose) {
|
||||
if (blank($serviceName)) {
|
||||
$this->dispatch('error', 'A service is required for compose domains.');
|
||||
|
|
@ -1838,6 +1991,15 @@ protected function saveDomainList(
|
|||
$allDomains = [];
|
||||
}
|
||||
|
||||
$previousServiceUrls = $this->currentDomainList($serviceName);
|
||||
$normalizedPorts = DomainPortOverrides::normalize($domainString, $this->application->domain_port_overrides);
|
||||
$domainString = $normalizedPorts['fqdn'];
|
||||
$intendedComposeOverrides = $this->mergeComposeDomainPortOverrides(
|
||||
$previousServiceUrls,
|
||||
$domainString,
|
||||
$normalizedPorts['overrides'] ?? null,
|
||||
);
|
||||
|
||||
$existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : [];
|
||||
// Preserve stored redirect only — pending Direction dropdown values must not
|
||||
// persist until setServiceRedirect() runs.
|
||||
|
|
@ -1846,6 +2008,7 @@ protected function saveDomainList(
|
|||
]);
|
||||
|
||||
$this->application->docker_compose_domains = json_encode($allDomains);
|
||||
$this->application->domain_port_overrides = $intendedComposeOverrides;
|
||||
$this->application->fqdn = null;
|
||||
} else {
|
||||
$this->application->fqdn = $domainString;
|
||||
|
|
@ -1872,12 +2035,47 @@ protected function saveDomainList(
|
|||
}
|
||||
|
||||
$this->application->save();
|
||||
|
||||
if ($this->isCompose && ($this->application->domain_port_overrides ?? null) !== $intendedComposeOverrides) {
|
||||
$this->application->domain_port_overrides = $intendedComposeOverrides;
|
||||
$this->application->save();
|
||||
}
|
||||
|
||||
$this->resetDefaultLabels();
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, string> $previousServiceUrls
|
||||
* @param array<string, int>|null $incomingOverrides
|
||||
* @return array<string, int>|null
|
||||
*/
|
||||
protected function mergeComposeDomainPortOverrides(
|
||||
Collection $previousServiceUrls,
|
||||
?string $newDomainString,
|
||||
?array $incomingOverrides,
|
||||
): ?array {
|
||||
$merged = $this->application->domain_port_overrides ?? [];
|
||||
$newCanonical = collect($this->splitDomains($newDomainString))
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
|
||||
->all();
|
||||
|
||||
foreach ($previousServiceUrls as $url) {
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
if (! in_array($canonical, $newCanonical, true)) {
|
||||
unset($merged[$canonical]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($incomingOverrides ?? [] as $url => $port) {
|
||||
$merged[$url] = (int) $port;
|
||||
}
|
||||
|
||||
return $merged ?: null;
|
||||
}
|
||||
|
||||
protected function resetDefaultLabels(): void
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class PreviewDomains extends Component
|
||||
{
|
||||
|
|
@ -28,6 +28,14 @@ class PreviewDomains extends Component
|
|||
|
||||
public ?int $editingIndex = null;
|
||||
|
||||
public bool $showPortWarningModal = false;
|
||||
|
||||
public bool $forceUseUnknownPort = false;
|
||||
|
||||
public ?int $unrecognizedPort = null;
|
||||
|
||||
public ?string $pendingPortAction = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->refreshDomains();
|
||||
|
|
@ -57,11 +65,20 @@ public function addDomain(): void
|
|||
if ($domain === null) {
|
||||
return;
|
||||
}
|
||||
if (collect($this->domainRows)->contains(fn (array $row): bool => $row['url'] === $domain && $row['service'] === $this->newDomainService)) {
|
||||
$canonicalDomain = DomainPortOverrides::withoutPort($domain);
|
||||
if (collect($this->domainRows)->contains(
|
||||
fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === $canonicalDomain
|
||||
&& $row['service'] === $this->newDomainService
|
||||
)) {
|
||||
$this->addError('newDomainParts.host', 'This domain is already configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) {
|
||||
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->domainRows[] = $this->makeRow($domain, $this->newDomainService);
|
||||
$index = array_key_last($this->domainRows);
|
||||
$checkId = new_public_id();
|
||||
|
|
@ -71,10 +88,12 @@ public function addDomain(): void
|
|||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
$domain = $this->domainRows[$index]['url'] ?? DomainPortOverrides::withoutPort($domain);
|
||||
$this->newDomainParts = DomainUrlParts::empty();
|
||||
$this->newDomainService = $this->preview->application->build_pack === 'dockercompose'
|
||||
? ($this->composeServices()[0] ?? null)
|
||||
: null;
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->dispatch('close-modal');
|
||||
|
||||
try {
|
||||
|
|
@ -109,7 +128,8 @@ public function generateDomain(): void
|
|||
$service = $this->newDomainService ?? data_get($this->domainRows, '0.service');
|
||||
foreach ($this->generateComposeDomains((string) $service) as $domain) {
|
||||
$alreadyExists = collect($this->domainRows)->contains(
|
||||
fn (array $row): bool => $row['url'] === $domain && $row['service'] === $service
|
||||
fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === DomainPortOverrides::withoutPort($domain)
|
||||
&& $row['service'] === $service
|
||||
);
|
||||
if (! $alreadyExists) {
|
||||
$this->domainRows[] = $this->makeRow($domain, $service);
|
||||
|
|
@ -134,6 +154,11 @@ public function startEdit(int $index): void
|
|||
}
|
||||
$this->editingIndex = $index;
|
||||
$this->editingDomainParts = DomainUrlParts::split($this->domainRows[$index]['url']);
|
||||
$canonical = DomainPortOverrides::withoutPort($this->domainRows[$index]['url']);
|
||||
$savedPort = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null;
|
||||
if (filled($savedPort)) {
|
||||
$this->editingDomainParts['port'] = (string) $savedPort;
|
||||
}
|
||||
$this->dispatch('open-preview-domain-edit');
|
||||
}
|
||||
|
||||
|
|
@ -147,6 +172,18 @@ public function updateDomain(): void
|
|||
if ($domain === null) {
|
||||
return;
|
||||
}
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) {
|
||||
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
|
||||
|
||||
return;
|
||||
}
|
||||
if (blank(DomainUrlParts::split($domain)['port'] ?? null)) {
|
||||
$portOverrides = $this->preview->domain_port_overrides ?? [];
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($domain)]);
|
||||
$this->preview->domain_port_overrides = $portOverrides ?: null;
|
||||
}
|
||||
$this->domainRows[$this->editingIndex]['url'] = $domain;
|
||||
$this->domainRows[$this->editingIndex]['dns_status'] = 'pending';
|
||||
$this->domainRows[$this->editingIndex]['dns_message'] = 'DNS has not been checked yet.';
|
||||
|
|
@ -155,11 +192,37 @@ public function updateDomain(): void
|
|||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->dispatch('close-preview-domain-edit');
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
$this->checkDomainDns($index);
|
||||
}
|
||||
|
||||
public function confirmUseUnknownPort(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
$this->forceUseUnknownPort = true;
|
||||
$this->showPortWarningModal = false;
|
||||
$action = $this->pendingPortAction;
|
||||
$this->pendingPortAction = null;
|
||||
|
||||
if ($action === 'update') {
|
||||
$this->updateDomain();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addDomain();
|
||||
}
|
||||
|
||||
public function cancelUseUnknownPort(): void
|
||||
{
|
||||
$this->showPortWarningModal = false;
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->unrecognizedPort = null;
|
||||
$this->pendingPortAction = null;
|
||||
}
|
||||
|
||||
public function removeDomain(int $index): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
|
|
@ -299,8 +362,24 @@ private function persistDomains(): bool
|
|||
} else {
|
||||
$this->preview->fqdn = collect($this->domainRows)->pluck('url')->implode(',') ?: null;
|
||||
}
|
||||
$normalized = DomainPortOverrides::normalize($this->preview->fqdn, $this->preview->domain_port_overrides);
|
||||
$this->preview->fqdn = $normalized['fqdn'];
|
||||
$this->preview->domain_port_overrides = $normalized['overrides'];
|
||||
if ($this->preview->application->build_pack === 'dockercompose' && is_array($domains ?? null)) {
|
||||
foreach ($domains as $service => $entry) {
|
||||
$serviceDomains = $this->splitDomains(composeDomainEntryString($entry));
|
||||
$domains[$service]['domain'] = collect($serviceDomains)
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
|
||||
->implode(',');
|
||||
}
|
||||
$this->preview->docker_compose_domains = json_encode($domains);
|
||||
}
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
$this->domainRows[$index]['url'] = DomainPortOverrides::withoutPort($row['url']);
|
||||
}
|
||||
$this->preview->save();
|
||||
$this->persistDnsStatuses();
|
||||
$this->refreshDomains();
|
||||
$this->dispatch('update_links');
|
||||
$this->dispatch('previewDomainsChanged');
|
||||
|
||||
|
|
@ -360,16 +439,107 @@ private function validatedDomain(array $parts, string $errorKey): ?string
|
|||
private function makeRow(string $url, ?string $service, array $statuses = []): array
|
||||
{
|
||||
$status = $statuses[$this->statusKey($url, $service)] ?? [];
|
||||
$port = $this->effectiveDomainInternalPort($url);
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'service' => $service,
|
||||
'internal_port' => $port['internal_port'],
|
||||
'has_port_override' => $port['has_port_override'],
|
||||
'dns_status' => $status['status'] ?? 'pending',
|
||||
'dns_message' => $status['message'] ?? 'DNS has not been checked yet.',
|
||||
'check_id' => $status['check_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{scheme: string, host: string, port: string, path: string} $parts
|
||||
*/
|
||||
private function portFromParts(array $parts): ?int
|
||||
{
|
||||
$port = trim((string) ($parts['port'] ?? ''));
|
||||
if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $port;
|
||||
}
|
||||
|
||||
private function currentRowPort(string $url): ?int
|
||||
{
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
$override = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null;
|
||||
if (filled($override) && (int) $override > 0) {
|
||||
return (int) $override;
|
||||
}
|
||||
|
||||
$legacy = DomainUrlParts::split($url)['port'] ?? '';
|
||||
|
||||
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
|
||||
}
|
||||
|
||||
private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool
|
||||
{
|
||||
if ($this->forceUseUnknownPort || $port === null) {
|
||||
return false;
|
||||
}
|
||||
if ($currentPort !== null && $port === $currentPort) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->preview->application->portRequiresConfirmation($port);
|
||||
}
|
||||
|
||||
private function openPortWarning(?int $port, string $action): void
|
||||
{
|
||||
$this->unrecognizedPort = $port;
|
||||
$this->pendingPortAction = $action;
|
||||
$this->showPortWarningModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{internal_port: ?int, has_port_override: bool}
|
||||
*/
|
||||
private function effectiveDomainInternalPort(string $url): array
|
||||
{
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
$overrides = $this->preview->domain_port_overrides ?? [];
|
||||
$legacyPortPart = DomainUrlParts::split($url)['port'] ?? '';
|
||||
$legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null;
|
||||
$hasMapEntry = array_key_exists($canonical, $overrides);
|
||||
|
||||
if ($hasMapEntry) {
|
||||
return [
|
||||
'internal_port' => (int) $overrides[$canonical],
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($legacyPort !== null) {
|
||||
return [
|
||||
'internal_port' => $legacyPort,
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->preview->application->settings?->is_static) {
|
||||
return [
|
||||
'internal_port' => 80,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$exposed = $this->preview->application->ports_exposes_array;
|
||||
$defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0
|
||||
? (int) $exposed[0]
|
||||
: null;
|
||||
|
||||
return [
|
||||
'internal_port' => $defaultPort,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function statusKey(string $url, ?string $service): string
|
||||
{
|
||||
return hash('sha256', $url.'|'.($service ?? ''));
|
||||
|
|
@ -393,14 +563,14 @@ private function generateComposeDomains(string $service): array
|
|||
}
|
||||
|
||||
return collect($this->splitDomains($domainString))->map(function (string $domain): string {
|
||||
$url = Url::fromString($domain);
|
||||
$generatedDomain = str_replace('{{random}}', new_public_id(), $this->preview->application->preview_url_template);
|
||||
$generatedDomain = str_replace('{{domain}}', $url->getHost(), $generatedDomain);
|
||||
$generatedDomain = str_replace('{{pr_id}}', (string) $this->preview->pull_request_id, $generatedDomain);
|
||||
$port = $url->getPort() !== null ? ':'.$url->getPort() : '';
|
||||
$path = ! in_array($url->getPath(), ['', '/'], true) ? $url->getPath() : '';
|
||||
$generated = $this->preview->generatedPreviewDomain($domain);
|
||||
if (filled($generated['port'])) {
|
||||
$overrides = $this->preview->domain_port_overrides ?? [];
|
||||
$overrides[$generated['url']] = $generated['port'];
|
||||
$this->preview->domain_port_overrides = $overrides;
|
||||
}
|
||||
|
||||
return "{$url->getScheme()}://{$generatedDomain}{$port}{$path}";
|
||||
return $generated['url'];
|
||||
})->all();
|
||||
}
|
||||
|
||||
|
|
@ -413,7 +583,6 @@ private function composeServices(bool $failOnError = false): array
|
|||
return [];
|
||||
}
|
||||
|
||||
$usesLegacyServiceKeys = (int) $this->preview->application->compose_parsing_version < 3;
|
||||
$previewSuffix = '-pr-'.$this->preview->pull_request_id;
|
||||
$serviceNames = [];
|
||||
foreach ($services as $serviceName => $service) {
|
||||
|
|
@ -422,7 +591,7 @@ private function composeServices(bool $failOnError = false): array
|
|||
}
|
||||
|
||||
$serviceName = (string) $serviceName;
|
||||
if ($usesLegacyServiceKeys && str_ends_with($serviceName, $previewSuffix)) {
|
||||
if (str_ends_with($serviceName, $previewSuffix)) {
|
||||
$serviceName = substr($serviceName, 0, -strlen($previewSuffix));
|
||||
}
|
||||
$serviceNames[] = $serviceName;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
|
@ -306,30 +307,15 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr
|
|||
{
|
||||
$entry = $stored[$url] ?? null;
|
||||
$displayName = $app->human_name ?: $app->name;
|
||||
$port = $this->effectiveDomainInternalPort($url, $app);
|
||||
|
||||
if (is_array($entry) && filled(data_get($entry, 'status'))) {
|
||||
return [
|
||||
'service_application_id' => $app->id,
|
||||
'service_name' => $displayName,
|
||||
'service_image' => $app->image,
|
||||
'url' => $url,
|
||||
'dns_status' => (string) data_get($entry, 'status', 'pending'),
|
||||
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
|
||||
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
|
||||
'checked_at' => data_get($entry, 'checked_at'),
|
||||
'check_id' => data_get($entry, 'check_id'),
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
'needs_force_add' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
$row = [
|
||||
'service_application_id' => $app->id,
|
||||
'service_name' => $displayName,
|
||||
'service_image' => $app->image,
|
||||
'url' => $url,
|
||||
'internal_port' => $port['internal_port'],
|
||||
'has_port_override' => $port['has_port_override'],
|
||||
'dns_status' => 'pending',
|
||||
'dns_message' => 'Not checked yet.',
|
||||
'expected_ip' => $this->serverIp,
|
||||
|
|
@ -340,6 +326,48 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr
|
|||
'suggestion_label' => null,
|
||||
'needs_force_add' => false,
|
||||
];
|
||||
|
||||
if (is_array($entry) && filled(data_get($entry, 'status'))) {
|
||||
$row['dns_status'] = (string) data_get($entry, 'status', 'pending');
|
||||
$row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.');
|
||||
$row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp;
|
||||
$row['checked_at'] = data_get($entry, 'checked_at');
|
||||
$row['check_id'] = data_get($entry, 'check_id');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{internal_port: ?int, has_port_override: bool}
|
||||
*/
|
||||
protected function effectiveDomainInternalPort(string $url, ServiceApplication $app): array
|
||||
{
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
$overrides = $app->domain_port_overrides ?? [];
|
||||
$legacyPortPart = DomainUrlParts::split($url)['port'] ?? '';
|
||||
$legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null;
|
||||
|
||||
if (array_key_exists($canonical, $overrides)) {
|
||||
return [
|
||||
'internal_port' => (int) $overrides[$canonical],
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($legacyPort !== null && $legacyPort > 0) {
|
||||
return [
|
||||
'internal_port' => $legacyPort,
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$requiredPort = $app->getRequiredPort();
|
||||
|
||||
return [
|
||||
'internal_port' => ($requiredPort !== null && $requiredPort > 0) ? $requiredPort : null,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -990,8 +1018,11 @@ public function addDomain(): void
|
|||
->all()
|
||||
: [];
|
||||
$current = collect($this->splitDomains($app->fqdn));
|
||||
$currentCanonicalDomains = $current->map(
|
||||
fn (string $url): string => DomainPortOverrides::withoutPort($url)
|
||||
);
|
||||
foreach ($newUrls as $url) {
|
||||
if ($current->contains($url)) {
|
||||
if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) {
|
||||
$this->addError('newDomain', "Domain {$url} is already configured for this service.");
|
||||
|
||||
return;
|
||||
|
|
@ -1111,6 +1142,12 @@ public function startEdit(int $index): void
|
|||
$this->editingIndex = $index;
|
||||
$this->editingDomain = $this->domainRows[$index]['url'];
|
||||
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
|
||||
$app = $this->findServiceApp((int) $this->domainRows[$index]['service_application_id']);
|
||||
$canonical = DomainPortOverrides::withoutPort($this->editingDomain);
|
||||
$savedPort = ($app?->domain_port_overrides ?? [])[$canonical] ?? null;
|
||||
if (filled($savedPort)) {
|
||||
$this->editingDomainParts['port'] = (string) $savedPort;
|
||||
}
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id'];
|
||||
$this->editDomainDnsFailed = false;
|
||||
|
|
@ -1144,7 +1181,7 @@ public function updateDomain(): void
|
|||
return;
|
||||
}
|
||||
|
||||
if ($this->editingDomainPartsChanged) {
|
||||
if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) {
|
||||
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
|
||||
}
|
||||
$this->validateOnly('editingDomain');
|
||||
|
|
@ -1166,7 +1203,17 @@ public function updateDomain(): void
|
|||
$current = collect($this->splitDomains($app->fqdn));
|
||||
$wasNoindexed = $app->isDomainNoindexed($oldUrl);
|
||||
|
||||
if ($newUrl !== $oldUrl && $current->contains($newUrl)) {
|
||||
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
|
||||
$portOverrides = $app->domain_port_overrides ?? [];
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]);
|
||||
$app->domain_port_overrides = $portOverrides ?: null;
|
||||
}
|
||||
|
||||
$otherCanonicalDomains = $current
|
||||
->reject(fn (string $url): bool => $url === $oldUrl)
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url));
|
||||
if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) {
|
||||
$this->addError('editingDomain', "Domain {$newUrl} is already configured for this service.");
|
||||
|
||||
return;
|
||||
|
|
@ -1398,8 +1445,9 @@ protected function saveDomainListForApp(
|
|||
if (! $this->forceRemovePort) {
|
||||
$requiredPort = $app->getRequiredPort();
|
||||
if ($requiredPort !== null && $domainString) {
|
||||
$previousFqdn = $app->getOriginal('fqdn');
|
||||
foreach ($this->splitDomains($domainString) as $fqdn) {
|
||||
if (ServiceApplication::extractPortFromUrl($fqdn) === null) {
|
||||
if ($app->portRequiresConfirmation($fqdn, $requiredPort, is_string($previousFqdn) ? $previousFqdn : null)) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
$app->refresh();
|
||||
|
|
|
|||
|
|
@ -52,12 +52,12 @@ private function syncData(bool $toModel = false): void
|
|||
$this->validate();
|
||||
|
||||
// Sync to model
|
||||
$this->application->fqdn = $this->fqdn;
|
||||
$this->application->setEditableUrls($this->fqdn);
|
||||
|
||||
$this->application->save();
|
||||
} else {
|
||||
// Sync from model
|
||||
$this->fqdn = $this->application->fqdn;
|
||||
$this->fqdn = $this->application->url;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +84,10 @@ public function cancelRemovePort()
|
|||
public function submit()
|
||||
{
|
||||
try {
|
||||
$persistedApplication = $this->application->fresh();
|
||||
$previousEditableUrls = $persistedApplication->url;
|
||||
$previousFqdn = $persistedApplication->fqdn;
|
||||
$previousPortOverrides = $persistedApplication->domain_port_overrides;
|
||||
$this->authorize('update', $this->application);
|
||||
$this->validate();
|
||||
|
||||
|
|
@ -93,7 +97,7 @@ public function submit()
|
|||
$this->dispatch('warning', __('warning.sslipdomain'));
|
||||
}
|
||||
// Sync to model for domain conflict check (without validation)
|
||||
$this->application->fqdn = $this->fqdn;
|
||||
$this->application->setEditableUrls($this->fqdn);
|
||||
// Check for domain conflicts if not forcing save
|
||||
if (! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
|
|
@ -113,29 +117,21 @@ public function submit()
|
|||
$requiredPort = $this->application->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
// Check if all FQDNs have a port
|
||||
$fqdns = str($this->fqdn)->trim()->explode(',');
|
||||
$missingPort = false;
|
||||
|
||||
foreach ($fqdns as $fqdn) {
|
||||
$fqdn = trim($fqdn);
|
||||
if (empty($fqdn)) {
|
||||
foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) {
|
||||
$fqdn = trim((string) $fqdn);
|
||||
if ($fqdn === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$port = ServiceApplication::extractPortFromUrl($fqdn);
|
||||
if ($port === null) {
|
||||
$missingPort = true;
|
||||
break;
|
||||
if ($this->application->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
$this->application->fqdn = $previousFqdn;
|
||||
$this->application->domain_port_overrides = $previousPortOverrides;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingPort) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
|
|
|
|||
|
|
@ -99,10 +99,27 @@ class Index extends Component
|
|||
'isStripprefixEnabled' => 'nullable|boolean',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
public function mount(?ServiceApplication $serviceApplication = null)
|
||||
{
|
||||
try {
|
||||
$this->services = collect([]);
|
||||
if ($serviceApplication) {
|
||||
$this->service = $serviceApplication->service;
|
||||
$this->authorize('view', $this->service);
|
||||
$this->parameters = [
|
||||
'project_uuid' => $this->service->environment->project->uuid,
|
||||
'environment_uuid' => $this->service->environment->uuid,
|
||||
'service_uuid' => $this->service->uuid,
|
||||
'stack_service_uuid' => $serviceApplication->uuid,
|
||||
];
|
||||
$this->query = request()->query();
|
||||
$this->serviceApplication = $serviceApplication;
|
||||
$this->resourceType = 'application';
|
||||
$this->initializeApplicationProperties();
|
||||
$this->s3s = currentTeam()->s3s;
|
||||
|
||||
return;
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->query = request()->query();
|
||||
$this->currentRoute = request()->route()->getName();
|
||||
|
|
@ -350,7 +367,7 @@ private function syncApplicationData(bool $toModel = false): void
|
|||
if ($toModel) {
|
||||
$this->serviceApplication->human_name = $this->humanName;
|
||||
$this->serviceApplication->description = $this->description;
|
||||
$this->serviceApplication->fqdn = $this->fqdn;
|
||||
$this->serviceApplication->setEditableUrls($this->fqdn);
|
||||
$this->serviceApplication->image = $this->image;
|
||||
$this->serviceApplication->exclude_from_status = $this->excludeFromStatus;
|
||||
$this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
|
|
@ -359,7 +376,7 @@ private function syncApplicationData(bool $toModel = false): void
|
|||
} else {
|
||||
$this->humanName = $this->serviceApplication->human_name;
|
||||
$this->description = $this->serviceApplication->description;
|
||||
$this->fqdn = $this->serviceApplication->fqdn;
|
||||
$this->fqdn = $this->serviceApplication->url;
|
||||
$this->image = $this->serviceApplication->image;
|
||||
$this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false);
|
||||
$this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false);
|
||||
|
|
@ -485,6 +502,10 @@ public function cancelRemovePort()
|
|||
public function submitApplication()
|
||||
{
|
||||
try {
|
||||
$persistedApplication = $this->serviceApplication->fresh();
|
||||
$previousEditableUrls = $persistedApplication->url;
|
||||
$previousFqdn = $persistedApplication->fqdn;
|
||||
$previousPortOverrides = $persistedApplication->domain_port_overrides;
|
||||
$this->authorize('update', $this->serviceApplication);
|
||||
$this->validate([
|
||||
'fqdn' => ValidationPatterns::applicationDomainRules(),
|
||||
|
|
@ -514,28 +535,21 @@ public function submitApplication()
|
|||
$requiredPort = $this->serviceApplication->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
$fqdns = str($this->fqdn)->trim()->explode(',');
|
||||
$missingPort = false;
|
||||
|
||||
foreach ($fqdns as $fqdn) {
|
||||
$fqdn = trim($fqdn);
|
||||
if (empty($fqdn)) {
|
||||
foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) {
|
||||
$fqdn = trim((string) $fqdn);
|
||||
if ($fqdn === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$port = ServiceApplication::extractPortFromUrl($fqdn);
|
||||
if ($port === null) {
|
||||
$missingPort = true;
|
||||
break;
|
||||
if ($this->serviceApplication->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
$this->serviceApplication->fqdn = $previousFqdn;
|
||||
$this->serviceApplication->domain_port_overrides = $previousPortOverrides;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingPort) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->forceRemovePort = false;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiff;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasConfiguration;
|
||||
use App\Traits\HasMetrics;
|
||||
|
|
@ -135,6 +137,7 @@ class Application extends BaseModel
|
|||
'description',
|
||||
'fqdn',
|
||||
'noindex_domains',
|
||||
'domain_port_overrides',
|
||||
'git_repository',
|
||||
'git_branch',
|
||||
'git_commit_sha',
|
||||
|
|
@ -246,6 +249,7 @@ class Application extends BaseModel
|
|||
'docker_compose_raw',
|
||||
'custom_labels',
|
||||
'domain_dns_statuses',
|
||||
'domain_port_overrides',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
|
@ -258,6 +262,7 @@ protected function casts(): array
|
|||
'manual_webhook_secret_gitea' => 'encrypted',
|
||||
'noindex_domains' => 'array',
|
||||
'domain_dns_statuses' => 'array',
|
||||
'domain_port_overrides' => 'array',
|
||||
'restart_count' => 'integer',
|
||||
'max_restart_count' => 'integer',
|
||||
'restart_limit_reached' => 'boolean',
|
||||
|
|
@ -286,6 +291,9 @@ protected static function booted()
|
|||
if ($application->fqdn === '') {
|
||||
$application->fqdn = null;
|
||||
}
|
||||
$normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides);
|
||||
$application->fqdn = $normalized['fqdn'];
|
||||
$application->domain_port_overrides = $normalized['overrides'];
|
||||
$payload['fqdn'] = $application->fqdn;
|
||||
$application->syncNoindexDomains();
|
||||
}
|
||||
|
|
@ -974,6 +982,46 @@ public function main_port()
|
|||
return $this->settings->is_static ? [80] : $this->ports_exposes_array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ports the container is expected to listen on: Ports Exposes plus ports already used by application domains.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function availableInternalPorts(): array
|
||||
{
|
||||
$ports = collect($this->settings?->is_static ? [80] : $this->ports_exposes_array)
|
||||
->filter(fn (mixed $port): bool => is_numeric($port) && (int) $port > 0)
|
||||
->map(fn (mixed $port): int => (int) $port);
|
||||
|
||||
foreach ($this->domain_port_overrides ?? [] as $port) {
|
||||
if (is_numeric($port) && (int) $port > 0) {
|
||||
$ports->push((int) $port);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (explode(',', (string) $this->fqdn) as $url) {
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$legacyPort = DomainUrlParts::split($url)['port'] ?? '';
|
||||
if ($legacyPort !== '' && is_numeric($legacyPort) && (int) $legacyPort > 0) {
|
||||
$ports->push((int) $legacyPort);
|
||||
}
|
||||
}
|
||||
|
||||
return $ports->unique()->sort()->values()->all();
|
||||
}
|
||||
|
||||
public function portRequiresConfirmation(?int $port): bool
|
||||
{
|
||||
if ($port === null || $port <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! in_array($port, $this->availableInternalPorts(), true);
|
||||
}
|
||||
|
||||
public function detectPortFromEnvironment(?bool $isPreview = false): ?int
|
||||
{
|
||||
$envVars = $isPreview
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\HasRestartLimit;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
|
@ -25,11 +26,17 @@ class ApplicationPreview extends BaseModel
|
|||
'docker_registry_image_tag',
|
||||
'last_online_at',
|
||||
'domain_dns_statuses',
|
||||
'domain_port_overrides',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'domain_port_overrides',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pull_request_id' => 'integer',
|
||||
'domain_dns_statuses' => 'array',
|
||||
'domain_port_overrides' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
|
|
@ -85,6 +92,14 @@ protected static function booted(): void
|
|||
if ($preview->isDirty('status')) {
|
||||
$preview->last_online_at = now();
|
||||
}
|
||||
if ($preview->isDirty('fqdn')) {
|
||||
if ($preview->fqdn === '') {
|
||||
$preview->fqdn = null;
|
||||
}
|
||||
$normalized = DomainPortOverrides::normalize($preview->fqdn, $preview->domain_port_overrides);
|
||||
$preview->fqdn = $normalized['fqdn'];
|
||||
$preview->domain_port_overrides = $normalized['overrides'];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -124,24 +139,14 @@ public function generate_preview_fqdn(bool $generateWithoutApplicationDomain = f
|
|||
}
|
||||
|
||||
if ($applicationFqdn) {
|
||||
if (str($applicationFqdn)->contains(',')) {
|
||||
$url = Url::fromString(str($applicationFqdn)->explode(',')[0]);
|
||||
} else {
|
||||
$url = Url::fromString($applicationFqdn);
|
||||
}
|
||||
$template = $this->application->preview_url_template;
|
||||
$host = $url->getHost();
|
||||
$schema = $url->getScheme();
|
||||
$portInt = $url->getPort();
|
||||
$port = $portInt !== null ? ':'.$portInt : '';
|
||||
$urlPath = $url->getPath();
|
||||
$path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
|
||||
$random = new_public_id();
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
|
||||
$preview_fqdn = "$schema://$preview_fqdn{$port}{$path}";
|
||||
$this->fqdn = $preview_fqdn;
|
||||
$sourceDomain = str($applicationFqdn)->contains(',')
|
||||
? str($applicationFqdn)->explode(',')[0]
|
||||
: $applicationFqdn;
|
||||
$generated = $this->generatedPreviewDomain((string) $sourceDomain);
|
||||
$this->fqdn = $generated['url'];
|
||||
$this->domain_port_overrides = filled($generated['port'])
|
||||
? [$generated['url'] => $generated['port']]
|
||||
: null;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
|
|
@ -187,6 +192,7 @@ public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDo
|
|||
->all();
|
||||
|
||||
$docker_compose_domains = [];
|
||||
$previewPortOverrides = [];
|
||||
foreach ($serviceNames as $service_name) {
|
||||
$domain_string = getComposeServiceDomainString($applicationDomains, $service_name);
|
||||
|
||||
|
|
@ -218,20 +224,11 @@ public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDo
|
|||
continue;
|
||||
}
|
||||
|
||||
$url = Url::fromString($domain);
|
||||
$template = $this->application->preview_url_template;
|
||||
$host = $url->getHost();
|
||||
$schema = $url->getScheme();
|
||||
$portInt = $url->getPort();
|
||||
$port = $portInt !== null ? ':'.$portInt : '';
|
||||
$urlPath = $url->getPath();
|
||||
$path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
|
||||
$random = new_public_id();
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
|
||||
$preview_fqdn = "$schema://$preview_fqdn{$port}{$path}";
|
||||
$preview_domains[] = $preview_fqdn;
|
||||
$generated = $this->generatedPreviewDomain((string) $domain);
|
||||
$preview_domains[] = $generated['url'];
|
||||
if (filled($generated['port'])) {
|
||||
$previewPortOverrides[$generated['url']] = $generated['port'];
|
||||
}
|
||||
}
|
||||
|
||||
$docker_compose_domains = putComposeServiceDomain(
|
||||
|
|
@ -255,10 +252,36 @@ public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDo
|
|||
->implode(',');
|
||||
|
||||
$this->fqdn = ! empty($allDomains) ? $allDomains : null;
|
||||
$this->domain_port_overrides = $previewPortOverrides ?: null;
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{url: string, port: ?int}
|
||||
*/
|
||||
public function generatedPreviewDomain(string $sourceDomain): array
|
||||
{
|
||||
$url = Url::fromString($sourceDomain);
|
||||
$template = $this->application->preview_url_template;
|
||||
$host = $url->getHost();
|
||||
$schema = $url->getScheme();
|
||||
$urlPath = $url->getPath();
|
||||
$path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
|
||||
$random = new_public_id();
|
||||
$previewFqdn = str_replace('{{random}}', $random, $template);
|
||||
$previewFqdn = str_replace('{{domain}}', $host, $previewFqdn);
|
||||
$previewFqdn = str_replace('{{pr_id}}', (string) $this->pull_request_id, $previewFqdn);
|
||||
$previewUrl = "{$schema}://{$previewFqdn}{$path}";
|
||||
$sourceCanonical = DomainPortOverrides::withoutPort($sourceDomain);
|
||||
$port = $url->getPort() ?? ($this->application->domain_port_overrides[$sourceCanonical] ?? null);
|
||||
|
||||
return [
|
||||
'url' => $previewUrl,
|
||||
'port' => $port !== null ? (int) $port : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Original compose service names for this preview (PR suffix stripped), excluding database images.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
|
@ -92,11 +93,18 @@ protected static function booted()
|
|||
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$domains = $this->applications()->get()->pluck('fqdn')->sort()->toArray();
|
||||
$applications = $this->applications()->get();
|
||||
$domains = $applications->pluck('fqdn')->sort()->toArray();
|
||||
$domains = implode(',', $domains);
|
||||
$noindexDomains = $this->applications()->get()->pluck('noindex_domains')->flatten()->filter()->sort()->implode(',');
|
||||
$noindexDomains = $applications->pluck('noindex_domains')->flatten()->filter()->sort()->implode(',');
|
||||
$domainPortOverrides = $applications
|
||||
->mapWithKeys(fn (ServiceApplication $application): array => [
|
||||
$application->id => DomainPortOverrides::sorted($application->domain_port_overrides),
|
||||
])
|
||||
->sortKeys()
|
||||
->all();
|
||||
|
||||
$applicationImages = $this->applications()->get()->pluck('image')->sort();
|
||||
$applicationImages = $applications->pluck('image')->sort();
|
||||
$databaseImages = $this->databases()->get()->pluck('image')->sort();
|
||||
$images = $applicationImages->merge($databaseImages);
|
||||
$images = implode(',', $images->toArray());
|
||||
|
|
@ -105,7 +113,7 @@ public function isConfigurationChanged(bool $save = false)
|
|||
$databaseStorages = $this->databases()->get()->pluck('persistentStorages')->flatten()->sortBy('id');
|
||||
$storages = $applicationStorages->merge($databaseStorages)->implode('updated_at');
|
||||
|
||||
$newConfigHash = $images.$domains.$images.$storages.$noindexDomains;
|
||||
$newConfigHash = $images.$domains.$images.$storages.$noindexDomains.json_encode($domainPortOverrides);
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
|
@ -1496,7 +1504,7 @@ public function getRequiredPort(): ?int
|
|||
{
|
||||
try {
|
||||
$services = get_service_templates();
|
||||
$serviceName = str($this->name)->beforeLast('-')->value();
|
||||
$serviceName = $this->service_type ?: str($this->name)->beforeLast('-')->value();
|
||||
$service = data_get($services, $serviceName, []);
|
||||
$port = data_get($service, 'port');
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Traits\HasNoindexDomains;
|
||||
use App\Traits\HasRestartLimit;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
|
@ -13,6 +15,8 @@ class ServiceApplication extends BaseModel
|
|||
{
|
||||
use HasFactory, HasNoindexDomains, HasRestartLimit, SoftDeletes;
|
||||
|
||||
protected $appends = ['url'];
|
||||
|
||||
protected $fillable = [
|
||||
'service_id',
|
||||
'name',
|
||||
|
|
@ -22,6 +26,7 @@ class ServiceApplication extends BaseModel
|
|||
'noindex_domains',
|
||||
'redirect',
|
||||
'domain_dns_statuses',
|
||||
'domain_port_overrides',
|
||||
'ports',
|
||||
'exposes',
|
||||
'status',
|
||||
|
|
@ -44,6 +49,7 @@ class ServiceApplication extends BaseModel
|
|||
*/
|
||||
protected $hidden = [
|
||||
'domain_dns_statuses',
|
||||
'domain_port_overrides',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
|
|
@ -54,6 +60,7 @@ protected function casts(): array
|
|||
{
|
||||
return [
|
||||
'domain_dns_statuses' => 'array',
|
||||
'domain_port_overrides' => 'array',
|
||||
'noindex_domains' => 'array',
|
||||
'is_force_https_enabled' => 'boolean',
|
||||
];
|
||||
|
|
@ -71,6 +78,7 @@ protected static function booted()
|
|||
$service->last_online_at = now();
|
||||
}
|
||||
if ($service->isDirty('fqdn')) {
|
||||
$service->normalizeDomainPortOverrides();
|
||||
$service->syncNoindexDomains();
|
||||
}
|
||||
});
|
||||
|
|
@ -192,6 +200,45 @@ public function fqdns(): Attribute
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the public URLs with their persisted internal port overrides.
|
||||
*/
|
||||
protected function url(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function (): ?string {
|
||||
if (blank($this->fqdn)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$overrides = $this->domain_port_overrides ?? [];
|
||||
|
||||
return collect(explode(',', $this->fqdn))
|
||||
->map(function (string $url) use ($overrides): string {
|
||||
$url = trim($url);
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
$port = $overrides[$canonical] ?? null;
|
||||
|
||||
if ($port === null) {
|
||||
return $canonical;
|
||||
}
|
||||
|
||||
$parts = DomainUrlParts::split($canonical);
|
||||
|
||||
return DomainUrlParts::compose($parts['scheme'], $parts['host'], (string) $port, $parts['path']);
|
||||
})
|
||||
->implode(',');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function setEditableUrls(?string $urls): void
|
||||
{
|
||||
$normalized = DomainPortOverrides::normalize($urls, null);
|
||||
$this->fqdn = $normalized['fqdn'];
|
||||
$this->domain_port_overrides = $normalized['overrides'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract port number from a given FQDN URL.
|
||||
* Returns null if no port is specified.
|
||||
|
|
@ -213,6 +260,58 @@ public static function extractPortFromUrl(string $url): ?int
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when saving this URL should confirm that it does not use the required template port.
|
||||
*/
|
||||
public function portRequiresConfirmation(string $fqdn, ?int $requiredPort, ?string $previousFqdn = null): bool
|
||||
{
|
||||
if ($requiredPort === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fqdn = trim($fqdn);
|
||||
if ($fqdn === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$canonical = DomainPortOverrides::withoutPort($fqdn);
|
||||
$explicit = self::extractPortFromUrl($fqdn);
|
||||
|
||||
if ($explicit === $requiredPort) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($explicit === null) {
|
||||
$previous = collect(explode(',', (string) $previousFqdn))
|
||||
->filter();
|
||||
$previousUrl = $previous->first(
|
||||
fn (string $url): bool => DomainPortOverrides::withoutPort(trim($url)) === $canonical
|
||||
);
|
||||
|
||||
if (is_string($previousUrl) && self::extractPortFromUrl($previousUrl) !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $previousUrl === null;
|
||||
}
|
||||
|
||||
$existingOverride = $this->domain_port_overrides[$canonical] ?? null;
|
||||
|
||||
return (int) $existingOverride !== $explicit;
|
||||
}
|
||||
|
||||
public static function withoutPort(string $url): string
|
||||
{
|
||||
return DomainPortOverrides::withoutPort($url);
|
||||
}
|
||||
|
||||
protected function normalizeDomainPortOverrides(): void
|
||||
{
|
||||
$normalized = DomainPortOverrides::normalize($this->fqdn, $this->domain_port_overrides);
|
||||
$this->fqdn = $normalized['fqdn'];
|
||||
$this->domain_port_overrides = $normalized['overrides'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all FQDNs have a port specified.
|
||||
*/
|
||||
|
|
@ -277,6 +376,7 @@ public function getRequiredPort(): ?int
|
|||
// Extract SERVICE_URL and SERVICE_FQDN variables DIRECTLY DECLARED in this service's environment
|
||||
// (not variables that are merely referenced with ${VAR} syntax)
|
||||
$portFound = null;
|
||||
$declaresHttpUrl = false;
|
||||
foreach ($environment as $key => $value) {
|
||||
if (is_int($key) && is_string($value)) {
|
||||
// List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value"
|
||||
|
|
@ -285,6 +385,7 @@ public function getRequiredPort(): ?int
|
|||
|
||||
// Only process direct declarations
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$declaresHttpUrl = true;
|
||||
// Parse to check if it has a port suffix
|
||||
$parsed = parseServiceEnvironmentVariable($envVarName->value());
|
||||
if ($parsed['has_port'] && $parsed['port']) {
|
||||
|
|
@ -299,6 +400,7 @@ public function getRequiredPort(): ?int
|
|||
|
||||
// Only process direct declarations
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$declaresHttpUrl = true;
|
||||
// Parse to check if it has a port suffix
|
||||
$parsed = parseServiceEnvironmentVariable($envVarName->value());
|
||||
if ($parsed['has_port'] && $parsed['port']) {
|
||||
|
|
@ -315,8 +417,12 @@ public function getRequiredPort(): ?int
|
|||
return $portFound;
|
||||
}
|
||||
|
||||
// No port-specific variables found for this service, return null
|
||||
// (DO NOT fall back to service-level port, as that applies to all services)
|
||||
// HTTP-facing compose services that only declare SERVICE_URL/FQDN (no _PORT
|
||||
// suffix), such as WordPress, inherit the one-click template `# port:`.
|
||||
if ($declaresHttpUrl) {
|
||||
return $this->service->getRequiredPort();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ApplicationConfigurationSnapshot
|
||||
|
|
@ -194,6 +195,7 @@ private function domainItems(): array
|
|||
{
|
||||
return [
|
||||
$this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'),
|
||||
$this->item('domain_port_overrides', 'Domain port overrides', DomainPortOverrides::sorted($this->application->domain_port_overrides), 'redeploy'),
|
||||
$this->item('noindex_domains', 'Search engine indexing', $this->application->noindexDomains()->all(), 'redeploy'),
|
||||
$this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'),
|
||||
$this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'),
|
||||
|
|
|
|||
91
app/Support/DomainPortOverrides.php
Normal file
91
app/Support/DomainPortOverrides.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class DomainPortOverrides
|
||||
{
|
||||
/**
|
||||
* @param array<string, int|string>|null $overrides
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
public static function sorted(?array $overrides): array
|
||||
{
|
||||
return collect($overrides ?? [])->sortKeys()->all();
|
||||
}
|
||||
|
||||
public static function withoutPort(string $url): string
|
||||
{
|
||||
$parts = DomainUrlParts::split($url);
|
||||
|
||||
return DomainUrlParts::compose($parts['scheme'], $parts['host'], path: $parts['path']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int|string>|null $existing
|
||||
* @return array{fqdn: ?string, overrides: ?array<string, int>}
|
||||
*/
|
||||
public static function normalize(?string $fqdn, ?array $existing): array
|
||||
{
|
||||
if (blank($fqdn)) {
|
||||
return ['fqdn' => null, 'overrides' => null];
|
||||
}
|
||||
|
||||
$existingOverrides = $existing ?? [];
|
||||
$normalizedDomains = collect(explode(',', $fqdn))
|
||||
->map(fn (string $domain): string => trim($domain))
|
||||
->filter()
|
||||
->map(function (string $domain) use ($existingOverrides): array {
|
||||
$portlessDomain = self::withoutPort($domain);
|
||||
$parts = DomainUrlParts::split($domain);
|
||||
$port = $parts['port'] !== ''
|
||||
? (int) $parts['port']
|
||||
: ($existingOverrides[$portlessDomain] ?? null);
|
||||
|
||||
return ['domain' => $portlessDomain, 'port' => $port];
|
||||
})
|
||||
->keyBy('domain')
|
||||
->values();
|
||||
|
||||
$effectiveOverrides = $normalizedDomains
|
||||
->filter(fn (array $domain): bool => filled($domain['port']))
|
||||
->mapWithKeys(fn (array $domain): array => [$domain['domain'] => (int) $domain['port']]);
|
||||
|
||||
$normalizedDomains = $normalizedDomains->map(function (array $domain) use ($effectiveOverrides): array {
|
||||
if (filled($domain['port'])) {
|
||||
return $domain;
|
||||
}
|
||||
|
||||
$counterpart = self::wwwCounterpart($domain['domain']);
|
||||
$domain['port'] = $counterpart === null ? null : $effectiveOverrides->get($counterpart);
|
||||
|
||||
return $domain;
|
||||
});
|
||||
|
||||
$normalizedFqdn = $normalizedDomains->pluck('domain')->implode(',');
|
||||
$overrides = $normalizedDomains
|
||||
->filter(fn (array $domain): bool => filled($domain['port']))
|
||||
->mapWithKeys(fn (array $domain): array => [$domain['domain'] => (int) $domain['port']])
|
||||
->all();
|
||||
|
||||
return [
|
||||
'fqdn' => $normalizedFqdn === '' ? null : $normalizedFqdn,
|
||||
'overrides' => $overrides ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
private static function wwwCounterpart(string $url): ?string
|
||||
{
|
||||
$parts = DomainUrlParts::split($url);
|
||||
$host = $parts['host'];
|
||||
|
||||
if ($host === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$counterpartHost = str_starts_with(strtolower($host), 'www.')
|
||||
? substr($host, 4)
|
||||
: 'www.'.$host;
|
||||
|
||||
return DomainUrlParts::compose($parts['scheme'], $counterpartHost, path: $parts['path']);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,15 +25,7 @@ public static function validateUrlString(?string $urlValue, bool $forceDomainOve
|
|||
->map(fn ($url) => trim((string) $url))
|
||||
->filter();
|
||||
|
||||
foreach ($urls as $url) {
|
||||
if (! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$errors[] = "Invalid URL: {$url}";
|
||||
}
|
||||
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
|
||||
if (! in_array(strtolower($scheme), ['http', 'https'], true)) {
|
||||
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
|
||||
}
|
||||
}
|
||||
$errors = ValidationPatterns::validateApplicationDomains($urls->implode(','));
|
||||
|
||||
$duplicates = $urls->duplicates()->unique()->values();
|
||||
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
|
||||
|
|
|
|||
|
|
@ -619,6 +619,13 @@ public static function validateApplicationDomains(mixed $value): array
|
|||
continue;
|
||||
}
|
||||
|
||||
$port = parse_url($url, PHP_URL_PORT);
|
||||
if ($port !== null && ($port < 1 || $port > 65535)) {
|
||||
$errors[] = "Invalid port for URL: {$url}. The port must be between 1 and 65535.";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$unwrappedHost = trim((string) $host, '[]');
|
||||
if (! str_contains($unwrappedHost, '.') && filter_var($unwrappedHost, FILTER_VALIDATE_IP) === false) {
|
||||
$errors[] = "Invalid URL: {$url}. The hostname must be a fully qualified domain name.";
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ public function noindexDomains(): Collection
|
|||
{
|
||||
return collect($this->noindex_domains ?? [])
|
||||
->filter(fn ($domain) => is_string($domain) && filled($domain))
|
||||
->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain))
|
||||
->map(fn (string $domain) => $this->normalizeNoindexDomain($domain))
|
||||
->unique()
|
||||
->values();
|
||||
}
|
||||
|
|
@ -27,7 +28,7 @@ public function noindexDomains(): Collection
|
|||
public function isDomainNoindexed(string $domain): bool
|
||||
{
|
||||
return $this->noindexDomains()->contains(
|
||||
ValidationPatterns::normalizeApplicationDomainUrl($domain)
|
||||
$this->normalizeNoindexDomain($domain)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ public function setNoindexDomains(iterable $domains): void
|
|||
{
|
||||
$this->noindex_domains = collect($domains)
|
||||
->filter(fn ($domain) => is_string($domain) && filled($domain))
|
||||
->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain))
|
||||
->map(fn (string $domain) => $this->normalizeNoindexDomain($domain))
|
||||
->intersect($this->currentDomains())
|
||||
->unique()
|
||||
->values()
|
||||
|
|
@ -58,6 +59,13 @@ public function syncNoindexDomains(): void
|
|||
private function currentDomains(): Collection
|
||||
{
|
||||
return collect(ValidationPatterns::applicationDomainList($this->fqdn))
|
||||
->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain));
|
||||
->map(fn (string $domain) => $this->normalizeNoindexDomain($domain));
|
||||
}
|
||||
|
||||
private function normalizeNoindexDomain(string $domain): string
|
||||
{
|
||||
return DomainPortOverrides::withoutPort(
|
||||
ValidationPatterns::normalizeApplicationDomainUrl($domain)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -530,7 +530,7 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool
|
|||
->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain));
|
||||
}
|
||||
|
||||
function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null)
|
||||
function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, array $domainPortOverrides = [])
|
||||
{
|
||||
$labels = collect([]);
|
||||
if ($serviceLabels) {
|
||||
|
|
@ -554,7 +554,8 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
|
|||
if ($schema === 'https' && ! $is_force_https_enabled) {
|
||||
$siteAddress = "http://{$host}, https://{$host}";
|
||||
}
|
||||
$port = $url->getPort();
|
||||
$portlessDomain = ServiceApplication::withoutPort($domain);
|
||||
$port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null);
|
||||
$handle = 'handle_path';
|
||||
if (! $is_stripprefix_enabled) {
|
||||
$handle = 'handle';
|
||||
|
|
@ -600,7 +601,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
|
|||
return $labels->sort();
|
||||
}
|
||||
|
||||
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true)
|
||||
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [])
|
||||
{
|
||||
$labels = collect([]);
|
||||
$labels->push('traefik.enable=true');
|
||||
|
|
@ -655,7 +656,8 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
|||
$host = $url->getHost();
|
||||
$path = $url->getPath();
|
||||
$schema = $url->getScheme();
|
||||
$port = $url->getPort();
|
||||
$portlessDomain = ServiceApplication::withoutPort($domain);
|
||||
$port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null);
|
||||
if (is_null($port) && ! is_null($onlyPort)) {
|
||||
$port = $onlyPort;
|
||||
}
|
||||
|
|
@ -898,6 +900,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
|||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
noindex_domains: $noindexDomains,
|
||||
domainPortOverrides: $application->domain_port_overrides ?? [],
|
||||
));
|
||||
break;
|
||||
case ProxyTypes::CADDY->value:
|
||||
|
|
@ -914,6 +917,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
|||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
noindex_domains: $noindexDomains,
|
||||
domainPortOverrides: $application->domain_port_overrides ?? [],
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
|
@ -931,6 +935,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
|||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
noindex_domains: $noindexDomains,
|
||||
escape_redirect_replacement_for_compose: false,
|
||||
domainPortOverrides: $application->domain_port_overrides ?? [],
|
||||
));
|
||||
$labels = $labels->merge(fqdnLabelsForCaddy(
|
||||
network: $application->destination->network,
|
||||
|
|
@ -945,6 +950,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
|||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
noindex_domains: $noindexDomains,
|
||||
domainPortOverrides: $application->domain_port_overrides ?? [],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -972,6 +978,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
|||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
noindex_domains: $noindexDomains,
|
||||
escape_redirect_replacement_for_compose: false,
|
||||
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
||||
));
|
||||
break;
|
||||
case ProxyTypes::CADDY->value:
|
||||
|
|
@ -987,6 +994,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
|||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
noindex_domains: $noindexDomains,
|
||||
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
|
@ -1003,6 +1011,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
|||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
noindex_domains: $noindexDomains,
|
||||
escape_redirect_replacement_for_compose: false,
|
||||
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
||||
));
|
||||
$labels = $labels->merge(fqdnLabelsForCaddy(
|
||||
network: $application->destination->network,
|
||||
|
|
@ -1016,6 +1025,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
|
|||
http_basic_auth_username: $application->http_basic_auth_username,
|
||||
http_basic_auth_password: $application->http_basic_auth_password,
|
||||
noindex_domains: $noindexDomains,
|
||||
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1265,24 +1265,16 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$fqdns = collect([]);
|
||||
}
|
||||
} else {
|
||||
$fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) {
|
||||
$preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId);
|
||||
$url = Url::fromString($fqdn);
|
||||
$template = $resource->preview_url_template;
|
||||
$host = $url->getHost();
|
||||
$schema = $url->getScheme();
|
||||
$portInt = $url->getPort();
|
||||
$port = $portInt !== null ? ':'.$portInt : '';
|
||||
$random = new_public_id();
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
|
||||
$preview_fqdn = "$schema://$preview_fqdn{$port}";
|
||||
$preview->fqdn = $preview_fqdn;
|
||||
$preview->save();
|
||||
|
||||
return $preview_fqdn;
|
||||
});
|
||||
$generatedDomains = $fqdns->map(
|
||||
fn ($fqdn) => $preview->generatedPreviewDomain((string) $fqdn)
|
||||
);
|
||||
$fqdns = $generatedDomains->pluck('url');
|
||||
$preview->fqdn = $fqdns->implode(',');
|
||||
$preview->domain_port_overrides = $generatedDomains
|
||||
->filter(fn (array $generated): bool => filled($generated['port']))
|
||||
->mapWithKeys(fn (array $generated): array => [$generated['url'] => $generated['port']])
|
||||
->all();
|
||||
$preview->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1359,6 +1351,14 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
|
||||
? $composeRedirect
|
||||
: 'both';
|
||||
$previewForPorts = $isPullRequest
|
||||
? ($resource->previews()->find($preview_id) ?? ApplicationPreview::where('application_id', $resource->id)->where('pull_request_id', $pullRequestId)->first())
|
||||
: null;
|
||||
$domainPortOverrides = $isPullRequest
|
||||
? ($previewForPorts?->domain_port_overrides ?? [])
|
||||
: ($originalResource->domain_port_overrides ?? []);
|
||||
$exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array;
|
||||
$onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null;
|
||||
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
|
||||
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
|
||||
}
|
||||
|
|
@ -1374,8 +1374,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
onlyPort: $onlyPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
));
|
||||
break;
|
||||
case ProxyTypes::CADDY->value:
|
||||
|
|
@ -1389,9 +1391,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
onlyPort: $onlyPort,
|
||||
predefinedPort: $predefinedPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
|
@ -1405,8 +1409,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
onlyPort: $onlyPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
));
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||
network: $labelNetwork,
|
||||
|
|
@ -1418,9 +1424,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
onlyPort: $onlyPort,
|
||||
predefinedPort: $predefinedPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1849,11 +1857,7 @@ function serviceParser(Service $resource): Collection
|
|||
// Only save fqdn to ServiceApplication, not ServiceDatabase
|
||||
if ($isServiceApplication && is_null($savedService->fqdn)) {
|
||||
// Save URL (with scheme) to database, not FQDN
|
||||
if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
|
||||
$savedService->fqdn = $urlWithPort;
|
||||
} else {
|
||||
$savedService->fqdn = $urlWithPort;
|
||||
}
|
||||
$savedService->fqdn = $url;
|
||||
$savedService->save();
|
||||
}
|
||||
|
||||
|
|
@ -2636,6 +2640,9 @@ function serviceParser(Service $resource): Collection
|
|||
$redirectDirection = in_array(data_get($originalResource, 'redirect'), ['www', 'non-www', 'both'], true)
|
||||
? data_get($originalResource, 'redirect')
|
||||
: 'both';
|
||||
$onlyPort = $originalResource instanceof ServiceApplication
|
||||
? ($originalResource->getRequiredPort() ?? $predefinedPort)
|
||||
: $predefinedPort;
|
||||
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
|
||||
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
|
||||
}
|
||||
|
|
@ -2651,6 +2658,8 @@ function serviceParser(Service $resource): Collection
|
|||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
onlyPort: $onlyPort,
|
||||
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
|
|
@ -2666,7 +2675,9 @@ function serviceParser(Service $resource): Collection
|
|||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
onlyPort: $onlyPort,
|
||||
predefinedPort: $predefinedPort,
|
||||
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
|
|
@ -2682,6 +2693,8 @@ function serviceParser(Service $resource): Collection
|
|||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
onlyPort: $onlyPort,
|
||||
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
|
|
@ -2695,7 +2708,9 @@ function serviceParser(Service $resource): Collection
|
|||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: $image,
|
||||
onlyPort: $onlyPort,
|
||||
predefinedPort: $predefinedPort,
|
||||
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
));
|
||||
|
|
|
|||
|
|
@ -3103,6 +3103,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
$redirectDirection = in_array(data_get($savedService, 'redirect'), ['www', 'non-www', 'both'], true)
|
||||
? data_get($savedService, 'redirect')
|
||||
: 'both';
|
||||
$domainPortOverrides = $savedService instanceof ServiceApplication
|
||||
? ($savedService->domain_port_overrides ?? [])
|
||||
: [];
|
||||
$onlyPort = $savedService instanceof ServiceApplication
|
||||
? ($savedService->getRequiredPort() ?? $predefinedPort)
|
||||
: $predefinedPort;
|
||||
if ($shouldGenerateLabelsExactly) {
|
||||
switch ($resource->server->proxyType()) {
|
||||
case ProxyTypes::TRAEFIK->value:
|
||||
|
|
@ -3115,8 +3121,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: data_get($service, 'image'),
|
||||
onlyPort: $onlyPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
));
|
||||
break;
|
||||
case ProxyTypes::CADDY->value:
|
||||
|
|
@ -3130,8 +3138,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: data_get($service, 'image'),
|
||||
onlyPort: $onlyPort,
|
||||
predefinedPort: $predefinedPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
|
@ -3145,8 +3156,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: data_get($service, 'image'),
|
||||
onlyPort: $onlyPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
));
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||
network: $resource->destination->network,
|
||||
|
|
@ -3158,8 +3171,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||
service_name: $serviceName,
|
||||
image: data_get($service, 'image'),
|
||||
onlyPort: $onlyPort,
|
||||
predefinedPort: $predefinedPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -3858,6 +3874,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
$fqdns = str($fqdns)->explode(',');
|
||||
if ($pull_request_id !== 0) {
|
||||
$preview = $resource->previews()->find($preview_id);
|
||||
if (! $preview) {
|
||||
try {
|
||||
$preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pull_request_id);
|
||||
} catch (ModelNotFoundException) {
|
||||
throw new RuntimeException('Preview not found.');
|
||||
}
|
||||
}
|
||||
$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);
|
||||
|
|
@ -3867,22 +3890,20 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
$fqdns = collect([]);
|
||||
}
|
||||
} else {
|
||||
$fqdns = $fqdns->map(function ($fqdn) use ($pull_request_id, $resource) {
|
||||
$preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pull_request_id);
|
||||
$url = Url::fromString($fqdn);
|
||||
$template = $resource->preview_url_template;
|
||||
$host = $url->getHost();
|
||||
$schema = $url->getScheme();
|
||||
$random = new_public_id();
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $pull_request_id, $preview_fqdn);
|
||||
$preview_fqdn = "$schema://$preview_fqdn";
|
||||
$preview->fqdn = $preview_fqdn;
|
||||
$preview->save();
|
||||
|
||||
return $preview_fqdn;
|
||||
});
|
||||
$generatedDomains = $fqdns->map(
|
||||
fn ($fqdn) => $preview->generatedPreviewDomain((string) $fqdn)
|
||||
);
|
||||
$fqdns = $generatedDomains->pluck('url');
|
||||
$preview->fqdn = $fqdns->implode(',');
|
||||
$generatedOverrides = $generatedDomains
|
||||
->filter(fn (array $generated): bool => filled($generated['port']))
|
||||
->mapWithKeys(fn (array $generated): array => [$generated['url'] => $generated['port']])
|
||||
->all();
|
||||
$preview->domain_port_overrides = array_replace(
|
||||
$preview->domain_port_overrides ?? [],
|
||||
$generatedOverrides,
|
||||
);
|
||||
$preview->save();
|
||||
}
|
||||
}
|
||||
$noindexDomains = $pull_request_id !== 0 ? $fqdns : $resource->noindexDomains();
|
||||
|
|
@ -3891,6 +3912,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
|
||||
? $composeRedirect
|
||||
: 'both';
|
||||
$domainPortOverrides = $pull_request_id === 0
|
||||
? ($resource->domain_port_overrides ?? [])
|
||||
: ($preview?->domain_port_overrides ?? []);
|
||||
$exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array;
|
||||
$onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null;
|
||||
if ($shouldGenerateLabelsExactly) {
|
||||
switch ($server->proxyType()) {
|
||||
case ProxyTypes::TRAEFIK->value:
|
||||
|
|
@ -3904,8 +3930,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
||||
is_gzip_enabled: $resource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
||||
onlyPort: $onlyPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
)
|
||||
);
|
||||
break;
|
||||
|
|
@ -3920,8 +3948,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
||||
is_gzip_enabled: $resource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
||||
onlyPort: $onlyPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
)
|
||||
);
|
||||
break;
|
||||
|
|
@ -3937,8 +3967,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
||||
is_gzip_enabled: $resource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
||||
onlyPort: $onlyPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
)
|
||||
);
|
||||
$serviceLabels = $serviceLabels->merge(
|
||||
|
|
@ -3951,8 +3983,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
||||
is_gzip_enabled: $resource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
||||
onlyPort: $onlyPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('service_applications', function (Blueprint $table) {
|
||||
$table->json('domain_port_overrides')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('service_applications', function (Blueprint $table) {
|
||||
$table->dropColumn('domain_port_overrides');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->json('domain_port_overrides')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->dropColumn('domain_port_overrides');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('application_previews', function (Blueprint $table) {
|
||||
$table->json('domain_port_overrides')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('application_previews', function (Blueprint $table) {
|
||||
$table->dropColumn('domain_port_overrides');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -366,4 +366,48 @@ class="icon-button shrink-0" aria-label="Close">
|
|||
|
||||
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
|
||||
confirmAction="confirmDomainUsage" />
|
||||
|
||||
@if ($showPortWarningModal)
|
||||
<div x-data="{ modalOpen: true }"
|
||||
@keydown.escape.window="modalOpen = false; $wire.call('cancelUseUnknownPort')"
|
||||
class="relative z-40">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen"
|
||||
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4" x-cloak>
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
|
||||
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header>
|
||||
<h3>Use a different port?</h3>
|
||||
<button type="button"
|
||||
@click="modalOpen = false; $wire.call('cancelUseUnknownPort')"
|
||||
class="icon-button" aria-label="Close">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="application-settings-section-body">
|
||||
<x-callout type="warning" title="Unrecognized internal port" class="mb-4">
|
||||
Port <strong>{{ $unrecognizedPort }}</strong> is not listed in Ports Exposes
|
||||
and is not used by any application domain. The proxy will still route to it,
|
||||
but the container may not be listening there.
|
||||
</x-callout>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button type="button" canGate="update" :canResource="$application"
|
||||
@click="modalOpen = false; $wire.call('cancelUseUnknownPort')">
|
||||
Cancel
|
||||
</x-forms.button>
|
||||
<x-forms.button type="button" wire:click="confirmUseUnknownPort" canGate="update"
|
||||
:canResource="$application"
|
||||
@click="modalOpen = false" isError>
|
||||
Use this port anyway
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -398,7 +398,15 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
|
|||
@endif
|
||||
|
||||
@if ($buildPack !== 'dockercompose')
|
||||
<x-application.settings-section id="networking-section" title="Networking" helper="Ports the container exposes, host port mappings and internal network aliases.">
|
||||
@php
|
||||
$applicationDomainsUrl = route('project.application.domains', [
|
||||
'project_uuid' => $application->environment->project->uuid,
|
||||
'environment_uuid' => $application->environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
]);
|
||||
$portsExposesDomainHint = "You can also set a different internal port for each domain on the <a class='underline dark:text-white' href='{$applicationDomainsUrl}'>Domains</a> page.";
|
||||
@endphp
|
||||
<x-application.settings-section id="networking-section" title="Networking" helper="Ports the container exposes, host port mappings and internal network aliases. You can also set an internal port per domain.">
|
||||
@if ($this->detectedPortInfo)
|
||||
@if ($this->detectedPortInfo['isEmpty'])
|
||||
<div
|
||||
|
|
@ -460,20 +468,30 @@ class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blu
|
|||
</x-callout>
|
||||
@endif
|
||||
<div class="grid gap-4 lg:grid-cols-[14rem_16rem_minmax(0,1fr)]">
|
||||
<div class="min-w-0">
|
||||
@if ($isStatic || $buildPack === 'static')
|
||||
<x-forms.input id="portsExposes" label="Ports exposes" readonly
|
||||
:helper="$portsExposesDomainHint"
|
||||
canGate="update" :canResource="$application"
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@else
|
||||
@if ($application->settings->is_container_label_readonly_enabled === false)
|
||||
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports exposes" readonly
|
||||
helper="Readonly labels are disabled. You can set the ports manually in the labels section."
|
||||
:helper="'Readonly labels are disabled. You can set the ports manually in the labels section.<br><br>'.$portsExposesDomainHint"
|
||||
canGate="update" :canResource="$application"
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@else
|
||||
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports exposes"
|
||||
helper="A comma separated list of ports your application uses. The first port will be used as default healthcheck port if nothing defined in the Healthcheck menu. Be sure to set this correctly."
|
||||
:helper="'A comma separated list of ports your application uses. The first port will be used as default healthcheck port if nothing defined in the Healthcheck menu. Be sure to set this correctly.<br><br>'.$portsExposesDomainHint"
|
||||
canGate="update" :canResource="$application"
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@endif
|
||||
@endif
|
||||
<p class="mt-1.5 text-xs text-neutral-500 dark:text-fg-dim">
|
||||
You can also set an internal port per domain on
|
||||
<a class="underline dark:text-white" href="{{ $applicationDomainsUrl }}" {{ wireNavigate() }}>Domains</a>.
|
||||
</p>
|
||||
</div>
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
<x-forms.input placeholder="3000:3000" id="portsMappings" label="Port mappings"
|
||||
helper="A comma separated list of ports you would like to map to the host system. Useful when you do not want to use domains.<br><br><span class='inline-block font-bold dark:text-warning'>Format:</span> host:container<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span> 3000:3000,3002:3002<br><br>Rolling update is not supported if you have a port mapped to the host."
|
||||
|
|
|
|||
|
|
@ -74,6 +74,17 @@ class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 un
|
|||
title="{{ $row['url'] }}">
|
||||
{{ $row['url'] }}
|
||||
</a>
|
||||
@if (filled($row['internal_port'] ?? null) && (int) $row['internal_port'] > 0)
|
||||
<span class="table-badge shrink-0"
|
||||
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from Ports Exposes' }}">
|
||||
Internal port {{ $row['internal_port'] }}
|
||||
</span>
|
||||
@else
|
||||
<span class="table-badge table-badge-danger shrink-0"
|
||||
title="Set Ports Exposes or a per-domain internal port so the proxy can route this domain.">
|
||||
No internal port
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
@if ($isSuggested && ! empty($row['suggestion_label']))
|
||||
<span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
|
||||
|
|
|
|||
|
|
@ -76,6 +76,17 @@
|
|||
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
|
||||
class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
|
||||
title="{{ $row['url'] }}">{{ $row['url'] }}</a>
|
||||
@if (filled($row['internal_port'] ?? null) && (int) $row['internal_port'] > 0)
|
||||
<span class="table-badge shrink-0"
|
||||
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from Ports Exposes' }}">
|
||||
Internal port {{ $row['internal_port'] }}
|
||||
</span>
|
||||
@else
|
||||
<span class="table-badge table-badge-danger shrink-0"
|
||||
title="Set Ports Exposes or a per-domain internal port so the proxy can route this domain.">
|
||||
No internal port
|
||||
</span>
|
||||
@endif
|
||||
@if (filled($row['service']))
|
||||
<span class="table-badge shrink-0">{{ $row['service'] }}</span>
|
||||
@endif
|
||||
|
|
@ -149,4 +160,48 @@ class="application-settings-form application-settings-section relative w-full ma
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@if ($showPortWarningModal)
|
||||
<div x-data="{ modalOpen: true }"
|
||||
@keydown.escape.window="modalOpen = false; $wire.call('cancelUseUnknownPort')"
|
||||
class="relative z-40">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen"
|
||||
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4" x-cloak>
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
|
||||
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header>
|
||||
<h3>Use a different port?</h3>
|
||||
<button type="button"
|
||||
@click="modalOpen = false; $wire.call('cancelUseUnknownPort')"
|
||||
class="icon-button" aria-label="Close">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="application-settings-section-body">
|
||||
<x-callout type="warning" title="Unrecognized internal port" class="mb-4">
|
||||
Port <strong>{{ $unrecognizedPort }}</strong> is not listed in Ports Exposes
|
||||
and is not used by any application domain. The proxy will still route to it,
|
||||
but the container may not be listening there.
|
||||
</x-callout>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button type="button" canGate="update" :canResource="$preview->application"
|
||||
@click="modalOpen = false; $wire.call('cancelUseUnknownPort')">
|
||||
Cancel
|
||||
</x-forms.button>
|
||||
<x-forms.button type="button" wire:click="confirmUseUnknownPort" canGate="update"
|
||||
:canResource="$preview->application"
|
||||
@click="modalOpen = false" isError>
|
||||
Use this port anyway
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -296,7 +296,7 @@ class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y
|
|||
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header>
|
||||
<h3>Remove required port?</h3>
|
||||
<h3>Use a different port?</h3>
|
||||
<button type="button"
|
||||
@click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="icon-button" aria-label="Close">
|
||||
|
|
@ -306,17 +306,17 @@ class="icon-button" aria-label="Close">
|
|||
<div class="application-settings-section-body">
|
||||
<x-callout type="warning" title="Port requirement" class="mb-4">
|
||||
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
|
||||
One or more of your domains are missing a port number.
|
||||
One or more of your domains use a different port, or none.
|
||||
</x-callout>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button type="button"
|
||||
@click="modalOpen = false; $wire.call('cancelRemovePort')">
|
||||
Keep port
|
||||
Keep required port
|
||||
</x-forms.button>
|
||||
<x-forms.button type="button" wire:click="confirmRemovePort"
|
||||
@click="modalOpen = false" isError>
|
||||
Remove port anyway
|
||||
Use this port anyway
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y
|
|||
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header>
|
||||
<h3>Remove required port?</h3>
|
||||
<h3>Use a different port?</h3>
|
||||
<button @click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="flex size-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
|
|
@ -53,7 +53,7 @@ class="flex size-7 items-center justify-center rounded-md text-neutral-500 trans
|
|||
<div class="application-settings-section-body">
|
||||
<x-callout type="warning" title="Port requirement" class="mb-4">
|
||||
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
|
||||
One or more of your domains are missing a port number.
|
||||
One or more of your domains use a different port, or none.
|
||||
</x-callout>
|
||||
|
||||
<x-callout type="danger" title="What will happen if you continue?" class="mb-4">
|
||||
|
|
@ -68,11 +68,11 @@ class="flex size-7 items-center justify-center rounded-md text-neutral-500 trans
|
|||
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button @click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="w-auto">
|
||||
Keep port
|
||||
Keep required port
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="confirmRemovePort" @click="modalOpen = false" class="w-auto"
|
||||
isError>
|
||||
Remove port anyway
|
||||
Use this port anyway
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y
|
|||
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header>
|
||||
<h3>Remove required port?</h3>
|
||||
<h3>Use a different port?</h3>
|
||||
<button @click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="flex size-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
|
|
@ -184,7 +184,7 @@ class="flex size-7 items-center justify-center rounded-md text-neutral-500 trans
|
|||
<div class="application-settings-section-body">
|
||||
<x-callout type="warning" title="Port requirement" class="mb-4">
|
||||
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
|
||||
One or more of your domains are missing a port number.
|
||||
One or more of your domains use a different port, or none.
|
||||
</x-callout>
|
||||
|
||||
<x-callout type="danger" title="What will happen if you continue?" class="mb-4">
|
||||
|
|
@ -199,11 +199,11 @@ class="flex size-7 items-center justify-center rounded-md text-neutral-500 trans
|
|||
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button @click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="w-auto">
|
||||
Keep port
|
||||
Keep required port
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="confirmRemovePort" @click="modalOpen = false" class="w-auto"
|
||||
isError>
|
||||
Remove port anyway
|
||||
Use this port anyway
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -98,6 +98,12 @@ class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 un
|
|||
title="{{ $row['url'] }}">
|
||||
{{ $row['url'] }}
|
||||
</a>
|
||||
@if (filled($row['internal_port'] ?? null) && (int) $row['internal_port'] > 0)
|
||||
<span class="table-badge shrink-0"
|
||||
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from the Coolify service port' }}">
|
||||
Internal port {{ $row['internal_port'] }}
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
@if ($isSuggested && ! empty($row['suggestion_label']))
|
||||
<span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@
|
|||
Route::post('/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::post('/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::patch('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'update_preview_by_pull_request_id'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: productivity
|
||||
# tags: lowcode, nocode, no, low, platform
|
||||
# logo: svgs/appsmith.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
appsmith:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: backend
|
||||
# tags: backend, backend-as-a-service, platform
|
||||
# logo: svgs/appwrite.svg
|
||||
# port: 3003
|
||||
|
||||
services:
|
||||
appwrite:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: productivity
|
||||
# tags: baby, parents, health, growth, activities
|
||||
# logo: svgs/babybuddy.png
|
||||
# port: 8000
|
||||
|
||||
services:
|
||||
babybuddy:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: finance
|
||||
# tags: personal finance, budgeting, expense tracking
|
||||
# logo: svgs/budge.png
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
budge:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: cms
|
||||
# tags: cms, blog, content, management
|
||||
# logo: svgs/classicpress.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
classicpress:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: cms
|
||||
# tags: cms, blog, content, management
|
||||
# logo: svgs/classicpress.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
classicpress:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: cms
|
||||
# tags: cms, blog, content, management
|
||||
# logo: svgs/classicpress.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
classicpress:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: cms
|
||||
# tags: wiki, documentation, knowledge, base
|
||||
# logo: svgs/dokuwiki.png
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
dokuwiki:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: cms
|
||||
# tags: cms, blog, content, management, postgresql
|
||||
# logo: svgs/drupal.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
drupal:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: productivity
|
||||
# tags: groceries, household, management, grocery, shopping
|
||||
# logo: svgs/grocy.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
grocy:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: productivity
|
||||
# tags: dashboard, server, applications, interface
|
||||
# logo: svgs/heimdall.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
heimdall:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: auth
|
||||
# tags: logto,identity,login,authentication,oauth,oidc,openid
|
||||
# logo: svgs/logto_dark.svg
|
||||
# port: 3001
|
||||
|
||||
services:
|
||||
logto:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: database
|
||||
# tags: database management
|
||||
# logo: svgs/phpmyadmin.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
phpmyadmin:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
# slogan: A self-hosted file-sharing service for secure and convenient file transfers, whether on a local network or the internet.
|
||||
# category: productivity
|
||||
# tags: file, sharing, transfer, local, network, internet
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
snapdrop:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: cms
|
||||
# tags: cms, blog, content, management, mariadb
|
||||
# logo: svgs/wordpress.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
wordpress:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: cms
|
||||
# tags: cms, blog, content, management, mysql
|
||||
# logo: svgs/wordpress.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
wordpress:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# category: cms
|
||||
# tags: cms, blog, content, management
|
||||
# logo: svgs/wordpress.svg
|
||||
# port: 80
|
||||
|
||||
services:
|
||||
wordpress:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -138,6 +138,33 @@ function recommendedApplicationSettingsPayload(): array
|
|||
expect(base64_decode($this->application->fresh()->custom_labels))->not->toContain('sentinel-label=true');
|
||||
});
|
||||
|
||||
test('changing a domain port regenerates managed labels with the requested port', function () {
|
||||
$this->application->settings->update(['is_container_label_readonly_enabled' => true]);
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'ports_exposes' => '80',
|
||||
'domain_port_overrides' => [
|
||||
'https://app.example.com' => 3000,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'domains' => 'https://app.example.com:8080',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$application = $this->application->fresh();
|
||||
$labels = $application->parseContainerLabels();
|
||||
|
||||
expect($application->fqdn)->toBe('https://app.example.com')
|
||||
->and($application->domain_port_overrides)->toBe([
|
||||
'https://app.example.com' => 8080,
|
||||
])
|
||||
->and($labels)->toContain('loadbalancer.server.port=8080')
|
||||
->and($labels)->not->toContain('loadbalancer.server.port=3000');
|
||||
});
|
||||
|
||||
test('http basic auth updates regenerate managed labels', function () {
|
||||
$this->application->settings->update(['is_container_label_readonly_enabled' => true]);
|
||||
$this->application->update([
|
||||
|
|
@ -279,6 +306,34 @@ function advancedApplicationSettingsPayload(): array
|
|||
->and($application->max_restart_count)->toBe(5);
|
||||
});
|
||||
|
||||
test('PATCH /api/v1/applications/{uuid} clears ports_exposes with null or an empty string', function (mixed $portsExposes) {
|
||||
$this->application->update(['ports_exposes' => '3000,8080']);
|
||||
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'ports_exposes' => $portsExposes,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect($this->application->fresh()->ports_exposes)->toBeNull();
|
||||
})->with([
|
||||
'null' => null,
|
||||
'empty string' => '',
|
||||
]);
|
||||
|
||||
test('PATCH /api/v1/applications/{uuid} rejects invalid exposed ports', function (string $portsExposes) {
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'ports_exposes' => $portsExposes,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('ports_exposes');
|
||||
})->with([
|
||||
'not numeric' => '80,abc',
|
||||
'zero' => '0',
|
||||
'above TCP range' => '65536',
|
||||
]);
|
||||
|
||||
test('GET /api/v1/applications/{uuid} includes advanced settings', function () {
|
||||
$this->application->settings->update(advancedApplicationSettingsPayload());
|
||||
$this->application->update([
|
||||
|
|
|
|||
|
|
@ -77,6 +77,47 @@ function configurationChangedDeployment(Application $application): ApplicationDe
|
|||
->and($change['impact'])->toBe('redeploy');
|
||||
});
|
||||
|
||||
it('reports domain port-only changes as requiring a redeploy', function () {
|
||||
$application = configurationChangedTestApplication([
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'domain_port_overrides' => ['https://app.example.com' => 3000],
|
||||
]);
|
||||
$deployment = configurationChangedDeployment($application);
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
$application->update([
|
||||
'domain_port_overrides' => ['https://app.example.com' => 8080],
|
||||
]);
|
||||
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
$change = collect($diff->changes())->firstWhere('key', 'domains.domain_port_overrides');
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($change)->not->toBeNull()
|
||||
->and($change['impact'])->toBe('redeploy');
|
||||
});
|
||||
|
||||
it('keeps application deployment snapshots stable when port overrides are reordered', function () {
|
||||
$application = configurationChangedTestApplication([
|
||||
'fqdn' => 'https://one.example.com,https://two.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://one.example.com' => 3000,
|
||||
'https://two.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
$deployment = configurationChangedDeployment($application);
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
$application->update([
|
||||
'domain_port_overrides' => [
|
||||
'https://two.example.com' => 8080,
|
||||
'https://one.example.com' => 3000,
|
||||
],
|
||||
]);
|
||||
|
||||
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not flag applications whose older snapshot omitted noindex domains', function () {
|
||||
$application = configurationChangedTestApplication([
|
||||
'fqdn' => 'https://app.example.com',
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
|
|
@ -744,9 +745,14 @@
|
|||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect(explode(',', (string) $this->application->fresh()->fqdn))->toBe([
|
||||
'https://www.example.com:3000',
|
||||
'https://example.com:3000',
|
||||
$application = $this->application->fresh();
|
||||
|
||||
expect(explode(',', (string) $application->fqdn))->toBe([
|
||||
'https://www.example.com',
|
||||
'https://example.com',
|
||||
])->and($application->domain_port_overrides)->toBe([
|
||||
'https://www.example.com' => 3000,
|
||||
'https://example.com' => 3000,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -821,7 +827,8 @@
|
|||
->assertSet('editingDomain', 'https://old.example.com')
|
||||
->assertSee('Direction')
|
||||
->assertSee('Search engine indexing')
|
||||
->set('editingDomain', 'https://new.example.com')
|
||||
->set('editingDomainParts.scheme', 'https')
|
||||
->set('editingDomainParts.host', 'new.example.com')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('showEditDomainModal', false)
|
||||
|
|
@ -845,7 +852,8 @@
|
|||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->set('editingDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid')
|
||||
->set('editingDomainParts.scheme', 'https')
|
||||
->set('editingDomainParts.host', 'this-domain-should-not-resolve-for-coolify-tests.invalid')
|
||||
->call('updateDomain')
|
||||
->assertSet('editDomainDnsFailed', true)
|
||||
->assertSet('showEditDomainModal', true)
|
||||
|
|
@ -1693,7 +1701,10 @@
|
|||
->assertSet('pendingAction', null)
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->fresh()->fqdn)->toBe('https://shared.example.com');
|
||||
expect(explode(',', (string) $this->application->fresh()->fqdn))->toBe([
|
||||
'https://shared.example.com',
|
||||
'https://www.shared.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('saves after confirming a domain conflict on edit', function () {
|
||||
|
|
@ -1711,7 +1722,8 @@
|
|||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->set('editingDomain', 'https://taken.example.com')
|
||||
->set('editingDomainParts.scheme', 'https')
|
||||
->set('editingDomainParts.host', 'taken.example.com')
|
||||
->call('updateDomain')
|
||||
->assertSet('showDomainConflictModal', true)
|
||||
->assertSet('pendingAction', 'update')
|
||||
|
|
@ -1966,6 +1978,72 @@
|
|||
->and($webDomains)->toContain('https://www.web.example.com');
|
||||
});
|
||||
|
||||
it('saves domain port overrides separately from the public FQDN', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://one.example.com:3000,https://two.example.com:8080',
|
||||
]);
|
||||
|
||||
$this->application->refresh();
|
||||
|
||||
expect($this->application->fqdn)
|
||||
->toBe('https://one.example.com,https://two.example.com')
|
||||
->and($this->application->domain_port_overrides)
|
||||
->toBe([
|
||||
'https://one.example.com' => 3000,
|
||||
'https://two.example.com' => 8080,
|
||||
]);
|
||||
});
|
||||
|
||||
it('retains an existing domain port override when saving a portless domain', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://one.example.com:3000',
|
||||
]);
|
||||
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://one.example.com',
|
||||
]);
|
||||
|
||||
expect($this->application->fresh()->fqdn)
|
||||
->toBe('https://one.example.com')
|
||||
->and($this->application->fresh()->domain_port_overrides)
|
||||
->toBe(['https://one.example.com' => 3000]);
|
||||
});
|
||||
|
||||
it('prunes a domain port override when that domain is removed', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://one.example.com:3000,https://two.example.com:8080',
|
||||
]);
|
||||
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://two.example.com',
|
||||
]);
|
||||
|
||||
expect($this->application->fresh()->fqdn)
|
||||
->toBe('https://two.example.com')
|
||||
->and($this->application->fresh()->domain_port_overrides)
|
||||
->toBe(['https://two.example.com' => 8080]);
|
||||
});
|
||||
|
||||
it('keeps a legacy port-bearing application domain after refresh and reparse', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://legacy.example.com',
|
||||
]);
|
||||
|
||||
DB::table('applications')->where('id', $this->application->id)->update([
|
||||
'fqdn' => 'https://legacy.example.com:9090',
|
||||
]);
|
||||
|
||||
$application = Application::find($this->application->id);
|
||||
$application->refresh();
|
||||
|
||||
expect($application->fqdn)->toBe('https://legacy.example.com:9090');
|
||||
|
||||
applicationParser($application);
|
||||
$application->update(['description' => 'unrelated reparse']);
|
||||
|
||||
expect($application->fresh()->fqdn)->toBe('https://legacy.example.com:9090');
|
||||
});
|
||||
|
||||
it('updates search engine indexing from the domains view', function () {
|
||||
$this->application->update(['fqdn' => 'https://app.example.com,https://staging.example.com']);
|
||||
|
||||
|
|
@ -1987,3 +2065,498 @@
|
|||
expect($this->application->refresh()->noindexDomains()->all())
|
||||
->toBe(['https://staging.example.com']);
|
||||
});
|
||||
|
||||
it('keeps noindex domains when normalizing a custom domain port', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://staging.example.com:8080',
|
||||
'noindex_domains' => ['https://staging.example.com:8080'],
|
||||
]);
|
||||
|
||||
expect($this->application->refresh())
|
||||
->fqdn->toBe('https://staging.example.com')
|
||||
->noindex_domains->toBe(['https://staging.example.com'])
|
||||
->and($this->application->domain_port_overrides)
|
||||
->toBe(['https://staging.example.com' => 8080]);
|
||||
});
|
||||
|
||||
it('saves a port override from the segmented add-domain form', function () {
|
||||
$this->application->update(['ports_exposes' => '3000,8080']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomainParts.host', 'example.com')
|
||||
->set('newDomainParts.port', '8080')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success')
|
||||
->assertSee('Internal port 8080')
|
||||
->assertDontSee('https://example.com:8080');
|
||||
|
||||
$this->application->refresh();
|
||||
|
||||
expect(explode(',', (string) $this->application->fqdn))
|
||||
->toContain('https://example.com')
|
||||
->not->toContain('https://example.com:8080')
|
||||
->and($this->application->domain_port_overrides['https://example.com'] ?? null)
|
||||
->toBe(8080);
|
||||
});
|
||||
|
||||
it('rejects adding a domain whose portless URL is already configured', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://example.com:3000',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomainParts.host', 'example.com')
|
||||
->set('newDomainParts.port', '8080')
|
||||
->call('addDomain')
|
||||
->assertHasErrors('newDomain');
|
||||
|
||||
expect($this->application->fresh()->fqdn)->toBe('https://example.com')
|
||||
->and($this->application->fresh()->domain_port_overrides)
|
||||
->toBe(['https://example.com' => 3000]);
|
||||
});
|
||||
|
||||
it('rejects renaming a domain to a port variant of another configured domain', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://first.example.com:3000,https://second.example.com:4000',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 1)
|
||||
->set('editingDomainParts.host', 'first.example.com')
|
||||
->set('editingDomainParts.port', '8080')
|
||||
->call('updateDomain')
|
||||
->assertHasErrors('editingDomain');
|
||||
|
||||
expect($this->application->fresh()->fqdn)
|
||||
->toBe('https://first.example.com,https://second.example.com')
|
||||
->and($this->application->fresh()->domain_port_overrides)
|
||||
->toBe([
|
||||
'https://first.example.com' => 3000,
|
||||
'https://second.example.com' => 4000,
|
||||
]);
|
||||
});
|
||||
|
||||
it('composes segmented add-domain fields into a port override when the changed flag is false', function () {
|
||||
$this->application->update(['ports_exposes' => '3000,8080']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomainParts.host', 'example.com')
|
||||
->set('newDomainParts.port', '8080')
|
||||
->set('newDomainPartsChanged', false)
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->fresh()->fqdn)
|
||||
->toContain('https://example.com')
|
||||
->not->toContain(':8080')
|
||||
->and($this->application->fresh()->domain_port_overrides)
|
||||
->toHaveKey('https://example.com', 8080);
|
||||
});
|
||||
|
||||
it('retains different port overrides for two application domains', function () {
|
||||
$this->application->update(['ports_exposes' => '3000,8080']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomainParts.host', 'one.example.com')
|
||||
->set('newDomainParts.port', '3000')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->set('newDomainParts.host', 'two.example.com')
|
||||
->set('newDomainParts.port', '8080')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->application->refresh();
|
||||
|
||||
expect($this->application->domain_port_overrides['https://one.example.com'] ?? null)->toBe(3000)
|
||||
->and($this->application->domain_port_overrides['https://two.example.com'] ?? null)->toBe(8080)
|
||||
->and(explode(',', (string) $this->application->fqdn))
|
||||
->toContain('https://one.example.com')
|
||||
->toContain('https://two.example.com')
|
||||
->not->toContain('https://one.example.com:3000')
|
||||
->not->toContain('https://two.example.com:8080');
|
||||
});
|
||||
|
||||
it('reopens edit with the saved domain port override', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => '3000,8080',
|
||||
'fqdn' => 'https://example.com:8080',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSet('domainRows.0.url', 'https://example.com')
|
||||
->assertSet('domainRows.0.internal_port', 8080)
|
||||
->assertSet('domainRows.0.has_port_override', true)
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '8080')
|
||||
->assertSet('editingDomainParts.host', 'example.com');
|
||||
});
|
||||
|
||||
it('does not prefill the default internal port when editing a domain without a port override', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => '3000,8080',
|
||||
'fqdn' => 'https://example.com',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSet('domainRows.0.internal_port', 3000)
|
||||
->assertSet('domainRows.0.has_port_override', false)
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '');
|
||||
});
|
||||
|
||||
it('clears a domain port override and shows the default internal port', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => '3000,8080',
|
||||
'fqdn' => 'https://one.example.com:8080,https://two.example.com:9090',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSee('Internal port 8080')
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '8080')
|
||||
->set('editingDomainParts.port', '')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertSee('Internal port 3000')
|
||||
->assertSee('Internal port 9090')
|
||||
->assertDontSee('Internal port 8080');
|
||||
|
||||
$this->application->refresh();
|
||||
|
||||
expect($this->application->fqdn)
|
||||
->toContain('https://one.example.com')
|
||||
->and($this->application->domain_port_overrides)
|
||||
->not->toHaveKey('https://one.example.com')
|
||||
->toHaveKey('https://two.example.com', 9090);
|
||||
});
|
||||
|
||||
it('prunes a domain port override when removing the domain from the ui', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => '3000,8080',
|
||||
'fqdn' => 'https://one.example.com:8080,https://two.example.com:3000',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('removeDomain', 0)
|
||||
->assertDispatched('success');
|
||||
|
||||
$this->application->refresh();
|
||||
|
||||
expect($this->application->fqdn)->toBe('https://two.example.com')
|
||||
->and($this->application->domain_port_overrides)
|
||||
->toBe(['https://two.example.com' => 3000]);
|
||||
});
|
||||
|
||||
it('renders a portless domain link with an internal port badge for overrides', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => '3000,8080',
|
||||
'fqdn' => 'https://example.com:8080',
|
||||
]);
|
||||
|
||||
$html = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSee('Internal port 8080')
|
||||
->assertSee('https://example.com')
|
||||
->assertDontSee('https://example.com:8080')
|
||||
->html();
|
||||
|
||||
expect($html)
|
||||
->toContain('href="'.getFqdnWithoutPort('https://example.com').'"')
|
||||
->not->toContain('href="https://example.com:8080"')
|
||||
->toContain('Custom internal port for this domain')
|
||||
->not->toContain('Inherited from Ports Exposes');
|
||||
});
|
||||
|
||||
it('shows an error badge when a domain has no internal port and ports exposes is empty', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => null,
|
||||
'fqdn' => 'https://example.com',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSet('domainRows.0.internal_port', null)
|
||||
->assertSee('No internal port')
|
||||
->assertDontSee('Internal port ')
|
||||
->assertSee('table-badge-danger', false);
|
||||
});
|
||||
|
||||
it('keeps the internal port badge when a domain override exists without ports exposes', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => null,
|
||||
'fqdn' => 'https://example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSee('Internal port 8080')
|
||||
->assertDontSee('No internal port');
|
||||
});
|
||||
|
||||
it('distinguishes an inherited internal port from a domain port override', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => '3000,8080',
|
||||
'fqdn' => 'https://example.com',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSee('Internal port 3000')
|
||||
->assertSee('Inherited from Ports Exposes', false)
|
||||
->assertDontSee('Custom internal port for this domain', false);
|
||||
});
|
||||
|
||||
it('keeps a legacy port-bearing url port in the edit field as an internal port override', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => '3000,8080',
|
||||
'fqdn' => 'https://legacy.example.com',
|
||||
]);
|
||||
|
||||
DB::table('applications')->where('id', $this->application->id)->update([
|
||||
'fqdn' => 'https://legacy.example.com:9090',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSet('domainRows.0.url', 'https://legacy.example.com:9090')
|
||||
->assertSet('domainRows.0.internal_port', 9090)
|
||||
->assertSet('domainRows.0.has_port_override', true)
|
||||
->assertSee('Internal port 9090')
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '9090');
|
||||
});
|
||||
|
||||
it('stores compose domain port overrides without wiping other services', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'fqdn' => null,
|
||||
'ports_exposes' => '3000,8080',
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: node:alpine\n",
|
||||
'docker_compose_domains' => json_encode([
|
||||
'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'],
|
||||
]),
|
||||
'domain_port_overrides' => [
|
||||
'https://api.example.com' => 4000,
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomainService', 'web')
|
||||
->set('newDomainParts.host', 'web.example.com')
|
||||
->set('newDomainParts.port', '8080')
|
||||
->set('newDomainPartsChanged', false)
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertSee('Internal port 8080');
|
||||
|
||||
$this->application->refresh();
|
||||
$domains = json_decode($this->application->docker_compose_domains, true);
|
||||
|
||||
expect(data_get($domains, 'web.domain'))
|
||||
->toContain('https://web.example.com')
|
||||
->not->toContain(':8080')
|
||||
->and($this->application->fqdn)->toBeNull()
|
||||
->and($this->application->domain_port_overrides)
|
||||
->toHaveKey('https://web.example.com', 8080)
|
||||
->toHaveKey('https://api.example.com', 4000);
|
||||
});
|
||||
|
||||
it('prunes a compose domain port override when that domain is removed', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'fqdn' => null,
|
||||
'ports_exposes' => '3000,8080',
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: node:alpine\n",
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => ['domain' => 'https://web.example.com', 'redirect' => 'both'],
|
||||
'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'],
|
||||
]),
|
||||
'domain_port_overrides' => [
|
||||
'https://web.example.com' => 8080,
|
||||
'https://api.example.com' => 4000,
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('removeDomain', 0)
|
||||
->assertDispatched('success');
|
||||
|
||||
$this->application->refresh();
|
||||
|
||||
expect($this->application->domain_port_overrides)
|
||||
->not->toHaveKey('https://web.example.com')
|
||||
->toHaveKey('https://api.example.com', 4000)
|
||||
->and($this->application->fqdn)->toBeNull();
|
||||
});
|
||||
|
||||
function applicationDomainPortOverrideApiToken(User $user, Team $team): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'application-domain-port-override-api',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
auth()->logout();
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
it('application domain port override API update containing a port persists a portless FQDN and override', function () {
|
||||
$bearer = applicationDomainPortOverrideApiToken($this->user, $this->team);
|
||||
|
||||
$this->withToken($bearer)
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'domains' => 'https://example.com:8080',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$application = $this->application->fresh();
|
||||
|
||||
expect($application->fqdn)->toBe('https://example.com')
|
||||
->and($application->domain_port_overrides)
|
||||
->toBe(['https://example.com' => 8080]);
|
||||
});
|
||||
|
||||
it('application domain port override API update omitting ports preserves overrides for unchanged domains', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://one.example.com:3000,https://two.example.com:8080',
|
||||
]);
|
||||
|
||||
$bearer = applicationDomainPortOverrideApiToken($this->user, $this->team);
|
||||
|
||||
$this->withToken($bearer)
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'domains' => 'https://one.example.com,https://two.example.com',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$application = $this->application->fresh();
|
||||
|
||||
expect($application->fqdn)->toBe('https://one.example.com,https://two.example.com')
|
||||
->and($application->domain_port_overrides)
|
||||
->toBe([
|
||||
'https://one.example.com' => 3000,
|
||||
'https://two.example.com' => 8080,
|
||||
]);
|
||||
});
|
||||
|
||||
it('application domain port override API domain removal prunes the override', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://one.example.com:3000,https://two.example.com:8080',
|
||||
]);
|
||||
|
||||
$bearer = applicationDomainPortOverrideApiToken($this->user, $this->team);
|
||||
|
||||
$this->withToken($bearer)
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'domains' => 'https://two.example.com',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$application = $this->application->fresh();
|
||||
|
||||
expect($application->fqdn)->toBe('https://two.example.com')
|
||||
->and($application->domain_port_overrides)
|
||||
->toBe(['https://two.example.com' => 8080]);
|
||||
});
|
||||
|
||||
it('application domain port override API update of an unrelated field does not rewrite a legacy FQDN', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://legacy.example.com',
|
||||
'description' => 'before',
|
||||
]);
|
||||
|
||||
DB::table('applications')->where('id', $this->application->id)->update([
|
||||
'fqdn' => 'https://legacy.example.com:9090',
|
||||
]);
|
||||
|
||||
$bearer = applicationDomainPortOverrideApiToken($this->user, $this->team);
|
||||
|
||||
$this->withToken($bearer)
|
||||
->getJson("/api/v1/applications/{$this->application->uuid}")
|
||||
->assertOk();
|
||||
|
||||
expect($this->application->fresh()->fqdn)->toBe('https://legacy.example.com:9090');
|
||||
|
||||
$this->withToken($bearer)
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'description' => 'unrelated',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$application = $this->application->fresh();
|
||||
|
||||
expect($application->fqdn)->toBe('https://legacy.example.com:9090')
|
||||
->and($application->description)->toBe('unrelated')
|
||||
->and($application->domain_port_overrides)->toBeNull();
|
||||
});
|
||||
|
||||
it('treats ports exposes and existing domain ports as available internal ports', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => '3000,8080',
|
||||
'fqdn' => 'https://one.example.com:9090',
|
||||
]);
|
||||
|
||||
$application = $this->application->fresh();
|
||||
|
||||
expect($application->availableInternalPorts())->toBe([3000, 8080, 9090])
|
||||
->and($application->portRequiresConfirmation(3000))->toBeFalse()
|
||||
->and($application->portRequiresConfirmation(8080))->toBeFalse()
|
||||
->and($application->portRequiresConfirmation(9090))->toBeFalse()
|
||||
->and($application->portRequiresConfirmation(5555))->toBeTrue()
|
||||
->and($application->portRequiresConfirmation(null))->toBeFalse();
|
||||
});
|
||||
|
||||
it('shows a port warning when an application domain uses a port that is not exposed or already used', function () {
|
||||
$this->application->update(['ports_exposes' => '3000,8080']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomainParts.host', 'example.com')
|
||||
->set('newDomainParts.port', '5555')
|
||||
->call('addDomain')
|
||||
->assertSet('showPortWarningModal', true)
|
||||
->assertSet('unrecognizedPort', 5555)
|
||||
->assertSee('Use a different port?');
|
||||
|
||||
expect($this->application->fresh()->fqdn)->toBeNull();
|
||||
});
|
||||
|
||||
it('saves an unrecognized application domain port after confirming the warning', function () {
|
||||
$this->application->update(['ports_exposes' => '3000,8080']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomainParts.host', 'example.com')
|
||||
->set('newDomainParts.port', '5555')
|
||||
->call('addDomain')
|
||||
->assertSet('showPortWarningModal', true)
|
||||
->call('confirmUseUnknownPort')
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
|
||||
$application = $this->application->fresh();
|
||||
|
||||
expect(explode(',', (string) $application->fqdn))
|
||||
->toContain('https://example.com')
|
||||
->and($application->domain_port_overrides['https://example.com'] ?? null)->toBe(5555);
|
||||
});
|
||||
|
||||
it('does not warn when editing an application domain to a port already used by another domain', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => '3000',
|
||||
'fqdn' => 'https://one.example.com:9090,https://two.example.com',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 1)
|
||||
->set('editingDomainParts.port', '9090')
|
||||
->call('updateDomain')
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -120,3 +120,32 @@ public function submit($showToaster = true): void
|
|||
expect($application->refresh()->fqdn)
|
||||
->toBe('https://example.com,https://www.example.com');
|
||||
});
|
||||
|
||||
test('networking section hints that internal ports can be set per domain', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'nixpacks',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'ports_exposes' => '3000,3001',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
$domainsUrl = route('project.application.domains', [
|
||||
'project_uuid' => $application->environment->project->uuid,
|
||||
'environment_uuid' => $application->environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSeeInOrder([
|
||||
'Ports exposes',
|
||||
'You can also set an internal port per domain on',
|
||||
'Port mappings',
|
||||
])
|
||||
->assertSee($domainsUrl, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,10 +10,20 @@
|
|||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use phpseclib3\Crypt\EC;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function disableExactProxyLabels(Application $application): Application
|
||||
{
|
||||
$settings = $application->destination->server->settings;
|
||||
$settings->generate_exact_labels = false;
|
||||
$settings->save();
|
||||
|
||||
return $application;
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
|
|
@ -454,3 +464,138 @@
|
|||
->toContain('traefik.docker.network=custom-network')
|
||||
->not->toContain("traefik.docker.network={$application->uuid}");
|
||||
});
|
||||
|
||||
test('generateLabelsApplication routes portless domains to saved internal port overrides for Traefik and Caddy', function () {
|
||||
$application = disableExactProxyLabels(Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'ports_exposes' => '80',
|
||||
'fqdn' => 'https://one.example.com,https://two.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://one.example.com' => 3000,
|
||||
'https://two.example.com' => 8080,
|
||||
],
|
||||
'redirect' => 'both',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
]));
|
||||
|
||||
$labels = collect(generateLabelsApplication($application));
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.routers.https-0-'.$application->uuid.'.rule=Host(`one.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-0-'.$application->uuid.'.loadbalancer.server.port=3000')
|
||||
->toContain('traefik.http.routers.https-1-'.$application->uuid.'.rule=Host(`two.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-1-'.$application->uuid.'.loadbalancer.server.port=8080')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}')
|
||||
->toContain('caddy_1.handle_path.1_reverse_proxy={{upstreams 8080}}')
|
||||
->not->toContain('Host(`one.example.com:3000`)')
|
||||
->not->toContain('Host(`two.example.com:8080`)');
|
||||
});
|
||||
|
||||
test('generateLabelsApplication uses the first ports_exposes value when a portless domain has no override', function () {
|
||||
$application = disableExactProxyLabels(Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'ports_exposes' => '4000,5000',
|
||||
'fqdn' => 'https://plain.example.com',
|
||||
'domain_port_overrides' => null,
|
||||
'redirect' => 'both',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
]));
|
||||
|
||||
$labels = collect(generateLabelsApplication($application));
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.services.https-0-'.$application->uuid.'.loadbalancer.server.port=4000')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 4000}}');
|
||||
});
|
||||
|
||||
test('generateLabelsApplication keeps routing a legacy port-bearing FQDN without an override map', function () {
|
||||
$application = disableExactProxyLabels(Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'ports_exposes' => '80',
|
||||
'fqdn' => 'https://legacy.example.com',
|
||||
'redirect' => 'both',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
]));
|
||||
|
||||
DB::table('applications')->where('id', $application->id)->update([
|
||||
'fqdn' => 'https://legacy.example.com:9090',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
$labels = collect(generateLabelsApplication($application->fresh()));
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.routers.https-0-'.$application->uuid.'.rule=Host(`legacy.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-0-'.$application->uuid.'.loadbalancer.server.port=9090')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 9090}}');
|
||||
});
|
||||
|
||||
test('applicationParser compose labels receive the application domain port override map', function () {
|
||||
$application = disableExactProxyLabels(Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
YAML,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://frontend.example.com'],
|
||||
]),
|
||||
]));
|
||||
|
||||
$application->update([
|
||||
'domain_port_overrides' => [
|
||||
'https://frontend.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
$parsedCompose = applicationParser($application->fresh());
|
||||
$labels = collect(data_get($parsedCompose, 'services.frontend.labels'));
|
||||
|
||||
expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=8080')))
|
||||
->toBeTrue()
|
||||
->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 8080}}')))
|
||||
->toBeTrue()
|
||||
->and($labels->contains(fn (string $label): bool => str_contains($label, 'Host(`frontend.example.com`)')))
|
||||
->toBeTrue();
|
||||
});
|
||||
|
||||
test('applicationParser compose labels use the first ports_exposes value when a portless domain has no override', function () {
|
||||
$application = disableExactProxyLabels(Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'ports_exposes' => '3000,8080',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
YAML,
|
||||
'fqdn' => null,
|
||||
'domain_port_overrides' => null,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://frontend.example.com'],
|
||||
]),
|
||||
]));
|
||||
|
||||
$parsedCompose = applicationParser($application->fresh());
|
||||
$labels = collect(data_get($parsedCompose, 'services.frontend.labels'));
|
||||
|
||||
expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=3000')))
|
||||
->toBeTrue()
|
||||
->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 3000}}')))
|
||||
->toBeTrue()
|
||||
->and($labels->contains(fn (string $label): bool => str_contains($label, 'Host(`frontend.example.com`)')))
|
||||
->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
|
|
@ -19,6 +20,7 @@
|
|||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
|
|
@ -130,3 +132,273 @@ function createPreview(Application $application, int $pullRequestId): Applicatio
|
|||
$response->assertForbidden();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
|
||||
test('stores preview domain ports separately from portless public domains', function () {
|
||||
$preview = createPreview($this->application, 42);
|
||||
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/42", [
|
||||
'domains' => 'https://one.example.com:3000,https://two.example.com:8080',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('domains', 'https://one.example.com,https://two.example.com');
|
||||
|
||||
expect($response->json('domain_port_overrides'))->toBe([
|
||||
'https://one.example.com' => 3000,
|
||||
'https://two.example.com' => 8080,
|
||||
]);
|
||||
|
||||
expect($preview->fresh()->fqdn)->toBe('https://one.example.com,https://two.example.com')
|
||||
->and($preview->fresh()->domain_port_overrides)->toBe([
|
||||
'https://one.example.com' => 3000,
|
||||
'https://two.example.com' => 8080,
|
||||
]);
|
||||
});
|
||||
|
||||
test('clears an existing preview domain override when the submitted domain is portless', function () {
|
||||
$preview = createPreview($this->application, 43);
|
||||
$preview->update(['fqdn' => 'https://preview.example.com:8080']);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/43", [
|
||||
'domains' => 'https://preview.example.com',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('domain_port_overrides', null);
|
||||
|
||||
expect($preview->fresh()->fqdn)->toBe('https://preview.example.com')
|
||||
->and($preview->fresh()->domain_port_overrides)->toBeNull();
|
||||
});
|
||||
|
||||
test('rejects invalid preview domains', function () {
|
||||
createPreview($this->application, 44);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/44", [
|
||||
'domains' => 'not-a-domain',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('domains');
|
||||
});
|
||||
|
||||
test('rejects preview domain ports outside the valid TCP range', function (string $domain) {
|
||||
createPreview($this->application, 59);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/59", [
|
||||
'domains' => $domain,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('domains');
|
||||
})->with([
|
||||
'zero' => 'https://preview.example.com:0',
|
||||
'above maximum' => 'https://preview.example.com:65536',
|
||||
]);
|
||||
|
||||
test('returns 403 when token lacks write ability', function () {
|
||||
$readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']);
|
||||
createPreview($this->application, 45);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($readOnlyToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/45", [
|
||||
'domains' => 'https://preview.example.com:3000',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('rejects a non-integer pull request id', function () {
|
||||
createPreview($this->application, 1);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/1.9", [
|
||||
'domains' => 'https://preview.example.com:3000',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJson(['message' => 'Invalid pull_request_id.']);
|
||||
});
|
||||
|
||||
test('detects conflicts after removing the submitted internal port', function () {
|
||||
$otherApplication = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'fqdn' => 'https://taken.example.com',
|
||||
]);
|
||||
createPreview($this->application, 46);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/46", [
|
||||
'domains' => 'https://taken.example.com:3000',
|
||||
])
|
||||
->assertConflict()
|
||||
->assertJsonPath('conflicts.0.resource_uuid', $otherApplication->uuid);
|
||||
});
|
||||
|
||||
test('detects conflicts with another preview domain', function () {
|
||||
createPreview($this->application, 47)->update(['fqdn' => 'https://taken-preview.example.com:3000']);
|
||||
createPreview($this->application, 48);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/48", [
|
||||
'domains' => 'https://taken-preview.example.com:8080',
|
||||
])
|
||||
->assertConflict();
|
||||
});
|
||||
|
||||
test('filters preview conflict candidates in the database', function () {
|
||||
createPreview($this->application, 56)->update(['fqdn' => 'https://taken-preview.example.com']);
|
||||
createPreview($this->application, 57)->update(['fqdn' => 'https://current-preview.example.com']);
|
||||
createPreview($this->application, 58)->update(['fqdn' => 'https://unrelated.example.com']);
|
||||
|
||||
$queries = collect();
|
||||
DB::listen(function ($query) use ($queries): void {
|
||||
if (str_contains($query->sql, 'from "application_previews"')) {
|
||||
$queries->push($query->sql);
|
||||
}
|
||||
});
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/57", [
|
||||
'domains' => 'https://taken-preview.example.com',
|
||||
])
|
||||
->assertConflict();
|
||||
|
||||
expect($queries->first(fn (string $sql): bool => str_contains($sql, '"application_id" in (select')
|
||||
&& str_contains($sql, '"fqdn" is not null')
|
||||
&& str_contains($sql, '"fqdn" like ?')))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('updates Docker Compose preview domains with per-domain ports', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
|
||||
]);
|
||||
$preview = createPreview($this->application, 49);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/49", [
|
||||
'docker_compose_domains' => [
|
||||
['name' => 'web', 'domain' => 'https://web-preview.example.com:8080'],
|
||||
['name' => 'api', 'domain' => 'https://api-preview.example.com:3000'],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('docker_compose_domains.0.name', 'web')
|
||||
->assertJsonPath('docker_compose_domains.0.domain', 'https://web-preview.example.com')
|
||||
->assertJsonPath('docker_compose_domains.1.name', 'api')
|
||||
->assertJsonPath('docker_compose_domains.1.domain', 'https://api-preview.example.com');
|
||||
|
||||
$preview->refresh();
|
||||
|
||||
expect(json_decode($preview->docker_compose_domains, true))->toBe([
|
||||
'web' => ['domain' => 'https://web-preview.example.com'],
|
||||
'api' => ['domain' => 'https://api-preview.example.com'],
|
||||
])->and($preview->fqdn)->toBe('https://web-preview.example.com,https://api-preview.example.com')
|
||||
->and($preview->domain_port_overrides)->toBe([
|
||||
'https://web-preview.example.com' => 8080,
|
||||
'https://api-preview.example.com' => 3000,
|
||||
]);
|
||||
});
|
||||
|
||||
test('clears Docker Compose preview port overrides with portless domains', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
]);
|
||||
$preview = createPreview($this->application, 50);
|
||||
$preview->update([
|
||||
'fqdn' => 'https://web-preview.example.com:8080',
|
||||
'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://web-preview.example.com']]),
|
||||
]);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/50", [
|
||||
'docker_compose_domains' => [
|
||||
['name' => 'web', 'domain' => 'https://web-preview.example.com'],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('domain_port_overrides', null);
|
||||
|
||||
expect($preview->fresh()->domain_port_overrides)->toBeNull();
|
||||
});
|
||||
|
||||
test('rejects unknown Docker Compose preview services', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
]);
|
||||
createPreview($this->application, 51);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/51", [
|
||||
'docker_compose_domains' => [
|
||||
['name' => 'unknown', 'domain' => 'https://unknown.example.com:8080'],
|
||||
],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('docker_compose_domains');
|
||||
});
|
||||
|
||||
test('rejects the same Docker Compose preview domain on different internal ports', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
|
||||
]);
|
||||
createPreview($this->application, 52);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/52", [
|
||||
'docker_compose_domains' => [
|
||||
['name' => 'web', 'domain' => 'https://duplicate.example.com:8080'],
|
||||
['name' => 'api', 'domain' => 'https://duplicate.example.com:3000'],
|
||||
],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('docker_compose_domains');
|
||||
});
|
||||
|
||||
test('rejects missing Compose services without changing the preview', function () {
|
||||
$this->application->update(['build_pack' => 'dockercompose', 'docker_compose_raw' => '']);
|
||||
$preview = createPreview($this->application, 53);
|
||||
$originalFqdn = $preview->fresh()->fqdn;
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/53", [
|
||||
'docker_compose_domains' => [],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('docker_compose_domains');
|
||||
|
||||
expect($preview->fresh()->fqdn)->toBe($originalFqdn);
|
||||
});
|
||||
|
||||
test('rejects the domain field for Compose previews', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
]);
|
||||
createPreview($this->application, 54);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/54", [
|
||||
'domains' => 'https://ignored.example.com',
|
||||
'docker_compose_domains' => [],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('domains');
|
||||
});
|
||||
|
||||
test('rejects Docker Compose domains for non-Compose previews', function () {
|
||||
createPreview($this->application, 55);
|
||||
|
||||
$this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/55", [
|
||||
'domains' => 'https://preview.example.com',
|
||||
'docker_compose_domains' => [],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('docker_compose_domains');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -83,6 +83,27 @@
|
|||
->toMatch('/<x-forms\.button(?=[^>]*wire:click\.stop="backupNow\(\'storage\',[^"]+")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/');
|
||||
});
|
||||
|
||||
it('declares update authorization on application port controls', function () {
|
||||
$domainsView = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
$previewDomainsView = file_get_contents(resource_path('views/livewire/project/application/preview-domains.blade.php'));
|
||||
$generalView = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
|
||||
expect($domainsView)
|
||||
->toMatch('/<x-forms\.button(?=[^>]*canGate="update")(?=[^>]*:canResource="\$application")[^>]*>\s*Cancel/s')
|
||||
->toMatch('/<x-forms\.button(?=[^>]*wire:click="confirmUseUnknownPort")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$application")[^>]*>/s');
|
||||
|
||||
expect($previewDomainsView)
|
||||
->toMatch('/<x-forms\.button[^\n]*canGate="update" :canResource="\$preview->application"[\s\S]{0,150}?Cancel/')
|
||||
->toMatch('/<x-forms\.button[^\n]*wire:click="confirmUseUnknownPort" canGate="update"\s+:canResource="\$preview->application"/');
|
||||
|
||||
$portsExposesControls = str($generalView)
|
||||
->after("@if (\$isStatic || \$buildPack === 'static')")
|
||||
->before('<p class="mt-1.5 text-xs');
|
||||
|
||||
expect($portsExposesControls->substrCount('id="portsExposes"'))->toBe(3)
|
||||
->and($portsExposesControls->substrCount('canGate="update" :canResource="$application"'))->toBe(3);
|
||||
});
|
||||
|
||||
it('keeps mutable Livewire components behind authorization checks', function (string $path, array $requiredNeedles) {
|
||||
$source = file_get_contents(base_path($path));
|
||||
|
||||
|
|
|
|||
674
tests/Feature/PreviewDomainPortOverridesTest.php
Normal file
674
tests/Feature/PreviewDomainPortOverridesTest.php
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\PreviewDomains;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
[
|
||||
'id' => 0,
|
||||
'is_dns_validation_enabled' => false,
|
||||
]
|
||||
));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$keyId = DB::table('private_keys')->insertGetId([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-key',
|
||||
'team_id' => $this->team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $keyId,
|
||||
'ip' => '203.0.113.10',
|
||||
]);
|
||||
|
||||
$this->server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'generate_exact_labels' => false,
|
||||
]);
|
||||
|
||||
StandaloneDocker::withoutEvents(function () {
|
||||
$this->destination = StandaloneDocker::firstOrCreate(
|
||||
['server_id' => $this->server->id, 'network' => 'coolify'],
|
||||
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
|
||||
);
|
||||
});
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Preview Port App',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'fqdn' => null,
|
||||
'redirect' => 'both',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000,8080',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
function createPreviewForPortTests(Application $application, int $pullRequestId, array $attributes = []): ApplicationPreview
|
||||
{
|
||||
return ApplicationPreview::create(array_merge([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'pull_request_html_url' => "https://github.com/coollabsio/coolify/pull/{$pullRequestId}",
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('saves preview domain port overrides separately from the public FQDN', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 101);
|
||||
|
||||
$preview->update([
|
||||
'fqdn' => 'https://one-pr-101.example.com:3000,https://two-pr-101.example.com:8080',
|
||||
]);
|
||||
|
||||
$preview->refresh();
|
||||
|
||||
expect($preview->fqdn)
|
||||
->toBe('https://one-pr-101.example.com,https://two-pr-101.example.com')
|
||||
->and($preview->domain_port_overrides)
|
||||
->toBe([
|
||||
'https://one-pr-101.example.com' => 3000,
|
||||
'https://two-pr-101.example.com' => 8080,
|
||||
]);
|
||||
});
|
||||
|
||||
it('retains an existing preview port override when saving a portless domain', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 102, [
|
||||
'fqdn' => 'https://one-pr-102.example.com:3000',
|
||||
]);
|
||||
|
||||
$preview->update([
|
||||
'fqdn' => 'https://one-pr-102.example.com',
|
||||
]);
|
||||
|
||||
expect($preview->fresh()->fqdn)
|
||||
->toBe('https://one-pr-102.example.com')
|
||||
->and($preview->fresh()->domain_port_overrides)
|
||||
->toBe(['https://one-pr-102.example.com' => 3000]);
|
||||
});
|
||||
|
||||
it('saves a preview port override from the add-domain form', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 103);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->set('newDomainParts.host', 'preview.example.com')
|
||||
->set('newDomainParts.port', '8080')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success')
|
||||
->assertSee('Internal port 8080')
|
||||
->assertDontSee('https://preview.example.com:8080');
|
||||
|
||||
$preview->refresh();
|
||||
|
||||
expect($preview->fqdn)
|
||||
->toBe('https://preview.example.com')
|
||||
->and($preview->domain_port_overrides['https://preview.example.com'] ?? null)
|
||||
->toBe(8080);
|
||||
});
|
||||
|
||||
it('retains different port overrides for two preview domains', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 104);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->set('newDomainParts.host', 'one-preview.example.com')
|
||||
->set('newDomainParts.port', '3000')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->set('newDomainParts.host', 'two-preview.example.com')
|
||||
->set('newDomainParts.port', '8080')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$preview->refresh();
|
||||
|
||||
expect($preview->domain_port_overrides['https://one-preview.example.com'] ?? null)->toBe(3000)
|
||||
->and($preview->domain_port_overrides['https://two-preview.example.com'] ?? null)->toBe(8080)
|
||||
->and(explode(',', (string) $preview->fqdn))
|
||||
->toContain('https://one-preview.example.com')
|
||||
->toContain('https://two-preview.example.com')
|
||||
->not->toContain('https://one-preview.example.com:3000')
|
||||
->not->toContain('https://two-preview.example.com:8080');
|
||||
});
|
||||
|
||||
it('reopens preview domain edit with the saved port override', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 105, [
|
||||
'fqdn' => 'https://preview.example.com:8080',
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->assertSet('domainRows.0.url', 'https://preview.example.com')
|
||||
->assertSet('domainRows.0.internal_port', 8080)
|
||||
->assertSet('domainRows.0.has_port_override', true)
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '8080')
|
||||
->assertSet('editingDomainParts.host', 'preview.example.com');
|
||||
});
|
||||
|
||||
it('does not prefill the default internal port when editing a preview domain without an override', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 106, [
|
||||
'fqdn' => 'https://preview.example.com',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->assertSet('domainRows.0.internal_port', 3000)
|
||||
->assertSet('domainRows.0.has_port_override', false)
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '');
|
||||
});
|
||||
|
||||
it('clears a preview domain port override and shows the default internal port', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 107, [
|
||||
'fqdn' => 'https://one-preview.example.com:8080,https://two-preview.example.com:9090',
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->assertSee('Internal port 8080')
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '8080')
|
||||
->set('editingDomainParts.port', '')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertSee('Internal port 3000')
|
||||
->assertSee('Internal port 9090')
|
||||
->assertDontSee('Internal port 8080');
|
||||
|
||||
$preview->refresh();
|
||||
|
||||
expect($preview->fqdn)
|
||||
->toContain('https://one-preview.example.com')
|
||||
->and($preview->domain_port_overrides)
|
||||
->not->toHaveKey('https://one-preview.example.com')
|
||||
->toHaveKey('https://two-preview.example.com', 9090);
|
||||
});
|
||||
|
||||
it('prunes a preview domain port override when removing the domain', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 108, [
|
||||
'fqdn' => 'https://one-preview.example.com:8080,https://two-preview.example.com:3000',
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->call('removeDomain', 0)
|
||||
->assertDispatched('success');
|
||||
|
||||
$preview->refresh();
|
||||
|
||||
expect($preview->fqdn)->toBe('https://two-preview.example.com')
|
||||
->and($preview->domain_port_overrides)
|
||||
->toBe(['https://two-preview.example.com' => 3000]);
|
||||
});
|
||||
|
||||
it('shows an error badge when a preview domain has no internal port and ports exposes is empty', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => null,
|
||||
]);
|
||||
|
||||
$preview = createPreviewForPortTests($this->application, 109, [
|
||||
'fqdn' => 'https://preview.example.com',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->assertSee('No internal port')
|
||||
->assertDontSee('Internal port');
|
||||
});
|
||||
|
||||
it('rejects adding a preview domain whose portless URL is already configured', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 110, [
|
||||
'fqdn' => 'https://preview.example.com:3000',
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->set('newDomainParts.host', 'preview.example.com')
|
||||
->set('newDomainParts.port', '8080')
|
||||
->call('addDomain')
|
||||
->assertHasErrors('newDomainParts.host');
|
||||
|
||||
expect($preview->fresh()->fqdn)->toBe('https://preview.example.com')
|
||||
->and($preview->fresh()->domain_port_overrides)
|
||||
->toBe(['https://preview.example.com' => 3000]);
|
||||
});
|
||||
|
||||
it('saves compose preview domain port overrides per service without putting the port in the public URL', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
|
||||
$preview = createPreviewForPortTests($this->application, 111);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->set('newDomainService', 'web')
|
||||
->set('newDomainParts.host', 'web-preview.example.com')
|
||||
->set('newDomainParts.port', '8080')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->set('newDomainService', 'api')
|
||||
->set('newDomainParts.host', 'api-preview.example.com')
|
||||
->set('newDomainParts.port', '3000')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$preview->refresh();
|
||||
$composeDomains = json_decode($preview->docker_compose_domains, true);
|
||||
|
||||
expect(data_get($composeDomains, 'web.domain'))->toBe('https://web-preview.example.com')
|
||||
->and(data_get($composeDomains, 'api.domain'))->toBe('https://api-preview.example.com')
|
||||
->and($preview->domain_port_overrides)
|
||||
->toBe([
|
||||
'https://web-preview.example.com' => 8080,
|
||||
'https://api-preview.example.com' => 3000,
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies the parent domain port override onto a generated preview domain', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://app.example.com:8080',
|
||||
]);
|
||||
|
||||
$preview = createPreviewForPortTests($this->application, 112);
|
||||
$preview->generate_preview_fqdn();
|
||||
$preview->refresh();
|
||||
|
||||
expect($preview->fqdn)
|
||||
->toContain('112.')
|
||||
->not->toContain(':8080')
|
||||
->and($preview->domain_port_overrides)
|
||||
->toHaveCount(1)
|
||||
->and(array_values($preview->domain_port_overrides))
|
||||
->toBe([8080]);
|
||||
});
|
||||
|
||||
it('saves generated preview domains once in the application parser', function () {
|
||||
$parser = file_get_contents(base_path('bootstrap/helpers/parsers.php'));
|
||||
$previewGeneration = Str::of($parser)
|
||||
->after('// If the domain is set, we need to generate the FQDNs for the preview')
|
||||
->before('$defaultLabels = defaultLabels');
|
||||
|
||||
expect($previewGeneration->substrCount('$preview->save();'))->toBe(1)
|
||||
->and((string) $previewGeneration)->toContain('$preview->fqdn = $fqdns->implode(\',\');');
|
||||
});
|
||||
|
||||
it('keeps every generated preview domain port override in the legacy compose parser', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'compose_parsing_version' => '2',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
YAML,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://one.example.com:3000,https://two.example.com:8080'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview = createPreviewForPortTests($this->application, 124);
|
||||
|
||||
parseDockerComposeFile($this->application->fresh(), pull_request_id: 124, preview_id: $preview->id);
|
||||
|
||||
$preview->refresh();
|
||||
|
||||
$previewDomains = explode(',', (string) $preview->fqdn);
|
||||
|
||||
expect($previewDomains)->toHaveCount(2)
|
||||
->and(collect($previewDomains)
|
||||
->filter(fn (string $domain): bool => parse_url($domain, PHP_URL_PORT) !== null))
|
||||
->toBeEmpty()
|
||||
->and($preview->domain_port_overrides)->toHaveCount(2)
|
||||
->and(array_keys($preview->domain_port_overrides))->toBe($previewDomains)
|
||||
->and(array_values($preview->domain_port_overrides))->toBe([3000, 8080]);
|
||||
});
|
||||
|
||||
it('finds the legacy compose preview by pull request when its id is unavailable', function (?int $previewId) {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'compose_parsing_version' => '2',
|
||||
'docker_compose_raw' => "services:\n frontend:\n image: nginx:alpine\n",
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://app.example.com:3000'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview = createPreviewForPortTests($this->application, 125);
|
||||
|
||||
parseDockerComposeFile($this->application->fresh(), pull_request_id: 125, preview_id: $previewId);
|
||||
|
||||
expect($preview->fresh()->fqdn)->not->toBeNull();
|
||||
})->with([
|
||||
'missing id' => null,
|
||||
'stale id' => PHP_INT_MAX,
|
||||
]);
|
||||
|
||||
it('throws a controlled exception when the legacy compose preview does not exist', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'compose_parsing_version' => '2',
|
||||
'docker_compose_raw' => "services:\n frontend:\n image: nginx:alpine\n",
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://app.example.com:3000'],
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(fn () => parseDockerComposeFile(
|
||||
$this->application->fresh(),
|
||||
pull_request_id: 126,
|
||||
preview_id: PHP_INT_MAX,
|
||||
))->toThrow(RuntimeException::class, 'Preview not found.');
|
||||
});
|
||||
|
||||
it('preserves an existing preview port override in the legacy compose parser', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'compose_parsing_version' => '2',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
YAML,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://frontend.example.com'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview = createPreviewForPortTests($this->application, 126, [
|
||||
'fqdn' => 'https://126.frontend.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://126.frontend.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
parseDockerComposeFile($this->application->fresh(), pull_request_id: 126, preview_id: $preview->id);
|
||||
|
||||
expect($preview->fresh()->domain_port_overrides)
|
||||
->toBe(['https://126.frontend.example.com' => 8080]);
|
||||
});
|
||||
|
||||
it('does not copy production domain port overrides onto preview proxy labels', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://app.example.com' => 9090,
|
||||
],
|
||||
]);
|
||||
|
||||
$preview = createPreviewForPortTests($this->application, 113, [
|
||||
'fqdn' => 'https://113.app.example.com',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
$labels = collect(generateLabelsApplication($this->application->fresh(), $preview->fresh()));
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.services.https-0-'.$this->application->uuid.'-pr-113.loadbalancer.server.port=3000')
|
||||
->not->toContain('loadbalancer.server.port=9090');
|
||||
});
|
||||
|
||||
it('routes portless preview domains to saved preview internal port overrides', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 114, [
|
||||
'fqdn' => 'https://one-pr.example.com,https://two-pr.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://one-pr.example.com' => 3000,
|
||||
'https://two-pr.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
$labels = collect(generateLabelsApplication($this->application->fresh(), $preview->fresh()));
|
||||
$uuid = $this->application->uuid.'-pr-114';
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.routers.https-0-'.$uuid.'.rule=Host(`one-pr.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-0-'.$uuid.'.loadbalancer.server.port=3000')
|
||||
->toContain('traefik.http.routers.https-1-'.$uuid.'.rule=Host(`two-pr.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-1-'.$uuid.'.loadbalancer.server.port=8080')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}')
|
||||
->toContain('caddy_1.handle_path.1_reverse_proxy={{upstreams 8080}}')
|
||||
->not->toContain('Host(`one-pr.example.com:3000`)')
|
||||
->not->toContain('Host(`two-pr.example.com:8080`)');
|
||||
});
|
||||
|
||||
it('uses the first ports_exposes value for a portless preview domain without an override', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 115, [
|
||||
'fqdn' => 'https://plain-pr.example.com',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
$labels = collect(generateLabelsApplication($this->application->fresh(), $preview->fresh()));
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.services.https-0-'.$this->application->uuid.'-pr-115.loadbalancer.server.port=3000')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}');
|
||||
});
|
||||
|
||||
it('keeps routing a legacy port-bearing preview FQDN without an override map', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 116, [
|
||||
'fqdn' => 'https://legacy-pr.example.com',
|
||||
]);
|
||||
|
||||
DB::table('application_previews')->where('id', $preview->id)->update([
|
||||
'fqdn' => 'https://legacy-pr.example.com:9090',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
$labels = collect(generateLabelsApplication($this->application->fresh(), $preview->fresh()));
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.routers.https-0-'.$this->application->uuid.'-pr-116.rule=Host(`legacy-pr.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-0-'.$this->application->uuid.'-pr-116.loadbalancer.server.port=9090')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 9090}}');
|
||||
});
|
||||
|
||||
it('passes preview domain port overrides into compose pull-request labels', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'compose_parsing_version' => '3',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
YAML,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://frontend.example.com'],
|
||||
]),
|
||||
'domain_port_overrides' => [
|
||||
'https://frontend.example.com' => 80,
|
||||
],
|
||||
]);
|
||||
|
||||
$preview = createPreviewForPortTests($this->application, 117, [
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://117.frontend.example.com'],
|
||||
]),
|
||||
'fqdn' => 'https://117.frontend.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://117.frontend.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
$parsedCompose = applicationParser($this->application->fresh(), 117, $preview->id);
|
||||
$labels = collect(data_get($parsedCompose, 'services.frontend-pr-117.labels'));
|
||||
|
||||
expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=8080')))
|
||||
->toBeTrue()
|
||||
->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 8080}}')))
|
||||
->toBeTrue()
|
||||
->and($labels->contains(fn (string $label): bool => str_contains($label, 'Host(`117.frontend.example.com`)')))
|
||||
->toBeTrue()
|
||||
->and($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=80')))
|
||||
->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses ports_exposes as the compose preview fallback when a domain has no override', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'compose_parsing_version' => '3',
|
||||
'ports_exposes' => '4000,5000',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
YAML,
|
||||
'fqdn' => null,
|
||||
'domain_port_overrides' => null,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://frontend.example.com'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview = createPreviewForPortTests($this->application, 118, [
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://118.frontend.example.com'],
|
||||
]),
|
||||
'fqdn' => 'https://118.frontend.example.com',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
$parsedCompose = applicationParser($this->application->fresh(), 118, $preview->id);
|
||||
$labels = collect(data_get($parsedCompose, 'services.frontend-pr-118.labels'));
|
||||
|
||||
expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=4000')))
|
||||
->toBeTrue()
|
||||
->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 4000}}')))
|
||||
->toBeTrue();
|
||||
});
|
||||
|
||||
it('shows a port warning when a preview domain uses a port that is not exposed or used by the application', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 119);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->set('newDomainParts.host', 'preview.example.com')
|
||||
->set('newDomainParts.port', '9090')
|
||||
->call('addDomain')
|
||||
->assertSet('showPortWarningModal', true)
|
||||
->assertSet('unrecognizedPort', 9090)
|
||||
->assertSee('Use a different port?')
|
||||
->assertSee('9090');
|
||||
|
||||
expect($preview->fresh()->fqdn)->toBeNull();
|
||||
});
|
||||
|
||||
it('does not warn when a preview domain port is listed in ports exposes', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 120);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->set('newDomainParts.host', 'preview.example.com')
|
||||
->set('newDomainParts.port', '8080')
|
||||
->call('addDomain')
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
});
|
||||
|
||||
it('does not warn when a preview domain port is already used by an application domain', function () {
|
||||
$this->application->update([
|
||||
'ports_exposes' => '3000',
|
||||
'fqdn' => 'https://app.example.com:9090',
|
||||
]);
|
||||
|
||||
$preview = createPreviewForPortTests($this->application, 121);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->set('newDomainParts.host', 'preview.example.com')
|
||||
->set('newDomainParts.port', '9090')
|
||||
->call('addDomain')
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
});
|
||||
|
||||
it('saves an unrecognized preview domain port after confirming the warning', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 122);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->set('newDomainParts.host', 'preview.example.com')
|
||||
->set('newDomainParts.port', '9090')
|
||||
->call('addDomain')
|
||||
->assertSet('showPortWarningModal', true)
|
||||
->call('confirmUseUnknownPort')
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($preview->fresh()->fqdn)->toBe('https://preview.example.com')
|
||||
->and($preview->fresh()->domain_port_overrides)
|
||||
->toBe(['https://preview.example.com' => 9090]);
|
||||
});
|
||||
|
||||
it('cancels an unrecognized preview domain port without saving', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 123);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->set('newDomainParts.host', 'preview.example.com')
|
||||
->set('newDomainParts.port', '9090')
|
||||
->call('addDomain')
|
||||
->assertSet('showPortWarningModal', true)
|
||||
->call('cancelUseUnknownPort')
|
||||
->assertSet('showPortWarningModal', false);
|
||||
|
||||
expect($preview->fresh()->fqdn)->toBeNull();
|
||||
});
|
||||
|
||||
it('warns when editing a preview domain to an unrecognized port', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 124, [
|
||||
'fqdn' => 'https://preview.example.com:3000',
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->call('startEdit', 0)
|
||||
->set('editingDomainParts.port', '5555')
|
||||
->call('updateDomain')
|
||||
->assertSet('showPortWarningModal', true)
|
||||
->assertSet('unrecognizedPort', 5555);
|
||||
|
||||
expect($preview->fresh()->domain_port_overrides)
|
||||
->toBe(['https://preview.example.com' => 3000]);
|
||||
});
|
||||
|
||||
it('does not warn when re-saving a preview domain with the same custom port', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 125, [
|
||||
'fqdn' => 'https://preview.example.com:9090',
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->call('startEdit', 0)
|
||||
->set('editingDomainParts.port', '9090')
|
||||
->call('updateDomain')
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
});
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
use App\Livewire\Project\Service\Domains;
|
||||
use App\Livewire\Project\Service\EditDomain;
|
||||
use App\Livewire\Project\Service\Index;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
|
|
@ -10,14 +12,21 @@
|
|||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
// Create user and team
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
// Create server
|
||||
$this->server = Server::factory()->create([
|
||||
|
|
@ -25,9 +34,7 @@
|
|||
]);
|
||||
|
||||
// Create standalone docker destination
|
||||
$this->destination = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
|
||||
// Create project and environment
|
||||
$this->project = Project::factory()->create([
|
||||
|
|
@ -48,8 +55,10 @@
|
|||
]);
|
||||
|
||||
// Create service application
|
||||
$this->serviceApplication = ServiceApplication::factory()->create([
|
||||
$this->serviceApplication = ServiceApplication::create([
|
||||
'service_id' => $this->service->id,
|
||||
'name' => 'web',
|
||||
'image' => 'nginx:alpine',
|
||||
'fqdn' => 'http://example.com:8000',
|
||||
]);
|
||||
|
||||
|
|
@ -68,6 +77,42 @@ function get_service_templates_mock()
|
|||
}
|
||||
});
|
||||
|
||||
it('loads a persisted port override in the service application editor', function () {
|
||||
$this->serviceApplication->update([
|
||||
'fqdn' => 'https://web.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://web.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Index::class, [
|
||||
'serviceApplication' => $this->serviceApplication->fresh(),
|
||||
'parameters' => [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'service_uuid' => $this->service->uuid,
|
||||
'stack_service_uuid' => $this->serviceApplication->uuid,
|
||||
],
|
||||
'query' => [],
|
||||
])
|
||||
->assertSet('fqdn', 'https://web.example.com:8080')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('initializes route state when mounting a service application directly', function () {
|
||||
Livewire::test(Index::class, [
|
||||
'serviceApplication' => $this->serviceApplication,
|
||||
])
|
||||
->assertSet('parameters', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'service_uuid' => $this->service->uuid,
|
||||
'stack_service_uuid' => $this->serviceApplication->uuid,
|
||||
])
|
||||
->assertSet('query', [])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('loads the EditDomain component with required port', function () {
|
||||
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
|
||||
->assertSet('requiredPort', 8000)
|
||||
|
|
@ -75,6 +120,28 @@ function get_service_templates_mock()
|
|||
->assertOk();
|
||||
});
|
||||
|
||||
it('loads a persisted port override and moves it when the hostname changes', function () {
|
||||
$this->serviceApplication->update([
|
||||
'fqdn' => 'https://old.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://old.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
|
||||
->assertSet('fqdn', 'https://old.example.com:8080')
|
||||
->set('fqdn', 'https://new.example.com:8080')
|
||||
->call('submit')
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertSet('fqdn', 'https://new.example.com:8080');
|
||||
|
||||
expect($this->serviceApplication->fresh())
|
||||
->fqdn->toBe('https://new.example.com')
|
||||
->domain_port_overrides->toBe([
|
||||
'https://new.example.com' => 8080,
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks noindex changes as pending configuration', function () {
|
||||
$this->service->isConfigurationChanged(save: true);
|
||||
|
||||
|
|
@ -107,7 +174,7 @@ function get_service_templates_mock()
|
|||
});
|
||||
|
||||
it('cancels port removal when user cancels', function () {
|
||||
$originalFqdn = $this->serviceApplication->fqdn;
|
||||
$originalFqdn = $this->serviceApplication->url;
|
||||
|
||||
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
|
||||
->set('fqdn', 'http://example.com') // Remove port
|
||||
|
|
@ -126,7 +193,10 @@ function get_service_templates_mock()
|
|||
|
||||
// Verify the FQDN was updated
|
||||
$this->serviceApplication->refresh();
|
||||
expect($this->serviceApplication->fqdn)->toBe('http://example.com:3000');
|
||||
expect($this->serviceApplication->fqdn)->toBe('http://example.com')
|
||||
->and($this->serviceApplication->domain_port_overrides)->toBe([
|
||||
'http://example.com' => 3000,
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows saving when all domains have ports (multiple domains)', function () {
|
||||
|
|
@ -153,8 +223,10 @@ function get_service_templates_mock()
|
|||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$appWithoutPort = ServiceApplication::factory()->create([
|
||||
$appWithoutPort = ServiceApplication::create([
|
||||
'service_id' => $serviceWithoutPort->id,
|
||||
'name' => 'web',
|
||||
'image' => 'nginx:alpine',
|
||||
'fqdn' => 'http://example.com',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
|
|
@ -173,6 +174,23 @@ function createServiceWithoutApplicationsForApiTest(object $ctx): Service
|
|||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid, 'name' => 'web']);
|
||||
});
|
||||
|
||||
test('returns an editable url with persisted port overrides', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$ctx->serviceApplication->update([
|
||||
'fqdn' => 'https://web.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://web.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('url', 'https://web.example.com:8080')
|
||||
->assertJsonMissingPath('domain_port_overrides');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () {
|
||||
|
|
@ -199,6 +217,34 @@ function createServiceWithoutApplicationsForApiTest(object $ctx): Service
|
|||
expect($ctx->serviceApplication->human_name)->toBe('Web UI');
|
||||
});
|
||||
|
||||
test('round trips and moves a port override when renaming a domain', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$ctx->serviceApplication->update([
|
||||
'fqdn' => 'https://old.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://old.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
$url = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}")
|
||||
->json('url');
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'url' => str_replace('old.example.com', 'new.example.com', $url),
|
||||
])->assertSuccessful()
|
||||
->assertJsonPath('url', 'https://new.example.com:8080');
|
||||
|
||||
expect($ctx->serviceApplication->fresh())
|
||||
->fqdn->toBe('https://new.example.com')
|
||||
->domain_port_overrides->toBe([
|
||||
'https://new.example.com' => 8080,
|
||||
]);
|
||||
});
|
||||
|
||||
test('updates the HTTP to HTTPS redirect setting', function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
|
@ -225,6 +271,19 @@ function createServiceWithoutApplicationsForApiTest(object $ctx): Service
|
|||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('returns 422 for a url port outside the valid TCP range', function (string $url) {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'url' => $url,
|
||||
])->assertUnprocessable();
|
||||
})->with([
|
||||
'zero' => 'https://example.com:0',
|
||||
'above maximum' => 'https://example.com:65536',
|
||||
]);
|
||||
|
||||
test('returns 422 when enabling log drain but server has no log drain', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@
|
|||
|
||||
$serviceApp->refresh();
|
||||
|
||||
expect($serviceApp->fqdn)->toBe('http://git.example.com:80')
|
||||
expect($serviceApp->fqdn)->toBe('http://git.example.com')
|
||||
->and($serviceApp->domain_port_overrides['http://git.example.com'] ?? null)->toBe(80)
|
||||
->and($serviceApp->fqdn)->not->toContain('//:80')
|
||||
->and(fn () => Url::fromString($serviceApp->fqdn))->not->toThrow(Throwable::class);
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,40 @@
|
|||
]);
|
||||
});
|
||||
|
||||
it('marks service application port-only changes as pending configuration', function () {
|
||||
$this->webApp->update([
|
||||
'fqdn' => 'http://example.com',
|
||||
'domain_port_overrides' => ['http://example.com' => 8000],
|
||||
]);
|
||||
$this->service->isConfigurationChanged(save: true);
|
||||
|
||||
$this->webApp->update([
|
||||
'domain_port_overrides' => ['http://example.com' => 3000],
|
||||
]);
|
||||
|
||||
expect($this->service->refresh()->isConfigurationChanged())->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not mark reordered service application port overrides as changed', function () {
|
||||
$this->webApp->update([
|
||||
'fqdn' => 'http://one.example.com,http://two.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'http://one.example.com' => 8000,
|
||||
'http://two.example.com' => 3000,
|
||||
],
|
||||
]);
|
||||
$this->service->isConfigurationChanged(save: true);
|
||||
|
||||
$this->webApp->update([
|
||||
'domain_port_overrides' => [
|
||||
'http://two.example.com' => 3000,
|
||||
'http://one.example.com' => 8000,
|
||||
],
|
||||
]);
|
||||
|
||||
expect($this->service->refresh()->isConfigurationChanged())->toBeFalse();
|
||||
});
|
||||
|
||||
it('groups configured domains and shows redirect settings in the table', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com,https://admin.example.com',
|
||||
|
|
@ -504,6 +538,133 @@
|
|||
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['message'] ?? null)->not->toBe('Stale DNS result.');
|
||||
});
|
||||
|
||||
it('shows the port warning modal when adding a domain with a non-default port', function () {
|
||||
$this->service->update([
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
environment:
|
||||
- SERVICE_FQDN_WEB_8000
|
||||
api:
|
||||
image: node:alpine
|
||||
YAML,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->set('newServiceApplicationId', $this->webApp->id)
|
||||
->set('newDomainParts.host', 'web.example.com')
|
||||
->set('newDomainParts.port', '3000')
|
||||
->set('newDomainPartsChanged', true)
|
||||
->call('addDomain')
|
||||
->assertSet('showPortWarningModal', true)
|
||||
->assertSet('requiredPort', 8000)
|
||||
->assertSee('Use a different port?');
|
||||
|
||||
expect($this->webApp->fresh()->fqdn)->toBeNull();
|
||||
});
|
||||
|
||||
it('saves a non-default domain port after confirming the warning', function () {
|
||||
$this->service->update([
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
environment:
|
||||
- SERVICE_FQDN_WEB_8000
|
||||
api:
|
||||
image: node:alpine
|
||||
YAML,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->set('newServiceApplicationId', $this->webApp->id)
|
||||
->set('newDomainParts.host', 'web.example.com')
|
||||
->set('newDomainParts.port', '3000')
|
||||
->set('newDomainPartsChanged', true)
|
||||
->call('addDomain')
|
||||
->assertSet('showPortWarningModal', true)
|
||||
->call('confirmRemovePort')
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
|
||||
$this->webApp->refresh();
|
||||
|
||||
expect($this->webApp->fqdn)->toContain('https://web.example.com')
|
||||
->and($this->webApp->domain_port_overrides['https://web.example.com'] ?? null)->toBe(3000);
|
||||
});
|
||||
|
||||
it('clears a service domain port override when saving without a port', function () {
|
||||
$this->service->update([
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
api:
|
||||
image: node:alpine
|
||||
environment:
|
||||
- SERVICE_FQDN_API_3000
|
||||
web:
|
||||
image: nginx:alpine
|
||||
YAML,
|
||||
]);
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://api.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '8080')
|
||||
->set('editingDomainParts.port', '')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertSee('Internal port 3000')
|
||||
->assertDontSee('Internal port 8080');
|
||||
|
||||
expect($this->apiApp->fresh()->domain_port_overrides ?? [])
|
||||
->not->toHaveKey('https://api.example.com');
|
||||
});
|
||||
|
||||
it('reopens service domain edit with the saved port override', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://api.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSet('domainRows.0.url', 'https://api.example.com')
|
||||
->assertSet('domainRows.0.internal_port', 8080)
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '8080')
|
||||
->assertSet('editingDomainParts.host', 'api.example.com');
|
||||
});
|
||||
|
||||
it('does not show the port warning modal when the domain uses the required port', function () {
|
||||
$this->service->update([
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
environment:
|
||||
- SERVICE_FQDN_WEB_8000
|
||||
api:
|
||||
image: node:alpine
|
||||
YAML,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->set('newServiceApplicationId', $this->webApp->id)
|
||||
->set('newDomainParts.host', 'web.example.com')
|
||||
->set('newDomainParts.port', '8000')
|
||||
->set('newDomainPartsChanged', true)
|
||||
->call('addDomain')
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
});
|
||||
|
||||
it('saves after confirming both a domain conflict and a missing required port', function () {
|
||||
$this->service->update([
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
|
|
@ -526,9 +687,9 @@
|
|||
->assertSet('showDomainConflictModal', false)
|
||||
->assertSet('showPortWarningModal', true)
|
||||
->assertSet('forceSaveDomains', true)
|
||||
->assertSee('Remove required port?')
|
||||
->assertSee('Keep port')
|
||||
->assertSee('Remove port anyway');
|
||||
->assertSee('Use a different port?')
|
||||
->assertSee('Keep required port')
|
||||
->assertSee('Use this port anyway');
|
||||
|
||||
$component
|
||||
->call('confirmRemovePort')
|
||||
|
|
@ -690,3 +851,65 @@
|
|||
expect(file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php')))
|
||||
->not->toContain('<select');
|
||||
});
|
||||
|
||||
it('keeps noindex domains when normalizing a custom service domain port', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com:8080',
|
||||
'noindex_domains' => ['https://api.example.com:8080'],
|
||||
]);
|
||||
|
||||
expect($this->apiApp->refresh())
|
||||
->fqdn->toBe('https://api.example.com')
|
||||
->noindex_domains->toBe(['https://api.example.com'])
|
||||
->and($this->apiApp->domain_port_overrides)
|
||||
->toBe(['https://api.example.com' => 8080]);
|
||||
});
|
||||
|
||||
it('shows an inherited internal port badge from the coolify service env port', function () {
|
||||
$this->service->update([
|
||||
'docker_compose_raw' => "services:\n api:\n image: node:alpine\n environment:\n - SERVICE_FQDN_API_3000\n",
|
||||
]);
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSet('domainRows.0.internal_port', 3000)
|
||||
->assertSet('domainRows.0.has_port_override', false)
|
||||
->assertSee('Internal port 3000')
|
||||
->assertSee('Inherited from the Coolify service port', false)
|
||||
->assertDontSee('No internal port');
|
||||
});
|
||||
|
||||
it('shows a custom internal port badge for a service domain override', function () {
|
||||
$this->service->update([
|
||||
'docker_compose_raw' => "services:\n api:\n image: node:alpine\n environment:\n - SERVICE_FQDN_API_3000\n",
|
||||
]);
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com',
|
||||
'domain_port_overrides' => [
|
||||
'https://api.example.com' => 8080,
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSet('domainRows.0.internal_port', 8080)
|
||||
->assertSet('domainRows.0.has_port_override', true)
|
||||
->assertSee('Internal port 8080')
|
||||
->assertSee('Custom internal port for this domain', false)
|
||||
->assertDontSee('Internal port 3000');
|
||||
});
|
||||
|
||||
it('does not show an internal port badge when the service has no env port', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSet('domainRows.0.internal_port', null)
|
||||
->assertDontSee('No internal port')
|
||||
->assertDontSee('Internal port ')
|
||||
->assertDontSee('table-badge-danger', false);
|
||||
});
|
||||
|
|
|
|||
48
tests/Feature/ServiceTemplatePortCoverageTest.php
Normal file
48
tests/Feature/ServiceTemplatePortCoverageTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* One-click templates that expose SERVICE_URL / SERVICE_FQDN without a _PORT
|
||||
* suffix (WordPress-style) must declare `# port:` so getRequiredPort() can
|
||||
* fall back for the badge and proxy.
|
||||
*/
|
||||
it('requires a template port for HTTP compose services that omit SERVICE_*_PORT', function () {
|
||||
$templates = get_service_templates();
|
||||
$missing = [];
|
||||
|
||||
foreach (glob(base_path('templates/compose/*.{yaml,yml}'), GLOB_BRACE) as $file) {
|
||||
$name = pathinfo($file, PATHINFO_FILENAME);
|
||||
if (! $templates->has($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = file_get_contents($file);
|
||||
$declaresHttpUrlWithoutPort = false;
|
||||
foreach (preg_split("/\r\n|\n|\r/", $text) as $line) {
|
||||
$line = trim($line);
|
||||
if (! preg_match('/^- SERVICE_(?:URL|FQDN)_([A-Z0-9_]+)$/', $line, $match)
|
||||
&& ! preg_match('/^SERVICE_(?:URL|FQDN)_([A-Z0-9_]+):/', $line, $match)) {
|
||||
continue;
|
||||
}
|
||||
if (! preg_match('/_\d+$/', $match[1])) {
|
||||
$declaresHttpUrlWithoutPort = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $declaresHttpUrlWithoutPort) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$yamlPort = null;
|
||||
if (preg_match('/^#\s*port:\s*(\d+)/m', $text, $portMatch)) {
|
||||
$yamlPort = $portMatch[1];
|
||||
}
|
||||
$jsonPort = data_get($templates, "{$name}.port");
|
||||
|
||||
if (! $yamlPort || ! filled($jsonPort)) {
|
||||
$missing[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
expect($missing)->toBeEmpty('HTTP templates without SERVICE_*_PORT need # port: and JSON port: '.implode(', ', $missing));
|
||||
});
|
||||
27
tests/Feature/ServiceTemplatePortLookupTest.php
Normal file
27
tests/Feature/ServiceTemplatePortLookupTest.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
|
||||
it('resolves the wordpress template port from service_type even when the display name differs', function () {
|
||||
expect(data_get(get_service_templates(), 'wordpress-without-database.port'))->toBe('80');
|
||||
|
||||
$service = new Service([
|
||||
'name' => 'api-smoke-wp',
|
||||
'service_type' => 'wordpress-without-database',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
wordpress:
|
||||
image: wordpress:latest
|
||||
environment:
|
||||
- SERVICE_URL_WORDPRESS
|
||||
YAML,
|
||||
]);
|
||||
|
||||
expect($service->getRequiredPort())->toBe(80);
|
||||
|
||||
$app = new ServiceApplication(['name' => 'wordpress']);
|
||||
$app->setRelation('service', $service);
|
||||
|
||||
expect($app->getRequiredPort())->toBe(80);
|
||||
});
|
||||
|
|
@ -29,6 +29,14 @@
|
|||
->toContain('$removedLabel = (string) collect($removedLabel)->first();');
|
||||
});
|
||||
|
||||
it('falls back to the template port for service application proxy labels', function () {
|
||||
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
|
||||
|
||||
expect($sharedFile)->toContain(
|
||||
'? ($savedService->getRequiredPort() ?? $predefinedPort)'
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies label parsing array check occurs before preg_match', function () {
|
||||
// Read the parseDockerComposeFile function from shared.php
|
||||
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
|
||||
|
|
|
|||
36
tests/Unit/DomainPortOverridesTest.php
Normal file
36
tests/Unit/DomainPortOverridesTest.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use App\Support\DomainPortOverrides;
|
||||
|
||||
it('copies the source port override to an automatically paired domain', function (string $source, string $counterpart) {
|
||||
$result = DomainPortOverrides::normalize(
|
||||
"$source,$counterpart",
|
||||
[$source => 8080],
|
||||
);
|
||||
|
||||
expect($result['overrides'])->toBe([
|
||||
$source => 8080,
|
||||
$counterpart => 8080,
|
||||
]);
|
||||
})->with([
|
||||
'www redirect' => ['https://example.com', 'https://www.example.com'],
|
||||
'non-www redirect' => ['https://www.example.com', 'https://example.com'],
|
||||
]);
|
||||
|
||||
it('keeps an explicit override on the paired domain', function (string $source, string $counterpart) {
|
||||
$result = DomainPortOverrides::normalize(
|
||||
"$source,$counterpart",
|
||||
[
|
||||
$source => 8080,
|
||||
$counterpart => 9090,
|
||||
],
|
||||
);
|
||||
|
||||
expect($result['overrides'])->toBe([
|
||||
$source => 8080,
|
||||
$counterpart => 9090,
|
||||
]);
|
||||
})->with([
|
||||
'www redirect' => ['https://example.com', 'https://www.example.com'],
|
||||
'non-www redirect' => ['https://www.example.com', 'https://example.com'],
|
||||
]);
|
||||
|
|
@ -232,3 +232,55 @@ function middlewaresOf(array $labels, string $router): array
|
|||
)->all())->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
test('fqdnLabelsForCaddy routes each portless domain to its override port', function () {
|
||||
$labels = fqdnLabelsForCaddy(
|
||||
network: 'testnetwork',
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://one.example.com', 'https://two.example.com']),
|
||||
onlyPort: 80,
|
||||
is_force_https_enabled: true,
|
||||
domainPortOverrides: [
|
||||
'https://one.example.com' => 3000,
|
||||
'https://two.example.com' => 8080,
|
||||
],
|
||||
)->values()->all();
|
||||
|
||||
expect($labels)
|
||||
->toContain('caddy_0=https://one.example.com')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}')
|
||||
->toContain('caddy_1=https://two.example.com')
|
||||
->toContain('caddy_1.handle_path.1_reverse_proxy={{upstreams 8080}}')
|
||||
->not->toContain('caddy_0=https://one.example.com:3000')
|
||||
->not->toContain('caddy_1=https://two.example.com:8080');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForCaddy uses onlyPort when a portless domain has no override', function () {
|
||||
$labels = fqdnLabelsForCaddy(
|
||||
network: 'testnetwork',
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://plain.example.com']),
|
||||
onlyPort: 4000,
|
||||
is_force_https_enabled: true,
|
||||
domainPortOverrides: [],
|
||||
)->values()->all();
|
||||
|
||||
expect($labels)
|
||||
->toContain('caddy_0=https://plain.example.com')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 4000}}');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForCaddy keeps routing a legacy port-bearing FQDN without an override map', function () {
|
||||
$labels = fqdnLabelsForCaddy(
|
||||
network: 'testnetwork',
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://legacy.example.com:9090']),
|
||||
onlyPort: 80,
|
||||
is_force_https_enabled: true,
|
||||
domainPortOverrides: [],
|
||||
)->values()->all();
|
||||
|
||||
expect($labels)
|
||||
->toContain('caddy_0=https://legacy.example.com')
|
||||
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 9090}}');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -198,6 +198,47 @@
|
|||
expect($result)->toBe(3000);
|
||||
});
|
||||
|
||||
it('falls back to the one-click template port when SERVICE_URL has no port suffix', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
wordpress:
|
||||
environment:
|
||||
- SERVICE_URL_WORDPRESS
|
||||
- WORDPRESS_DB_HOST=mysql
|
||||
YAML;
|
||||
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->docker_compose_raw = $yaml;
|
||||
$service->shouldReceive('getRequiredPort')->andReturn(80);
|
||||
|
||||
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||
$app->name = 'wordpress';
|
||||
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||
$app->service = $service;
|
||||
|
||||
expect($app->getRequiredPort())->toBe(80);
|
||||
});
|
||||
|
||||
it('does not apply the template port to a container without SERVICE_URL or SERVICE_FQDN', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
mysql:
|
||||
environment:
|
||||
- MYSQL_DATABASE=wordpress
|
||||
YAML;
|
||||
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->docker_compose_raw = $yaml;
|
||||
$service->shouldReceive('getRequiredPort')->andReturn(80);
|
||||
|
||||
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||
$app->name = 'mysql';
|
||||
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||
$app->service = $service;
|
||||
|
||||
expect($app->getRequiredPort())->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for map-style environment without port', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
|
|
|
|||
|
|
@ -107,3 +107,49 @@
|
|||
expect($labels)
|
||||
->toContain('traefik.http.middlewares.0-application-uuid-to-www.redirectregex.replacement=${1}://www.${2}');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForTraefik routes each portless domain to its override port', function () {
|
||||
$labels = fqdnLabelsForTraefik(
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://one.example.com', 'https://two.example.com']),
|
||||
onlyPort: 80,
|
||||
domainPortOverrides: [
|
||||
'https://one.example.com' => 3000,
|
||||
'https://two.example.com' => 8080,
|
||||
],
|
||||
);
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`one.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=3000')
|
||||
->toContain('traefik.http.routers.https-1-appuuid.rule=Host(`two.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-1-appuuid.loadbalancer.server.port=8080')
|
||||
->not->toContain('Host(`one.example.com:3000`)')
|
||||
->not->toContain('Host(`two.example.com:8080`)');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForTraefik uses onlyPort when a portless domain has no override', function () {
|
||||
$labels = fqdnLabelsForTraefik(
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://plain.example.com']),
|
||||
onlyPort: 4000,
|
||||
domainPortOverrides: [],
|
||||
);
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`plain.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=4000');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForTraefik keeps routing a legacy port-bearing FQDN without an override map', function () {
|
||||
$labels = fqdnLabelsForTraefik(
|
||||
uuid: 'appuuid',
|
||||
domains: collect(['https://legacy.example.com:9090']),
|
||||
onlyPort: 80,
|
||||
domainPortOverrides: [],
|
||||
);
|
||||
|
||||
expect($labels)
|
||||
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`legacy.example.com`) && PathPrefix(`/`)')
|
||||
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=9090');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -224,3 +224,17 @@
|
|||
->and(ValidationPatterns::validateApplicationDomains('https://localhost'))->not->toBeEmpty()
|
||||
->and(ValidationPatterns::validateApplicationDomains('http://192.0.2.10:8000'))->toBeEmpty();
|
||||
});
|
||||
|
||||
it('rejects application domain ports outside the valid TCP range', function (string $domain) {
|
||||
expect(ValidationPatterns::validateApplicationDomains($domain))->not->toBeEmpty();
|
||||
})->with([
|
||||
'zero' => 'https://example.com:0',
|
||||
'above maximum' => 'https://example.com:65536',
|
||||
]);
|
||||
|
||||
it('accepts application domain ports at the TCP range boundaries', function (string $domain) {
|
||||
expect(ValidationPatterns::validateApplicationDomains($domain))->toBeEmpty();
|
||||
})->with([
|
||||
'minimum' => 'https://example.com:1',
|
||||
'maximum' => 'https://example.com:65535',
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue