fix(domains): route Compose domains using service ports
Resolve routing ports per Compose service, preserve legacy embedded ports, and ignore non-TCP declarations.
This commit is contained in:
parent
e5c75db84a
commit
183e28682c
16 changed files with 609 additions and 122 deletions
|
|
@ -554,13 +554,6 @@ protected function effectiveDomainInternalPort(string $url, ?string $service = n
|
|||
];
|
||||
}
|
||||
|
||||
if ($this->application->settings?->is_static) {
|
||||
return [
|
||||
'internal_port' => 80,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service);
|
||||
if ($composePort !== null) {
|
||||
return [
|
||||
|
|
@ -569,13 +562,20 @@ protected function effectiveDomainInternalPort(string $url, ?string $service = n
|
|||
];
|
||||
}
|
||||
|
||||
if ($this->isCompose && $service !== null && count($this->composeServices) > 1) {
|
||||
if ($this->isCompose && $service !== null) {
|
||||
return [
|
||||
'internal_port' => null,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
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]
|
||||
|
|
@ -613,7 +613,7 @@ protected function currentRowPort(string $url): ?int
|
|||
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
|
||||
}
|
||||
|
||||
protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool
|
||||
protected function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool
|
||||
{
|
||||
if ($this->forceUseUnknownPort || $port === null) {
|
||||
return false;
|
||||
|
|
@ -622,7 +622,7 @@ protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool
|
|||
return false;
|
||||
}
|
||||
|
||||
return $this->application->portRequiresConfirmation($port);
|
||||
return $this->application->portRequiresConfirmation($port, $serviceName);
|
||||
}
|
||||
|
||||
protected function openPortWarning(?int $port, string $action): void
|
||||
|
|
@ -1018,7 +1018,7 @@ public function addDomain(): void
|
|||
}
|
||||
}
|
||||
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) {
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) {
|
||||
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
|
||||
|
||||
return;
|
||||
|
|
@ -1415,7 +1415,7 @@ public function updateDomain(): void
|
|||
return;
|
||||
}
|
||||
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) {
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $service)) {
|
||||
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
|
||||
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ public function addDomain(): void
|
|||
|
||||
return;
|
||||
}
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) {
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) {
|
||||
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
|
||||
|
||||
return;
|
||||
|
|
@ -173,7 +173,7 @@ public function updateDomain(): void
|
|||
return;
|
||||
}
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) {
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) {
|
||||
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
|
||||
|
||||
return;
|
||||
|
|
@ -478,7 +478,7 @@ private function currentRowPort(string $url): ?int
|
|||
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
|
||||
}
|
||||
|
||||
private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool
|
||||
private function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool
|
||||
{
|
||||
if ($this->forceUseUnknownPort || $port === null) {
|
||||
return false;
|
||||
|
|
@ -487,7 +487,7 @@ private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool
|
|||
return false;
|
||||
}
|
||||
|
||||
return $this->preview->application->portRequiresConfirmation($port);
|
||||
return $this->preview->application->portRequiresConfirmation($port, $serviceName);
|
||||
}
|
||||
|
||||
private function openPortWarning(?int $port, string $action): void
|
||||
|
|
@ -522,13 +522,6 @@ private function effectiveDomainInternalPort(string $url, ?string $service = nul
|
|||
];
|
||||
}
|
||||
|
||||
if ($this->preview->application->settings?->is_static) {
|
||||
return [
|
||||
'internal_port' => 80,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service);
|
||||
if ($composePort !== null) {
|
||||
return [
|
||||
|
|
@ -537,13 +530,20 @@ private function effectiveDomainInternalPort(string $url, ?string $service = nul
|
|||
];
|
||||
}
|
||||
|
||||
if ($this->preview->application->build_pack === 'dockercompose' && $service !== null && count($this->composeServices()) > 1) {
|
||||
if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) {
|
||||
return [
|
||||
'internal_port' => null,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -964,12 +964,16 @@ public function main_port()
|
|||
}
|
||||
|
||||
/**
|
||||
* Ports the container is expected to listen on: Ports Exposes plus ports already used by application domains.
|
||||
* Ports declared by the selected Compose service, or exposed and previously used application ports.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function availableInternalPorts(): array
|
||||
public function availableInternalPorts(?string $serviceName = null): array
|
||||
{
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
return dockerComposeServicePorts($this->docker_compose_raw, $serviceName);
|
||||
}
|
||||
|
||||
$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);
|
||||
|
|
@ -994,13 +998,13 @@ public function availableInternalPorts(): array
|
|||
return $ports->unique()->sort()->values()->all();
|
||||
}
|
||||
|
||||
public function portRequiresConfirmation(?int $port): bool
|
||||
public function portRequiresConfirmation(?int $port, ?string $serviceName = null): bool
|
||||
{
|
||||
if ($port === null || $port <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! in_array($port, $this->availableInternalPorts(), true);
|
||||
return ! in_array($port, $this->availableInternalPorts($serviceName), true);
|
||||
}
|
||||
|
||||
public function detectPortFromEnvironment(?bool $isPreview = false): ?int
|
||||
|
|
|
|||
|
|
@ -1477,7 +1477,10 @@ public function getRequiredPort(): ?int
|
|||
{
|
||||
try {
|
||||
$services = get_service_templates();
|
||||
$serviceName = $this->service_type ?: str($this->name)->beforeLast('-')->value();
|
||||
if (blank($this->service_type)) {
|
||||
return null;
|
||||
}
|
||||
$serviceName = $this->service_type;
|
||||
$service = data_get($services, $serviceName, []);
|
||||
$port = data_get($service, 'port');
|
||||
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ public function fqdns(): Attribute
|
|||
}
|
||||
|
||||
/**
|
||||
* Return the public URLs with their persisted internal port overrides.
|
||||
* Return editable URLs with persisted overrides or legacy embedded ports.
|
||||
*/
|
||||
protected function url(): Attribute
|
||||
{
|
||||
|
|
@ -220,7 +220,7 @@ protected function url(): Attribute
|
|||
$port = $overrides[$canonical] ?? null;
|
||||
|
||||
if ($port === null) {
|
||||
return $canonical;
|
||||
return $url;
|
||||
}
|
||||
|
||||
$parts = DomainUrlParts::split($canonical);
|
||||
|
|
@ -366,7 +366,7 @@ public function getRequiredPort(): ?int
|
|||
}
|
||||
|
||||
$dockerCompose = Yaml::parse($dockerComposeRaw);
|
||||
$serviceConfig = data_get($dockerCompose, "services.{$this->name}");
|
||||
$serviceConfig = $dockerCompose['services'][$this->name] ?? null;
|
||||
if (! $serviceConfig) {
|
||||
return $this->service->getRequiredPort();
|
||||
}
|
||||
|
|
@ -417,9 +417,21 @@ public function getRequiredPort(): ?int
|
|||
return $portFound;
|
||||
}
|
||||
|
||||
$composePort = firstDockerComposeServicePort($serviceConfig);
|
||||
if ($composePort !== null) {
|
||||
return $composePort;
|
||||
}
|
||||
|
||||
// HTTP-facing compose services that only declare SERVICE_URL/FQDN (no _PORT
|
||||
// suffix), such as WordPress, inherit the one-click template `# port:`.
|
||||
if ($declaresHttpUrl) {
|
||||
if (blank($this->service->service_type)) {
|
||||
$savedPort = $this->getSavedLegacyRoutingPort($serviceConfig);
|
||||
if ($savedPort !== null) {
|
||||
return $savedPort;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->service->getRequiredPort();
|
||||
}
|
||||
|
||||
|
|
@ -428,4 +440,52 @@ public function getRequiredPort(): ?int
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve only an unambiguous upstream from this legacy container's saved labels.
|
||||
*/
|
||||
private function getSavedLegacyRoutingPort(array $serviceConfig): ?int
|
||||
{
|
||||
$savedCompose = Yaml::parse($this->service->docker_compose ?? '');
|
||||
$savedService = $savedCompose['services'][$this->name] ?? null;
|
||||
$image = $serviceConfig['image'] ?? null;
|
||||
if (! is_string($image) || $image === '' || ($savedService['image'] ?? null) !== $image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$labels = $savedService['labels'] ?? [];
|
||||
if (! is_array($labels)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ports = [];
|
||||
foreach ($labels as $key => $value) {
|
||||
if (is_int($key)) {
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
[$key, $value] = array_pad(explode('=', $value, 2), 2, null);
|
||||
}
|
||||
|
||||
if (preg_match('/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/', $key)) {
|
||||
$port = $value;
|
||||
} elseif (preg_match('/^caddy(?:_\d+)?\..*reverse_proxy$/', $key)) {
|
||||
if (! is_string($value) || ! preg_match('/^\{\{upstreams ([0-9]+)\}\}$/', $value, $matches)) {
|
||||
return null;
|
||||
}
|
||||
$port = $matches[1];
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((! is_string($port) && ! is_int($port)) || ! preg_match('/^[0-9]+$/', (string) $port) || (int) $port < 1 || (int) $port > 65535) {
|
||||
return null;
|
||||
}
|
||||
$ports[] = (int) $port;
|
||||
}
|
||||
|
||||
$ports = array_values(array_unique($ports));
|
||||
|
||||
return count($ports) === 1 ? $ports[0] : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -607,6 +607,13 @@ function firstDockerComposeServicePort(mixed $service): ?int
|
|||
->merge(data_get($service, 'ports', []));
|
||||
|
||||
foreach ($portDefinitions as $definition) {
|
||||
$protocol = is_array($definition)
|
||||
? data_get($definition, 'protocol', 'tcp')
|
||||
: (str_contains((string) $definition, '/') ? str((string) $definition)->afterLast('/')->value() : 'tcp');
|
||||
if ($protocol !== 'tcp') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$port = is_array($definition)
|
||||
? data_get($definition, 'target')
|
||||
: str((string) $definition)->before('/')->afterLast(':')->value();
|
||||
|
|
@ -620,18 +627,29 @@ function firstDockerComposeServicePort(mixed $service): ?int
|
|||
}
|
||||
|
||||
function dockerComposeServicePort(?string $compose, ?string $serviceName): ?int
|
||||
{
|
||||
return dockerComposeServicePorts($compose, $serviceName)[0] ?? null;
|
||||
}
|
||||
|
||||
function dockerComposeServicePorts(?string $compose, ?string $serviceName): array
|
||||
{
|
||||
if (blank($compose) || blank($serviceName)) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$services = data_get(Yaml::parse($compose), 'services', []);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
return firstDockerComposeServicePort(is_array($services) ? ($services[$serviceName] ?? null) : null);
|
||||
$service = is_array($services) ? ($services[$serviceName] ?? []) : [];
|
||||
|
||||
return collect(data_get($service, 'expose', []))
|
||||
->merge(data_get($service, 'ports', []))
|
||||
->map(fn ($definition) => firstDockerComposeServicePort(['expose' => [$definition]]))
|
||||
->filter(fn ($port) => $port !== null)
|
||||
->unique()->values()->all();
|
||||
}
|
||||
|
||||
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 = [])
|
||||
|
|
|
|||
|
|
@ -390,9 +390,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
return collect([]);
|
||||
}
|
||||
$services = data_get($yaml, 'services', collect([]));
|
||||
$applicationServiceCount = collect($services)
|
||||
->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image')))
|
||||
->count();
|
||||
$topLevel = collect([
|
||||
'volumes' => collect(data_get($yaml, 'volumes', [])),
|
||||
'networks' => collect(data_get($yaml, 'networks', [])),
|
||||
|
|
@ -1357,9 +1354,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$domainPortOverrides = $isPullRequest
|
||||
? ($previewForPorts?->domain_port_overrides ?? [])
|
||||
: ($originalResource->domain_port_overrides ?? []);
|
||||
$exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array;
|
||||
$onlyPort = firstDockerComposeServicePort($service)
|
||||
?? ($applicationServiceCount === 1 ? ($exposedPorts[0] ?? null) : null);
|
||||
$onlyPort = firstDockerComposeServicePort($service);
|
||||
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
|
||||
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
|
||||
}
|
||||
|
|
@ -1566,7 +1561,6 @@ function serviceParser(Service $resource): Collection
|
|||
$envComments = extractYamlEnvironmentComments($compose);
|
||||
|
||||
$server = data_get($resource, 'server');
|
||||
$allServices = get_service_templates();
|
||||
|
||||
try {
|
||||
$yaml = Yaml::parse($compose);
|
||||
|
|
@ -1699,22 +1693,7 @@ function serviceParser(Service $resource): Collection
|
|||
|
||||
$containerName = "$serviceName-{$resource->uuid}";
|
||||
|
||||
if ($serviceName === 'registry') {
|
||||
$tempServiceName = 'docker-registry';
|
||||
} else {
|
||||
$tempServiceName = $serviceName;
|
||||
}
|
||||
if (str(data_get($service, 'image'))->contains('glitchtip')) {
|
||||
$tempServiceName = 'glitchtip';
|
||||
}
|
||||
if ($serviceName === 'supabase-kong') {
|
||||
$tempServiceName = 'supabase';
|
||||
}
|
||||
$serviceDefinition = data_get($allServices, $tempServiceName);
|
||||
$predefinedPort = data_get($serviceDefinition, 'port');
|
||||
if ($serviceName === 'plausible') {
|
||||
$predefinedPort = '8000';
|
||||
}
|
||||
$predefinedPort = $resource->getRequiredPort();
|
||||
|
||||
if ($migratedApp || $migratedDb) {
|
||||
// Use the already determined migrated service
|
||||
|
|
@ -2084,22 +2063,7 @@ function serviceParser(Service $resource): Collection
|
|||
|
||||
$containerName = "$serviceName-{$resource->uuid}";
|
||||
|
||||
if ($serviceName === 'registry') {
|
||||
$tempServiceName = 'docker-registry';
|
||||
} else {
|
||||
$tempServiceName = $serviceName;
|
||||
}
|
||||
if (str(data_get($service, 'image'))->contains('glitchtip')) {
|
||||
$tempServiceName = 'glitchtip';
|
||||
}
|
||||
if ($serviceName === 'supabase-kong') {
|
||||
$tempServiceName = 'supabase';
|
||||
}
|
||||
$serviceDefinition = data_get($allServices, $tempServiceName);
|
||||
$predefinedPort = data_get($serviceDefinition, 'port');
|
||||
if ($serviceName === 'plausible') {
|
||||
$predefinedPort = '8000';
|
||||
}
|
||||
$predefinedPort = $resource->getRequiredPort();
|
||||
|
||||
if ($migratedApp || $migratedDb) {
|
||||
// Use the already determined migrated service
|
||||
|
|
@ -2642,7 +2606,7 @@ function serviceParser(Service $resource): Collection
|
|||
? data_get($originalResource, 'redirect')
|
||||
: 'both';
|
||||
$onlyPort = $originalResource instanceof ServiceApplication
|
||||
? ($originalResource->getRequiredPort() ?? $predefinedPort)
|
||||
? $originalResource->getRequiredPort()
|
||||
: $predefinedPort;
|
||||
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
|
||||
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
|
||||
|
|
@ -2677,7 +2641,7 @@ function serviceParser(Service $resource): Collection
|
|||
service_name: $serviceName,
|
||||
image: $image,
|
||||
onlyPort: $onlyPort,
|
||||
predefinedPort: $predefinedPort,
|
||||
predefinedPort: $onlyPort,
|
||||
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
|
|
@ -2710,7 +2674,7 @@ function serviceParser(Service $resource): Collection
|
|||
service_name: $serviceName,
|
||||
image: $image,
|
||||
onlyPort: $onlyPort,
|
||||
predefinedPort: $predefinedPort,
|
||||
predefinedPort: $onlyPort,
|
||||
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection
|
||||
|
|
|
|||
|
|
@ -2486,7 +2486,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
} catch (Exception $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
$allServices = get_service_templates();
|
||||
$topLevelVolumes = collect(data_get($yaml, 'volumes', []));
|
||||
$topLevelNetworks = collect(data_get($yaml, 'networks', []));
|
||||
$topLevelConfigs = collect(data_get($yaml, 'configs', []));
|
||||
|
|
@ -2512,25 +2511,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
}
|
||||
$topLevelVolumes = collect($tempTopLevelVolumes);
|
||||
}
|
||||
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) {
|
||||
// Workarounds for beta users.
|
||||
if ($serviceName === 'registry') {
|
||||
$tempServiceName = 'docker-registry';
|
||||
} else {
|
||||
$tempServiceName = $serviceName;
|
||||
}
|
||||
if (str(data_get($service, 'image'))->contains('glitchtip')) {
|
||||
$tempServiceName = 'glitchtip';
|
||||
}
|
||||
if ($serviceName === 'supabase-kong') {
|
||||
$tempServiceName = 'supabase';
|
||||
}
|
||||
$serviceDefinition = data_get($allServices, $tempServiceName);
|
||||
$predefinedPort = data_get($serviceDefinition, 'port');
|
||||
if ($serviceName === 'plausible') {
|
||||
$predefinedPort = '8000';
|
||||
}
|
||||
// End of workarounds for beta users.
|
||||
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $envComments) {
|
||||
$predefinedPort = $resource->getRequiredPort();
|
||||
$serviceVolumes = collect(data_get($service, 'volumes', []));
|
||||
$servicePorts = collect(data_get($service, 'ports', []));
|
||||
$serviceNetworks = collect(data_get($service, 'networks', []));
|
||||
|
|
@ -3107,7 +3089,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
? ($savedService->domain_port_overrides ?? [])
|
||||
: [];
|
||||
$onlyPort = $savedService instanceof ServiceApplication
|
||||
? ($savedService->getRequiredPort() ?? $predefinedPort)
|
||||
? $savedService->getRequiredPort()
|
||||
: $predefinedPort;
|
||||
if ($shouldGenerateLabelsExactly) {
|
||||
switch ($resource->server->proxyType()) {
|
||||
|
|
@ -3139,7 +3121,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
service_name: $serviceName,
|
||||
image: data_get($service, 'image'),
|
||||
onlyPort: $onlyPort,
|
||||
predefinedPort: $predefinedPort,
|
||||
predefinedPort: $onlyPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
|
|
@ -3172,7 +3154,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
service_name: $serviceName,
|
||||
image: data_get($service, 'image'),
|
||||
onlyPort: $onlyPort,
|
||||
predefinedPort: $predefinedPort,
|
||||
predefinedPort: $onlyPort,
|
||||
noindex_domains: $noindexDomains,
|
||||
redirect_direction: $redirectDirection,
|
||||
domainPortOverrides: $domainPortOverrides,
|
||||
|
|
@ -3318,10 +3300,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
if ($pull_request_id !== 0) {
|
||||
$definedNetwork = collect(["{$resource->uuid}-$pull_request_id"]);
|
||||
}
|
||||
$usesSharedApplicationPort = collect($services)
|
||||
->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image')))
|
||||
->count() === 1;
|
||||
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id, $usesSharedApplicationPort) {
|
||||
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id) {
|
||||
$serviceVolumes = collect(data_get($service, 'volumes', []));
|
||||
$servicePorts = collect(data_get($service, 'ports', []));
|
||||
$serviceNetworks = collect(data_get($service, 'networks', []));
|
||||
|
|
@ -3918,9 +3897,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
$domainPortOverrides = $pull_request_id === 0
|
||||
? ($resource->domain_port_overrides ?? [])
|
||||
: ($preview?->domain_port_overrides ?? []);
|
||||
$exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array;
|
||||
$onlyPort = firstDockerComposeServicePort($service)
|
||||
?? ($usesSharedApplicationPort ? ($exposedPorts[0] ?? null) : null);
|
||||
$onlyPort = firstDockerComposeServicePort($service);
|
||||
if ($shouldGenerateLabelsExactly) {
|
||||
switch ($server->proxyType()) {
|
||||
case ProxyTypes::TRAEFIK->value:
|
||||
|
|
|
|||
|
|
@ -2524,7 +2524,7 @@
|
|||
'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_raw' => "services:\n web:\n image: nginx:alpine\n expose: [8080]\n api:\n image: node:alpine\n",
|
||||
'docker_compose_domains' => json_encode([
|
||||
'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'],
|
||||
]),
|
||||
|
|
@ -2773,3 +2773,117 @@ function applicationDomainPortOverrideApiToken(User $user, Team $team): string
|
|||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
});
|
||||
|
||||
it('does not inherit the application port for single-service compose domains', function (string $extraService, bool $isPreview, bool $isStatic) {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'ports_exposes' => '3000',
|
||||
'docker_compose_raw' => "services:\n web:\n image: httpd:2.4-alpine\n".$extraService,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => ['domain' => 'https://apache.example.com'],
|
||||
]),
|
||||
'fqdn' => null,
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
$this->application->settings()->update(['is_static' => $isStatic]);
|
||||
|
||||
if ($isPreview) {
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $this->application->id,
|
||||
'pull_request_id' => 1,
|
||||
'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/1',
|
||||
'docker_compose_domains' => $this->application->docker_compose_domains,
|
||||
]);
|
||||
$component = Livewire::test(PreviewDomains::class, ['preview' => $preview]);
|
||||
} else {
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
|
||||
}
|
||||
|
||||
$component->assertSet('domainRows.0.internal_port', null)
|
||||
->assertSet('domainRows.0.has_port_override', false)
|
||||
->assertDontSee('Internal port 3000');
|
||||
})->with([
|
||||
'web only' => '',
|
||||
'web and database' => " database:\n image: postgres:16-alpine\n",
|
||||
])->with([false, true])->with([false, true]);
|
||||
|
||||
it('checks compose ports against the selected service when saving domains', function (string $action, int $port, bool $warn) {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'ports_exposes' => '3000',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
expose:
|
||||
- 8080
|
||||
- 8081
|
||||
ports:
|
||||
- target: 8082
|
||||
published: 18082
|
||||
- "18083:8083"
|
||||
api:
|
||||
image: nginx:alpine
|
||||
expose:
|
||||
- 9090
|
||||
YAML,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => ['domain' => 'https://existing.example.com'],
|
||||
'api' => ['domain' => 'https://api.example.com:9090'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
|
||||
if ($action === 'add') {
|
||||
$component->set('newDomainService', 'web')
|
||||
->set('newDomainParts.host', 'new.example.com')
|
||||
->set('newDomainParts.port', (string) $port)
|
||||
->call('addDomain');
|
||||
$domain = 'https://new.example.com';
|
||||
} else {
|
||||
$component->call('startEdit', 0)
|
||||
->set('editingDomainParts.port', (string) $port)
|
||||
->call('updateDomain');
|
||||
$domain = 'https://existing.example.com';
|
||||
}
|
||||
|
||||
$component->assertHasNoErrors()->assertSet('showPortWarningModal', $warn);
|
||||
|
||||
if ($warn) {
|
||||
expect($this->application->fresh()->domain_port_overrides ?? [])->not->toHaveKey($domain);
|
||||
$component->assertSet('unrecognizedPort', $port)
|
||||
->call('confirmUseUnknownPort')
|
||||
->assertSet('showPortWarningModal', false);
|
||||
}
|
||||
|
||||
$component->assertDispatched('success');
|
||||
expect($this->application->fresh()->domain_port_overrides[$domain] ?? null)->toBe($port);
|
||||
})->with(['add', 'edit'])->with([
|
||||
'first exposed port' => [8080, false],
|
||||
'second exposed port' => [8081, false],
|
||||
'long syntax target' => [8082, false],
|
||||
'short syntax target' => [8083, false],
|
||||
'global port' => [3000, true],
|
||||
'another service port' => [9090, true],
|
||||
'published host port' => [18082, true],
|
||||
]);
|
||||
|
||||
it('keeps an existing custom compose port without another warning', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'ports_exposes' => '3000',
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [8080]\n",
|
||||
'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://existing.example.com:7070']]),
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '7070')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->fresh()->domain_port_overrides['https://existing.example.com'] ?? null)->toBe(7070);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
|
|
@ -570,17 +571,19 @@ function disableExactProxyLabels(Application $application): Application
|
|||
->toBeTrue();
|
||||
});
|
||||
|
||||
test('applicationParser compose labels use the first ports_exposes value when a portless domain has no override', function () {
|
||||
test('single-service compose leaves undeclared ports to proxy discovery', function (int $parserVersion, string $extraService, bool $isPreview) {
|
||||
$application = disableExactProxyLabels(Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'compose_parsing_version' => $parserVersion,
|
||||
'ports_exposes' => '3000,8080',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
'docker_compose_raw' => <<<YAML
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
image: httpd:2.4-alpine
|
||||
{$extraService}
|
||||
YAML,
|
||||
'fqdn' => null,
|
||||
'domain_port_overrides' => null,
|
||||
|
|
@ -589,16 +592,29 @@ function disableExactProxyLabels(Application $application): Application
|
|||
]),
|
||||
]));
|
||||
|
||||
$parsedCompose = applicationParser($application->fresh());
|
||||
$labels = collect(data_get($parsedCompose, 'services.frontend.labels'));
|
||||
$preview = $isPreview ? ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 1,
|
||||
'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/1',
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]) : null;
|
||||
|
||||
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}}')))
|
||||
$parsedCompose = $application->fresh()->parse(
|
||||
pull_request_id: $preview?->pull_request_id ?? 0,
|
||||
preview_id: $preview?->id,
|
||||
);
|
||||
$labels = collect(data_get($parsedCompose, 'services'))->flatMap(fn ($service) => data_get($service, 'labels', []));
|
||||
|
||||
expect($labels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port=')))
|
||||
->toBeFalse()
|
||||
->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams}}')))
|
||||
->toBeTrue()
|
||||
->and($labels->contains(fn (string $label): bool => str_contains($label, 'Host(`frontend.example.com`)')))
|
||||
->toBeTrue();
|
||||
});
|
||||
})->with([2, 3])->with([
|
||||
'web only' => '',
|
||||
'web and database' => " database:\n image: postgres:16-alpine",
|
||||
])->with([false, true]);
|
||||
|
||||
test('applicationParser compose labels prefer the service exposed port over application ports_exposes', function (string $portConfiguration) {
|
||||
$application = disableExactProxyLabels(Application::factory()->create([
|
||||
|
|
|
|||
19
tests/Feature/DockerComposeHttpPortsTest.php
Normal file
19
tests/Feature/DockerComposeHttpPortsTest.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
it('selects only TCP container ports for HTTP routing and confirmation', function (array $service, array $expected) {
|
||||
$compose = Yaml::dump(['services' => ['web' => $service]]);
|
||||
|
||||
expect(firstDockerComposeServicePort($service))->toBe($expected[0] ?? null)
|
||||
->and(dockerComposeServicePort($compose, 'web'))->toBe($expected[0] ?? null)
|
||||
->and(dockerComposeServicePorts($compose, 'web'))->toBe($expected);
|
||||
})->with([
|
||||
'expose UDP first' => [['expose' => ['53/udp', '8080/tcp']], [8080]],
|
||||
'published UDP first' => [['ports' => ['53:53/udp', '18080:8080/tcp']], [8080]],
|
||||
'long syntax UDP first' => [['ports' => [['target' => 53, 'protocol' => 'udp'], ['target' => 8080, 'published' => 18080, 'protocol' => 'tcp']]], [8080]],
|
||||
'implicit TCP' => [['expose' => [80], 'ports' => [['target' => 8080], '18081:8081']], [80, 8080, 8081]],
|
||||
'UDP only' => [['expose' => ['53/udp'], 'ports' => [['target' => 67, 'protocol' => 'udp']]], []],
|
||||
'other protocol' => [['ports' => ['9000:9000/sctp', '8080']], [8080]],
|
||||
'duplicates' => [['expose' => ['8080/udp', '8080/tcp'], 'ports' => ['18080:8080']], [8080]],
|
||||
]);
|
||||
94
tests/Feature/LegacyServiceRoutingPortTest.php
Normal file
94
tests/Feature/LegacyServiceRoutingPortTest.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('only preserves an unambiguous saved upstream for the same legacy container', function (array $labels, ?int $expected, array $rawChanges = [], array $savedChanges = [], ?string $type = null) {
|
||||
$raw = array_replace(['image' => 'httpd:2.4-alpine', 'environment' => ['SERVICE_URL_WEB']], $rawChanges);
|
||||
$saved = array_replace(['image' => 'httpd:2.4-alpine', 'labels' => $labels], $savedChanges);
|
||||
$service = new Service;
|
||||
$service->forceFill([
|
||||
'service_type' => $type,
|
||||
'docker_compose_raw' => Yaml::dump(['services' => ['web' => $raw]]),
|
||||
'docker_compose' => Yaml::dump(['services' => ['web' => $saved, 'other' => ['image' => 'httpd:2.4-alpine', 'labels' => ['traefik.http.services.other.loadbalancer.server.port=9000']]]]),
|
||||
]);
|
||||
$application = new ServiceApplication(['name' => 'web']);
|
||||
$application->setRelation('service', $service);
|
||||
|
||||
expect($application->getRequiredPort())->toBe($expected);
|
||||
})->with([
|
||||
'Traefik list' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 3000],
|
||||
'Traefik map' => [['traefik.http.services.web.loadbalancer.server.port' => '3000'], 3000],
|
||||
'Caddy list' => [['caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}'], 3000],
|
||||
'Caddy map' => [['caddy_0.handle_path.0_reverse_proxy' => '{{upstreams 3000}}'], 3000],
|
||||
'matching proxies' => [['traefik.http.services.web.loadbalancer.server.port=3000', 'caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}'], 3000],
|
||||
'conflicting proxies' => [['traefik.http.services.web.loadbalancer.server.port=3000', 'caddy_0.handle_path.0_reverse_proxy={{upstreams 4000}}'], null],
|
||||
'multiple Traefik ports' => [['traefik.http.services.web.loadbalancer.server.port=3000', 'traefik.http.services.api.loadbalancer.server.port=4000'], null],
|
||||
'malformed port' => [['traefik.http.services.web.loadbalancer.server.port=3000oops'], null],
|
||||
'out of range' => [['traefik.http.services.web.loadbalancer.server.port=65536'], null],
|
||||
'negative' => [['traefik.http.services.web.loadbalancer.server.port=-1'], null],
|
||||
'mixed malformed' => [['traefik.http.services.web.loadbalancer.server.port=3000', 'caddy_0.handle_path.0_reverse_proxy=oops'], null],
|
||||
'map bare FQDN' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 3000, ['environment' => ['SERVICE_FQDN_WEB' => null]]],
|
||||
'UDP only preserves HTTP upstream' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 3000, ['expose' => ['53/udp']]],
|
||||
'zero' => [['traefik.http.services.web.loadbalancer.server.port=0'], null],
|
||||
'malformed Caddy' => [['caddy_0.handle_path.0_reverse_proxy={{upstreams 3000oops}}'], null],
|
||||
'discovery Caddy' => [['caddy_0.handle_path.0_reverse_proxy={{upstreams}}'], null],
|
||||
'no cross container' => [[], null],
|
||||
'changed image' => [['traefik.http.services.web.loadbalancer.server.port=3000'], null, [], ['image' => 'httpd:latest']],
|
||||
'missing saved image' => [['traefik.http.services.web.loadbalancer.server.port=3000'], null, [], ['image' => null]],
|
||||
'no direct declaration' => [['traefik.http.services.web.loadbalancer.server.port=3000'], null, ['environment' => ['URL=${SERVICE_URL_WEB}']]],
|
||||
'explicit magic wins' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 8080, ['environment' => ['SERVICE_URL_WEB_8080']]],
|
||||
'Compose port wins' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 8080, ['expose' => [8080]]],
|
||||
'known template excluded' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 80, [], [], 'wordpress-without-database'],
|
||||
]);
|
||||
|
||||
it('preserves legacy upstreams across repeated parsing without adding public ports', function (int $version, ?int $override) {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$server->settings->update(['generate_exact_labels' => false]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$service = Service::factory()->create([
|
||||
'name' => 'legacy-stack',
|
||||
'service_type' => null,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n web:\n image: httpd:2.4-alpine\n environment:\n - SERVICE_URL_WEB\n",
|
||||
'docker_compose' => Yaml::dump(['services' => ['web' => ['image' => 'httpd:2.4-alpine', 'labels' => ['traefik.http.services.web.loadbalancer.server.port=3000']]]]),
|
||||
'compose_parsing_version' => (string) $version,
|
||||
]);
|
||||
ServiceApplication::create([
|
||||
'name' => 'web',
|
||||
'service_id' => $service->id,
|
||||
'image' => 'httpd:2.4-alpine',
|
||||
'fqdn' => 'https://legacy.example.com',
|
||||
'domain_port_overrides' => $override === null ? null : ['https://legacy.example.com' => $override],
|
||||
]);
|
||||
|
||||
for ($iteration = 0; $iteration < 2; $iteration++) {
|
||||
$parsed = $service->fresh()->parse();
|
||||
$labels = collect($parsed['services']['web']['labels']);
|
||||
$ports = $labels->filter(fn ($label) => str_contains($label, '.loadbalancer.server.port='));
|
||||
expect($ports)->not->toBeEmpty();
|
||||
foreach ($ports as $port) {
|
||||
expect($port)->toEndWith('='.($override ?? 3000));
|
||||
}
|
||||
expect($labels->filter(fn ($label) => str_contains($label, 'reverse_proxy'))->implode(' '))->toContain('{{upstreams '.($override ?? 3000).'}}');
|
||||
expect($service->fresh()->applications()->first()->fqdn)->toBe('https://legacy.example.com');
|
||||
}
|
||||
})->with([4, 5])->with([null, 8080]);
|
||||
|
|
@ -269,7 +269,7 @@ function createPreviewForPortTests(Application $application, int $pullRequestId,
|
|||
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_raw' => "services:\n web:\n image: nginx:alpine\n expose: [8080]\n api:\n image: nginx:alpine\n expose: [3000]\n",
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
|
||||
|
|
@ -537,7 +537,7 @@ function createPreviewForPortTests(Application $application, int $pullRequestId,
|
|||
->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses ports_exposes as the compose preview fallback when a domain has no override', function () {
|
||||
it('leaves single-service compose preview ports to proxy discovery when a domain has no override', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'compose_parsing_version' => '3',
|
||||
|
|
@ -565,9 +565,9 @@ function createPreviewForPortTests(Application $application, int $pullRequestId,
|
|||
$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}}')))
|
||||
expect($labels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port=')))
|
||||
->toBeFalse()
|
||||
->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams}}')))
|
||||
->toBeTrue();
|
||||
});
|
||||
|
||||
|
|
@ -672,3 +672,91 @@ function createPreviewForPortTests(Application $application, int $pullRequestId,
|
|||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
});
|
||||
|
||||
it('checks compose preview ports against the selected service when saving domains', function (string $action, int $port, bool $warn) {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'ports_exposes' => '3000',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
expose:
|
||||
- 8080
|
||||
- 8081
|
||||
ports:
|
||||
- target: 8082
|
||||
published: 18082
|
||||
- "18083:8083"
|
||||
api:
|
||||
image: nginx:alpine
|
||||
expose:
|
||||
- 9090
|
||||
YAML,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => ['domain' => 'https://existing.example.com'],
|
||||
'api' => ['domain' => 'https://api.example.com:9090'],
|
||||
]),
|
||||
]);
|
||||
$preview = createPreviewForPortTests($this->application, 130, [
|
||||
'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://existing.example.com']]),
|
||||
'fqdn' => 'https://existing.example.com',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(PreviewDomains::class, ['preview' => $preview]);
|
||||
if ($action === 'add') {
|
||||
$component->set('newDomainService', 'web')
|
||||
->set('newDomainParts.host', 'new.example.com')
|
||||
->set('newDomainParts.port', (string) $port)
|
||||
->call('addDomain');
|
||||
$domain = 'https://new.example.com';
|
||||
} else {
|
||||
$component->call('startEdit', 0)
|
||||
->set('editingDomainParts.port', (string) $port)
|
||||
->call('updateDomain');
|
||||
$domain = 'https://existing.example.com';
|
||||
}
|
||||
|
||||
$component->assertHasNoErrors()->assertSet('showPortWarningModal', $warn);
|
||||
|
||||
if ($warn) {
|
||||
expect($preview->fresh()->domain_port_overrides ?? [])->not->toHaveKey($domain);
|
||||
$component->assertSet('unrecognizedPort', $port)
|
||||
->call('confirmUseUnknownPort')
|
||||
->assertSet('showPortWarningModal', false);
|
||||
}
|
||||
|
||||
$component->assertDispatched('success');
|
||||
expect($preview->fresh()->domain_port_overrides[$domain] ?? null)->toBe($port);
|
||||
})->with(['add', 'edit'])->with([
|
||||
'first exposed port' => [8080, false],
|
||||
'second exposed port' => [8081, false],
|
||||
'long syntax target' => [8082, false],
|
||||
'short syntax target' => [8083, false],
|
||||
'global port' => [3000, true],
|
||||
'another service port' => [9090, true],
|
||||
'published host port' => [18082, true],
|
||||
]);
|
||||
|
||||
it('keeps an existing custom compose preview port without another warning', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'ports_exposes' => '3000',
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [8080]\n",
|
||||
'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://existing.example.com:7070']]),
|
||||
]);
|
||||
$preview = createPreviewForPortTests($this->application, 131, [
|
||||
'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://existing.example.com:7070']]),
|
||||
'fqdn' => 'https://existing.example.com:7070',
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '7070')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($preview->fresh()->domain_port_overrides['https://existing.example.com'] ?? null)->toBe(7070);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
// Create service with a name that maps to a template with required port
|
||||
$this->service = Service::factory()->create([
|
||||
'name' => 'supabase-test123',
|
||||
'service_type' => 'supabase',
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
|
|
@ -99,6 +100,67 @@ function get_service_templates_mock()
|
|||
->assertOk();
|
||||
});
|
||||
|
||||
it('preserves a legacy embedded port when only the description changes', function () {
|
||||
ServiceApplication::query()->whereKey($this->serviceApplication->id)->update([
|
||||
'fqdn' => 'http://example.com:8080',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(Index::class, [
|
||||
'serviceApplication' => $this->serviceApplication->fresh(),
|
||||
])
|
||||
->set('description', 'Updated description')
|
||||
->call('submitApplication')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertSet('fqdn', 'http://example.com:8080');
|
||||
|
||||
expect($this->serviceApplication->fresh())
|
||||
->description->toBe('Updated description')
|
||||
->fqdn->toBe('http://example.com')
|
||||
->domain_port_overrides->toBe(['http://example.com' => 8080]);
|
||||
});
|
||||
|
||||
it('loads a legacy embedded port in the domain editor', function () {
|
||||
ServiceApplication::query()->whereKey($this->serviceApplication->id)->update([
|
||||
'fqdn' => 'http://example.com:8080',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
|
||||
->assertSet('fqdn', 'http://example.com:8080')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('prefers a persisted override over a legacy embedded port', function () {
|
||||
ServiceApplication::query()->whereKey($this->serviceApplication->id)->update([
|
||||
'fqdn' => 'http://example.com:8080',
|
||||
'domain_port_overrides' => json_encode(['http://example.com' => 3000]),
|
||||
]);
|
||||
|
||||
expect($this->serviceApplication->fresh()->url)->toBe('http://example.com:3000');
|
||||
});
|
||||
|
||||
it('allows explicit removal of a legacy embedded port', function () {
|
||||
ServiceApplication::query()->whereKey($this->serviceApplication->id)->update([
|
||||
'fqdn' => 'http://example.com:8080',
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
|
||||
->set('fqdn', 'http://example.com')
|
||||
->call('submit')
|
||||
->assertSet('showPortWarningModal', true)
|
||||
->call('confirmRemovePort')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('showPortWarningModal', false);
|
||||
|
||||
expect($this->serviceApplication->fresh())
|
||||
->fqdn->toBe('http://example.com')
|
||||
->url->toBe('http://example.com')
|
||||
->domain_port_overrides->toBeNull();
|
||||
});
|
||||
|
||||
it('initializes route state when mounting a service application directly', function () {
|
||||
Livewire::test(Index::class, [
|
||||
'serviceApplication' => $this->serviceApplication,
|
||||
|
|
|
|||
62
tests/Feature/ServiceRoutingPortTest.php
Normal file
62
tests/Feature/ServiceRoutingPortTest.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('does not infer a custom service port from a coincidental template name', function (int $version, string $name, ?string $type, string $declarations, ?int $expected) {
|
||||
expect(config('database.connections.'.config('database.default').'.driver'))->toBe('sqlite');
|
||||
expect(config('database.connections.'.config('database.default').'.database'))->toBe(':memory:');
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$server->settings->update(['generate_exact_labels' => false]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$service = Service::factory()->create([
|
||||
'name' => 'custom-stack',
|
||||
'service_type' => $type,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n {$name}:\n image: httpd:2.4-alpine\n{$declarations}",
|
||||
'compose_parsing_version' => (string) $version,
|
||||
]);
|
||||
ServiceApplication::create([
|
||||
'name' => $name,
|
||||
'service_id' => $service->id,
|
||||
'image' => 'httpd:2.4-alpine',
|
||||
'fqdn' => 'https://apache.example.com',
|
||||
]);
|
||||
$parsed = $service->fresh()->parse();
|
||||
$labels = collect(data_get($parsed, 'services'))->flatMap(fn ($entry) => data_get($entry, 'labels', []));
|
||||
$ports = $labels->filter(fn ($label) => str_contains($label, '.loadbalancer.server.port='))->values()->all();
|
||||
if ($expected === null) {
|
||||
expect($ports)->toBeEmpty();
|
||||
expect($labels->filter(fn ($label) => str_contains($label, 'reverse_proxy'))->implode(' '))->not->toContain('upstreams 3000');
|
||||
} else {
|
||||
expect($ports)->not->toBeEmpty();
|
||||
foreach ($ports as $port) {
|
||||
expect($port)->toEndWith('.loadbalancer.server.port='.$expected);
|
||||
}
|
||||
}
|
||||
})->with([4, 5])->with([
|
||||
'UDP before HTTP' => ['web', null, " expose: ['53/udp', '8080/tcp']\n", 8080],
|
||||
'UDP only discovery' => ['web', null, " expose: ['53/udp']\n", null],
|
||||
'custom collision' => ['grafana', null, " expose: [80]\n", 80],
|
||||
'custom discovery' => ['grafana', null, '', null],
|
||||
'unrelated template child' => ['web', 'grafana', '', null],
|
||||
'real template' => ['wordpress', 'wordpress-without-database', " environment:\n - SERVICE_URL_WORDPRESS\n", 80],
|
||||
'explicit magic port' => ['web', null, " expose: [80]\n environment:\n - SERVICE_URL_WEB_8080\n", 8080],
|
||||
]);
|
||||
|
|
@ -25,3 +25,9 @@
|
|||
|
||||
expect($app->getRequiredPort())->toBe(80);
|
||||
});
|
||||
|
||||
it('does not infer a template port from a custom display name', function () {
|
||||
$service = new Service(['name' => 'grafana-custom', 'service_type' => null]);
|
||||
|
||||
expect($service->getRequiredPort())->toBeNull();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue