fix: avoid inherited compose ports and defer archive inspection
Prevent multi-service Compose domains from inheriting the application port, and defer PostgreSQL custom-format archive inspection to pg_restore.
This commit is contained in:
parent
16295e1ab3
commit
208f7720bc
8 changed files with 130 additions and 14 deletions
|
|
@ -569,6 +569,13 @@ protected function effectiveDomainInternalPort(string $url, ?string $service = n
|
|||
];
|
||||
}
|
||||
|
||||
if ($this->isCompose && $service !== null && count($this->composeServices) > 1) {
|
||||
return [
|
||||
'internal_port' => null,
|
||||
'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]
|
||||
|
|
|
|||
|
|
@ -537,6 +537,13 @@ private function effectiveDomainInternalPort(string $url, ?string $service = nul
|
|||
];
|
||||
}
|
||||
|
||||
if ($this->preview->application->build_pack === 'dockercompose' && $service !== null && count($this->composeServices()) > 1) {
|
||||
return [
|
||||
'internal_port' => null,
|
||||
'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]
|
||||
|
|
|
|||
|
|
@ -90,11 +90,8 @@ public static function fileContainsPostgresqlProgramExecution(string $path): boo
|
|||
|
||||
public static function containsPostgresqlProgramExecution(string $sql): bool
|
||||
{
|
||||
$requireStatementBoundary = true;
|
||||
|
||||
if (str_starts_with($sql, 'PGDMP')) {
|
||||
$sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql;
|
||||
$requireStatementBoundary = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
$withoutComments = self::stripSqlComments($sql);
|
||||
|
|
@ -103,9 +100,7 @@ public static function containsPostgresqlProgramExecution(string $sql): bool
|
|||
return true;
|
||||
}
|
||||
|
||||
$copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b';
|
||||
|
||||
return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
|
||||
return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
|
||||
}
|
||||
|
||||
private static function extensionFor(string $name): ?string
|
||||
|
|
|
|||
|
|
@ -390,6 +390,9 @@ 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', [])),
|
||||
|
|
@ -1355,7 +1358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
? ($previewForPorts?->domain_port_overrides ?? [])
|
||||
: ($originalResource->domain_port_overrides ?? []);
|
||||
$exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array;
|
||||
$onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null);
|
||||
$onlyPort = firstDockerComposeServicePort($service)
|
||||
?? ($applicationServiceCount === 1 ? ($exposedPorts[0] ?? null) : null);
|
||||
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
|
||||
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3318,7 +3318,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
if ($pull_request_id !== 0) {
|
||||
$definedNetwork = collect(["{$resource->uuid}-$pull_request_id"]);
|
||||
}
|
||||
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_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) {
|
||||
$serviceVolumes = collect(data_get($service, 'volumes', []));
|
||||
$servicePorts = collect(data_get($service, 'ports', []));
|
||||
$serviceNetworks = collect(data_get($service, 'networks', []));
|
||||
|
|
@ -3916,7 +3919,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
? ($resource->domain_port_overrides ?? [])
|
||||
: ($preview?->domain_port_overrides ?? []);
|
||||
$exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array;
|
||||
$onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null);
|
||||
$onlyPort = firstDockerComposeServicePort($service)
|
||||
?? ($usesSharedApplicationPort ? ($exposedPorts[0] ?? null) : null);
|
||||
if ($shouldGenerateLabelsExactly) {
|
||||
switch ($server->proxyType()) {
|
||||
case ProxyTypes::TRAEFIK->value:
|
||||
|
|
|
|||
|
|
@ -2436,6 +2436,25 @@
|
|||
->assertDontSee('Internal port 3000');
|
||||
});
|
||||
|
||||
it('does not show an application port as the inherited port for a compose service without a declared port', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'ports_exposes' => '3000',
|
||||
'docker_compose_raw' => "services:\n backend:\n build: ./backend\n frontend:\n build: ./frontend\n",
|
||||
'docker_compose_domains' => json_encode([
|
||||
'backend' => ['domain' => 'https://api.example.com'],
|
||||
'frontend' => ['domain' => 'https://app.example.com'],
|
||||
]),
|
||||
'fqdn' => null,
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSet('domainRows.0.internal_port', null)
|
||||
->assertSet('domainRows.1.internal_port', null)
|
||||
->assertDontSee('Internal port 3000');
|
||||
});
|
||||
|
||||
it('shows the detected compose service port for preview domains', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
|
|
@ -2459,6 +2478,27 @@
|
|||
->assertDontSee('Internal port 3000');
|
||||
});
|
||||
|
||||
it('does not show an application port for a preview compose service without a declared port', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'ports_exposes' => '3000',
|
||||
'docker_compose_raw' => "services:\n backend:\n build: ./backend\n frontend:\n build: ./frontend\n",
|
||||
]);
|
||||
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $this->application->id,
|
||||
'pull_request_id' => 8070,
|
||||
'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/8070',
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://preview.example.com'],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->assertSet('domainRows.0.internal_port', null)
|
||||
->assertDontSee('Internal port 3000');
|
||||
});
|
||||
|
||||
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',
|
||||
|
|
@ -2514,6 +2554,31 @@
|
|||
->toHaveKey('https://api.example.com', 4000);
|
||||
});
|
||||
|
||||
it('saves an unrecognized compose domain port after confirming the warning', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'fqdn' => null,
|
||||
'ports_exposes' => '3000',
|
||||
'docker_compose_raw' => "services:\n frontend:\n build: ./frontend\n",
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://app.example.com'],
|
||||
]),
|
||||
'domain_port_overrides' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->set('editingDomainParts.port', '80')
|
||||
->call('updateDomain')
|
||||
->assertSet('showPortWarningModal', true)
|
||||
->call('confirmUseUnknownPort')
|
||||
->assertSet('showPortWarningModal', false)
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->fresh()->domain_port_overrides)
|
||||
->toBe(['https://app.example.com' => 80]);
|
||||
});
|
||||
|
||||
it('prunes a compose domain port override when that domain is removed', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
|
|
|
|||
|
|
@ -631,3 +631,37 @@ function disableExactProxyLabels(Application $application): Application
|
|||
'short port syntax' => " ports:\n - '18069:8069'",
|
||||
'long port syntax' => " ports:\n - target: 8069\n published: 18069",
|
||||
]);
|
||||
|
||||
test('applicationParser does not apply an application port to compose services without a declared port', 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',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
backend:
|
||||
build: ./backend
|
||||
frontend:
|
||||
build: ./frontend
|
||||
YAML,
|
||||
'fqdn' => null,
|
||||
'domain_port_overrides' => null,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'backend' => ['domain' => 'https://api.example.com'],
|
||||
'frontend' => ['domain' => 'https://app.example.com'],
|
||||
]),
|
||||
]));
|
||||
|
||||
$services = data_get(applicationParser($application->fresh()), 'services');
|
||||
$backendLabels = collect(data_get($services, 'backend.labels'));
|
||||
$frontendLabels = collect(data_get($services, 'frontend.labels'));
|
||||
|
||||
expect($backendLabels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port=')))
|
||||
->toBeFalse()
|
||||
->and($frontendLabels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port=')))
|
||||
->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -220,16 +220,16 @@ public function getMorphClass(): string
|
|||
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzClean))->toBeFalse();
|
||||
});
|
||||
|
||||
test('file scanner detects program execution payloads inside custom format archives', function () {
|
||||
test('file scanner defers custom format archives to pg_restore inspection', function () {
|
||||
$archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload");
|
||||
|
||||
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeTrue();
|
||||
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse();
|
||||
});
|
||||
|
||||
test('file scanner detects program execution payloads inside gzipped custom format archives', function () {
|
||||
test('file scanner defers gzipped custom format archives to pg_restore inspection', function () {
|
||||
$archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload", gzip: true);
|
||||
|
||||
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeTrue();
|
||||
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse();
|
||||
});
|
||||
|
||||
test('file scanner allows custom format archives without program execution', function () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue