mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 20:14:45 +00:00
fix(compose): normalize service-name keys for domains and env vars (#11040)
This commit is contained in:
1 parent
7b18777f06
commit
0b843bb07c
18 files changed
+1443
-220
No files matched your search
@@ -211,6 +211,86 @@ YAML;
|
||||
->and($domains['backend']['domain'])->toStartWith('http://');
|
||||
});
|
||||
|
||||
test('applicationParser stores domains under original hyphenated compose service names', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
another-service:
|
||||
image: myapp/api:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_ANOTHER_SERVICE=${API_URL}
|
||||
analytics:
|
||||
image: myapp/analytics:latest
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
|
||||
expect($domains)->toBeArray()
|
||||
->and($domains)->toHaveKey('another-service')
|
||||
->and($domains)->not->toHaveKey('another_service')
|
||||
->and($domains['another-service']['domain'])->toStartWith('http://');
|
||||
});
|
||||
|
||||
test('applicationParser preserves legacy underscore domain keys by matching hyphenated services', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
another-service:
|
||||
image: myapp/api:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_ANOTHER_SERVICE=${API_URL}
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
]),
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
|
||||
// Existing domain is preserved (not overwritten) even when stored under legacy underscore key.
|
||||
expect(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com');
|
||||
});
|
||||
|
||||
test('compose domain reconciliation preserves stored domains when parsing returns no services', function () {
|
||||
$storedDomains = json_encode([
|
||||
'frontend' => ['domain' => 'https://frontend.example.com'],
|
||||
]);
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => $storedDomains,
|
||||
]);
|
||||
|
||||
$method = new ReflectionMethod($application, 'reconcileDockerComposeDomains');
|
||||
$method->invoke($application, ['services' => []]);
|
||||
|
||||
expect($application->fresh()->docker_compose_domains)->toBe($storedDomains);
|
||||
});
|
||||
|
||||
test('applicationParser handles other docker compose domain shapes without regressions', function () {
|
||||
$createApplication = function (string $dockerCompose, ?string $dockerComposeDomains = null): Application {
|
||||
return Application::factory()->create([
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -57,6 +62,52 @@ it('populates fqdn with multiple domains from multiple services', function () {
|
||||
expect($preview->fqdn)->toContain('api.example.com');
|
||||
});
|
||||
|
||||
it('preserves distinct services whose normalized names collide when generating preview domains', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
api-test:
|
||||
image: nginx:alpine
|
||||
api.test:
|
||||
image: nginx:alpine
|
||||
YAML;
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::query()->where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'build_pack' => 'dockercompose',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'api-test' => ['domain' => 'https://hyphen.example.com'],
|
||||
'api.test' => ['domain' => 'https://dot.example.com'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 17,
|
||||
'pull_request_html_url' => 'https://github.com/example/repo/pull/17',
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
|
||||
$preview->generate_preview_fqdn_compose();
|
||||
$preview->refresh();
|
||||
|
||||
$domains = json_decode($preview->docker_compose_domains, true);
|
||||
|
||||
expect($domains)->toHaveKeys(['api-test', 'api.test'])
|
||||
->and($domains)->toHaveCount(2)
|
||||
->and($domains['api-test']['domain'])->toContain('hyphen.example.com')
|
||||
->and($domains['api.test']['domain'])->toContain('dot.example.com');
|
||||
});
|
||||
|
||||
it('sets fqdn to null when no domains are configured', function () {
|
||||
$application = Application::factory()->create([
|
||||
'build_pack' => 'dockercompose',
|
||||
@@ -78,3 +129,64 @@ it('sets fqdn to null when no domains are configured', function () {
|
||||
|
||||
expect($preview->fqdn)->toBeNull();
|
||||
});
|
||||
|
||||
it('collapses dashed and underscore twin domain keys into one preview service', function () {
|
||||
$application = Application::factory()->create([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web-api' => ['domain' => 'https://web-api.example.com'],
|
||||
'web_api' => ['domain' => ''],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 58,
|
||||
'pull_request_html_url' => 'https://github.com/example/repo/pull/58',
|
||||
// Existing dual-key preview state from older Coolify versions / mixed write paths.
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web_api' => ['domain' => 'https://old-preview.example.com'],
|
||||
'web-api' => ['domain' => ''],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview->generate_preview_fqdn_compose();
|
||||
$preview->refresh();
|
||||
|
||||
$domains = json_decode($preview->docker_compose_domains, true);
|
||||
|
||||
expect($domains)->toHaveKey('web-api')
|
||||
->and($domains)->not->toHaveKey('web_api')
|
||||
->and($domains)->toHaveCount(1)
|
||||
->and($domains['web-api']['domain'])->toContain('web-api.example.com')
|
||||
->and($domains['web-api']['domain'])->toContain('58')
|
||||
->and($preview->fqdn)->toContain('web-api.example.com');
|
||||
});
|
||||
|
||||
it('reads legacy underscore application domains when generating previews for dashed services', function () {
|
||||
$application = Application::factory()->create([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web_api' => ['domain' => 'https://legacy.example.com'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 12,
|
||||
'pull_request_html_url' => 'https://github.com/example/repo/pull/12',
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web-api' => ['domain' => ''],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview->generate_preview_fqdn_compose();
|
||||
$preview->refresh();
|
||||
|
||||
$domains = json_decode($preview->docker_compose_domains, true);
|
||||
|
||||
expect($domains)->toHaveKey('web-api')
|
||||
->and($domains)->not->toHaveKey('web_api')
|
||||
->and($domains['web-api']['domain'])->toContain('legacy.example.com')
|
||||
->and($preview->fqdn)->toContain('legacy.example.com');
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Feature regression for #8798 / #8980: saving a service domain that includes a
|
||||
* required port must not corrupt SERVICE_URL values.
|
||||
*
|
||||
* EditDomain saves fqdn then calls updateCompose() (Spatie-based) and parse().
|
||||
* updateCompose is the path that writes SERVICE_URL_* from the saved FQDN and is
|
||||
* fully exerciseable under SQLite; getFqdnWithoutPort coverage lives in unit tests.
|
||||
*/
|
||||
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 Spatie\Url\Url;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
test('updateCompose keeps valid SERVICE_URL values when domain includes required port', function () {
|
||||
$template = <<<'YAML'
|
||||
services:
|
||||
gitlab:
|
||||
image: gitlab/gitlab-ce:latest
|
||||
environment:
|
||||
- SERVICE_URL_GITLAB_80
|
||||
- EXTERNAL_URL=$SERVICE_URL_GITLAB
|
||||
YAML;
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'docker_compose_raw' => $template,
|
||||
'compose_parsing_version' => '5',
|
||||
]);
|
||||
|
||||
$serviceApp = ServiceApplication::create([
|
||||
'name' => 'gitlab',
|
||||
'service_id' => $service->id,
|
||||
'image' => 'gitlab/gitlab-ce:latest',
|
||||
'fqdn' => 'http://git.example.com:80',
|
||||
]);
|
||||
|
||||
// EditDomain::submit calls updateCompose after saving fqdn
|
||||
updateCompose($serviceApp);
|
||||
|
||||
$serviceApp->refresh();
|
||||
|
||||
expect($serviceApp->fqdn)->toBe('http://git.example.com:80')
|
||||
->and($serviceApp->fqdn)->not->toContain('//:80')
|
||||
->and(fn () => Url::fromString($serviceApp->fqdn))->not->toThrow(Throwable::class);
|
||||
|
||||
$baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_GITLAB')->first();
|
||||
$portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_GITLAB_80')->first();
|
||||
|
||||
expect($baseUrl)->not->toBeNull()
|
||||
->and($portUrl)->not->toBeNull()
|
||||
->and($baseUrl->value)->toBe('http://git.example.com')
|
||||
->and($portUrl->value)->toBe('http://git.example.com:80')
|
||||
->and(fn () => Url::fromString($baseUrl->value))->not->toThrow(Throwable::class)
|
||||
->and(fn () => Url::fromString($portUrl->value))->not->toThrow(Throwable::class);
|
||||
});
|
||||
|
||||
test('getFqdnWithoutPort used by serviceParser strip is safe for ported service domains', function () {
|
||||
// Mirrors serviceParser when $savedService->fqdn already has a port
|
||||
$savedFqdn = 'http://git.example.com:80';
|
||||
$firstFqdn = trim((string) str($savedFqdn)->explode(',')->first());
|
||||
$url = getFqdnWithoutPort($firstFqdn);
|
||||
$port = '80';
|
||||
$urlWithPort = "$url:$port";
|
||||
|
||||
expect($url)->toBe('http://git.example.com')
|
||||
->and($urlWithPort)->toBe('http://git.example.com:80')
|
||||
->and($urlWithPort)->not->toBe('http://git.example.com/:80')
|
||||
->and(fn () => Url::fromString($urlWithPort))->not->toThrow(Throwable::class);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Stringable;
|
||||
|
||||
/**
|
||||
* Unit tests to verify that the applicationParser function in parsers.php
|
||||
* properly converts Stringable objects to plain strings to fix strict
|
||||
@@ -26,8 +28,8 @@ it('ensures service name normalization returns plain strings not Stringable obje
|
||||
// Verify both are plain strings, not Stringable objects
|
||||
expect(is_string($originalServiceName))->toBeTrue('$originalServiceName should be a plain string');
|
||||
expect(is_string($serviceName))->toBeTrue('$serviceName should be a plain string');
|
||||
expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||
expect($serviceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||
expect($originalServiceName)->not->toBeInstanceOf(Stringable::class);
|
||||
expect($serviceName)->not->toBeInstanceOf(Stringable::class);
|
||||
|
||||
// Verify the transformations work correctly
|
||||
expect($originalServiceName)->toBe('my-service');
|
||||
@@ -135,7 +137,7 @@ it('ensures originalServiceName conversion works for FQDN generation', function
|
||||
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||
|
||||
expect(is_string($originalServiceName))->toBeTrue();
|
||||
expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||
expect($originalServiceName)->not->toBeInstanceOf(Stringable::class);
|
||||
expect($originalServiceName)->toBe('my-service');
|
||||
|
||||
// Verify it can be used in string interpolation (line 544)
|
||||
@@ -177,6 +179,9 @@ it('verifies parsers.php has the ->value() calls', function () {
|
||||
// Line 539: Check originalServiceName conversion
|
||||
expect($parsersFile)->toContain("str(\$serviceName)->replace('_', '-')->value()");
|
||||
|
||||
// Line 541: Check serviceName normalization
|
||||
expect($parsersFile)->toContain("str(\$serviceName)->replace('-', '_')->replace('.', '_')->value()");
|
||||
// Service name normalization for env keys / domain map (helper-based)
|
||||
expect($parsersFile)->toContain('normalizeComposeServiceName((string) $serviceName)')
|
||||
->and($parsersFile)->toContain('getComposeServiceDomainString')
|
||||
->and($parsersFile)->toContain('putComposeServiceDomain')
|
||||
->and($parsersFile)->toContain('findServiceApplicationForEnvName');
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Models\Application;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
test('normalizeComposeServiceName replaces hyphens and dots', function () {
|
||||
expect(normalizeComposeServiceName('another-service'))->toBe('another_service')
|
||||
->and(normalizeComposeServiceName('api.test'))->toBe('api_test')
|
||||
->and(normalizeComposeServiceName('api'))->toBe('api');
|
||||
});
|
||||
|
||||
test('findComposeServiceName resolves legacy underscore keys to original names', function () {
|
||||
$services = ['another-service', 'api.test', 'api'];
|
||||
|
||||
expect(findComposeServiceName('another-service', $services))->toBe('another-service')
|
||||
->and(findComposeServiceName('another_service', $services))->toBe('another-service')
|
||||
->and(findComposeServiceName('api.test', $services))->toBe('api.test')
|
||||
->and(findComposeServiceName('api_test', $services))->toBe('api.test')
|
||||
->and(findComposeServiceName('missing', $services))->toBeNull();
|
||||
});
|
||||
|
||||
test('getComposeServiceDomainString reads original and legacy keys without dotted data_get bugs', function () {
|
||||
$domains = [
|
||||
'api.test' => ['domain' => 'https://dotted.example.com'],
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
];
|
||||
|
||||
expect(getComposeServiceDomainString($domains, 'api.test'))->toBe('https://dotted.example.com')
|
||||
->and(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com')
|
||||
->and(getComposeServiceDomainString($domains, 'missing'))->toBeNull();
|
||||
});
|
||||
|
||||
test('putComposeServiceDomain writes original service keys and drops twin underscore keys', function () {
|
||||
$domains = putComposeServiceDomain(
|
||||
['another_service' => ['domain' => 'https://old.example.com']],
|
||||
'another-service',
|
||||
'https://new.example.com',
|
||||
['another-service', 'analytics'],
|
||||
);
|
||||
|
||||
expect($domains)->toHaveKey('another-service')
|
||||
->and($domains)->not->toHaveKey('another_service')
|
||||
->and($domains['another-service']['domain'])->toBe('https://new.example.com');
|
||||
});
|
||||
|
||||
test('rekeyComposeDomainsToServiceNames migrates underscore keys to original compose names', function () {
|
||||
$rekeyed = rekeyComposeDomainsToServiceNames(
|
||||
[
|
||||
'another_service' => ['domain' => 'https://hyphen.example.com'],
|
||||
'api_test' => ['domain' => 'https://dotted.example.com'],
|
||||
'orphan' => ['domain' => 'https://orphan.example.com'],
|
||||
],
|
||||
['another-service', 'api.test'],
|
||||
);
|
||||
|
||||
expect($rekeyed)->toHaveKey('another-service')
|
||||
->and($rekeyed)->not->toHaveKey('another_service')
|
||||
->and($rekeyed['another-service']['domain'])->toBe('https://hyphen.example.com')
|
||||
->and($rekeyed)->toHaveKey('api.test')
|
||||
->and($rekeyed['api.test']['domain'])->toBe('https://dotted.example.com')
|
||||
->and($rekeyed)->toHaveKey('orphan');
|
||||
});
|
||||
|
||||
test('rekeyComposeDomainsToServiceNames prefers the canonical domain regardless of twin key order', function (array $domains) {
|
||||
$rekeyed = rekeyComposeDomainsToServiceNames($domains, ['another-service']);
|
||||
|
||||
expect($rekeyed['another-service']['domain'])->toBe('https://canonical.example.com');
|
||||
})->with([
|
||||
'legacy key first' => [[
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
'another-service' => ['domain' => 'https://canonical.example.com'],
|
||||
]],
|
||||
'canonical key first' => [[
|
||||
'another-service' => ['domain' => 'https://canonical.example.com'],
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
]],
|
||||
]);
|
||||
|
||||
test('rekeyComposeDomainsToServiceNames never lets empty canonical wipe filled legacy', function (array $domains) {
|
||||
$rekeyed = rekeyComposeDomainsToServiceNames($domains, ['another-service']);
|
||||
|
||||
expect($rekeyed)->toHaveKey('another-service')
|
||||
->and($rekeyed)->not->toHaveKey('another_service')
|
||||
->and($rekeyed['another-service']['domain'])->toBe('https://legacy.example.com');
|
||||
})->with([
|
||||
'empty canonical first' => [[
|
||||
'another-service' => ['domain' => ''],
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
]],
|
||||
'filled legacy first' => [[
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
'another-service' => ['domain' => ''],
|
||||
]],
|
||||
]);
|
||||
|
||||
test('getComposeServiceDomainString prefers filled twin over blank original key', function () {
|
||||
$domains = [
|
||||
'another-service' => ['domain' => ''],
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
];
|
||||
|
||||
expect(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com')
|
||||
->and(getComposeServiceDomainString($domains, 'another_service'))->toBe('https://legacy.example.com');
|
||||
});
|
||||
|
||||
test('preferredComposeServiceNamesFromDomainKeys collapses underscore twins', function () {
|
||||
expect(preferredComposeServiceNamesFromDomainKeys(['web_api', 'web-api', 'api']))->toEqualCanonicalizing(['web-api', 'api']);
|
||||
});
|
||||
|
||||
test('legacy underscore domain keys still resolve for hyphenated compose services', function () {
|
||||
// Existing production shape: domains stored under underscore keys only.
|
||||
$legacy = [
|
||||
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||
'web' => ['domain' => 'https://web.example.com'],
|
||||
];
|
||||
|
||||
expect(getComposeServiceDomainString($legacy, 'another-service'))->toBe('https://legacy.example.com')
|
||||
->and(getComposeServiceDomainString($legacy, 'another_service'))->toBe('https://legacy.example.com')
|
||||
->and(getComposeServiceDomainString($legacy, 'web'))->toBe('https://web.example.com');
|
||||
});
|
||||
|
||||
test('SERVICE env keys stay underscore-normalized for both storage shapes', function () {
|
||||
// Mirrors ApplicationDeploymentJob: domain map key may be original or legacy.
|
||||
foreach (['another-service', 'another_service'] as $storageKey) {
|
||||
$envKey = str(normalizeComposeServiceName($storageKey))->upper()->toString();
|
||||
expect($envKey)->toBe('ANOTHER_SERVICE')
|
||||
->and('SERVICE_URL_'.$envKey)->toBe('SERVICE_URL_ANOTHER_SERVICE');
|
||||
}
|
||||
});
|
||||
|
||||
test('findComposeServiceName maps SERVICE env fragments used by serviceParser magic path', function () {
|
||||
// serviceParser historically only did underscore→hyphen, so API_TEST became name "api-test"
|
||||
// and missed compose service "api.test". normalize-based lookup finds the real name.
|
||||
$services = ['another-service', 'api.test', 'web'];
|
||||
|
||||
expect(findComposeServiceName('another_service', $services))->toBe('another-service')
|
||||
->and(findComposeServiceName('api_test', $services))->toBe('api.test')
|
||||
->and(findComposeServiceName('api-test', $services))->toBe('api.test') // same normalized form
|
||||
->and(findComposeServiceName('web', $services))->toBe('web');
|
||||
|
||||
// Exact DB lookup the old serviceParser used would miss dotted names:
|
||||
expect(in_array('api-test', $services, true))->toBeFalse()
|
||||
->and(in_array('api.test', $services, true))->toBeTrue();
|
||||
});
|
||||
|
||||
test('normalized service name collisions only resolve exact matches', function () {
|
||||
$services = ['api-test', 'api.test'];
|
||||
|
||||
expect(findComposeServiceName('api-test', $services))->toBe('api-test')
|
||||
->and(findComposeServiceName('api.test', $services))->toBe('api.test')
|
||||
->and(findComposeServiceName('api_test', $services))->toBeNull();
|
||||
});
|
||||
|
||||
test('rekeyComposeDomainsToServiceNames keeps ambiguous normalized keys separate', function () {
|
||||
$rekeyed = rekeyComposeDomainsToServiceNames(
|
||||
[
|
||||
'api-test' => ['domain' => 'https://hyphen.example.com'],
|
||||
'api.test' => ['domain' => 'https://dot.example.com'],
|
||||
'api_test' => ['domain' => 'https://legacy.example.com'],
|
||||
],
|
||||
['api-test', 'api.test'],
|
||||
);
|
||||
|
||||
expect($rekeyed['api-test']['domain'])->toBe('https://hyphen.example.com')
|
||||
->and($rekeyed['api.test']['domain'])->toBe('https://dot.example.com')
|
||||
->and($rekeyed['api_test']['domain'])->toBe('https://legacy.example.com');
|
||||
});
|
||||
|
||||
test('empty parsed compose services do not wipe existing domains', function () {
|
||||
$application = new Application;
|
||||
$application->docker_compose_domains = json_encode([
|
||||
'web' => ['domain' => 'https://web.example.com'],
|
||||
]);
|
||||
|
||||
$method = new ReflectionMethod($application, 'reconcileDockerComposeDomains');
|
||||
$method->invoke($application, collect(['services' => []]));
|
||||
|
||||
expect($application->docker_compose_domains)->toBe(json_encode([
|
||||
'web' => ['domain' => 'https://web.example.com'],
|
||||
]));
|
||||
});
|
||||
|
||||
test('normalized service collisions retain domains under their exact compose keys', function () {
|
||||
$domains = collect([
|
||||
'api-test' => ['domain' => 'https://hyphen.example.com'],
|
||||
'api.test' => ['domain' => 'https://dot.example.com'],
|
||||
]);
|
||||
|
||||
$rekeyed = rekeyComposeDomainsToServiceNames($domains, collect(['api-test', 'api.test']));
|
||||
|
||||
expect($rekeyed)->toBe([
|
||||
'api-test' => ['domain' => 'https://hyphen.example.com'],
|
||||
'api.test' => ['domain' => 'https://dot.example.com'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('General rekeys form domains to original compose service names', function (array|Collection $domains) {
|
||||
$component = new General;
|
||||
$component->parsedServices = collect([
|
||||
'services' => collect([
|
||||
'web-api' => [],
|
||||
'metrics.internal' => [],
|
||||
]),
|
||||
]);
|
||||
$component->parsedServiceDomains = $domains;
|
||||
|
||||
$method = new ReflectionMethod($component, 'composeDomainsForStorage');
|
||||
$stored = $method->invoke($component);
|
||||
|
||||
expect($stored)->toBe([
|
||||
'web-api' => ['domain' => 'https://api.example.com'],
|
||||
'metrics.internal' => ['domain' => 'https://metrics.example.com'],
|
||||
]);
|
||||
})->with([
|
||||
'array' => [[
|
||||
'web_api' => ['domain' => 'https://api.example.com'],
|
||||
'metrics_internal' => ['domain' => 'https://metrics.example.com'],
|
||||
]],
|
||||
'collection' => [collect([
|
||||
'web_api' => ['domain' => 'https://api.example.com'],
|
||||
'metrics_internal' => ['domain' => 'https://metrics.example.com'],
|
||||
])],
|
||||
]);
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared domain URL helpers used by application/service parsers and updateCompose.
|
||||
*
|
||||
* TDD targets:
|
||||
* - getHostWithoutPort(): COOLIFY_FQDN / SERVICE_FQDN host-only form (no scheme, no port)
|
||||
* - firstDomainFromList(): comma-separated FQDN lists (same as updateCompose / serviceParser)
|
||||
* - getFqdnWithoutPort() remains the scheme+host form for COOLIFY_URL / SERVICE_URL
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getHostWithoutPort — expected behavior (fails until helper exists / is correct)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('getHostWithoutPort strips scheme and port from simple urls', function () {
|
||||
expect(getHostWithoutPort('http://git.example.com:80'))->toBe('git.example.com')
|
||||
->and(getHostWithoutPort('https://n8n.example.com:5678'))->toBe('n8n.example.com')
|
||||
->and(getHostWithoutPort('http://git.example.com'))->toBe('git.example.com');
|
||||
});
|
||||
|
||||
test('getHostWithoutPort preserves path segments', function () {
|
||||
expect(getHostWithoutPort('http://git.example.com:80/v1/realtime'))
|
||||
->toBe('git.example.com/v1/realtime');
|
||||
});
|
||||
|
||||
test('getHostWithoutPort keeps real host when credentials are present', function () {
|
||||
// Legacy COOLIFY_FQDN strip: replace scheme then before(':') → "user"
|
||||
expect(getHostWithoutPort('http://user:secret@git.example.com:80'))
|
||||
->toBe('git.example.com');
|
||||
});
|
||||
|
||||
test('getHostWithoutPort handles scheme-less host:port', function () {
|
||||
expect(getHostWithoutPort('git.example.com:80'))->toBe('git.example.com');
|
||||
});
|
||||
|
||||
test('getHostWithoutPort returns original when host cannot be parsed', function () {
|
||||
expect(getHostWithoutPort('not-a-url'))->toBe('not-a-url');
|
||||
});
|
||||
|
||||
test('legacy COOLIFY_FQDN strip corrupts credential urls (documents why we refactor)', function () {
|
||||
$fqdn = 'http://user:secret@git.example.com:80';
|
||||
|
||||
$legacy = (string) str($fqdn)
|
||||
->replace('http://', '')
|
||||
->replace('https://', '')
|
||||
->before(':');
|
||||
|
||||
expect($legacy)->toBe('user')
|
||||
->and($legacy)->not->toBe(getHostWithoutPort($fqdn));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// firstDomainFromList
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('firstDomainFromList returns the first comma-separated domain trimmed', function () {
|
||||
expect(firstDomainFromList('http://a.example.com:80,http://b.example.com:80'))
|
||||
->toBe('http://a.example.com:80')
|
||||
->and(firstDomainFromList(' https://only.example.com '))
|
||||
->toBe('https://only.example.com')
|
||||
->and(firstDomainFromList(null))
|
||||
->toBe('')
|
||||
->and(firstDomainFromList(''))
|
||||
->toBe('');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateCompose / parser pairing: URL + FQDN from the same helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('url and host helpers stay paired for ported service domains', function () {
|
||||
$fqdn = 'http://git.example.com:80';
|
||||
|
||||
$urlValue = getFqdnWithoutPort($fqdn);
|
||||
$fqdnValue = getHostWithoutPort($fqdn);
|
||||
$port = '80';
|
||||
|
||||
expect($urlValue)->toBe('http://git.example.com')
|
||||
->and($fqdnValue)->toBe('git.example.com')
|
||||
->and($urlValue.':'.$port)->toBe('http://git.example.com:80')
|
||||
->and($fqdnValue.':'.$port)->toBe('git.example.com:80')
|
||||
->and($urlValue.':'.$port)->not->toContain('/:');
|
||||
});
|
||||
|
||||
test('first domain then helpers match multi-domain service FQDN handling', function () {
|
||||
$saved = 'http://git.example.com:80,http://git-alt.example.com:80';
|
||||
$first = firstDomainFromList($saved);
|
||||
|
||||
expect($first)->toBe('http://git.example.com:80')
|
||||
->and(getFqdnWithoutPort($first))->toBe('http://git.example.com')
|
||||
->and(getHostWithoutPort($first))->toBe('git.example.com');
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use Spatie\Url\Url;
|
||||
|
||||
/**
|
||||
* Regression tests for service domain FQDN port handling (#8798 / #8980).
|
||||
*
|
||||
* Fragile str()->after('://')->before(':') stripping corrupts URLs that contain
|
||||
* credentials, IPv6, or other non-trivial structure. getFqdnWithoutPort() must
|
||||
* strip only the port and keep a usable base URL for COOLIFY_URL / SERVICE_URL.
|
||||
*/
|
||||
test('getFqdnWithoutPort strips port from simple http urls without trailing slash', function () {
|
||||
expect(getFqdnWithoutPort('http://git.example.com:80'))->toBe('http://git.example.com')
|
||||
->and(getFqdnWithoutPort('https://n8n.example.com:5678'))->toBe('https://n8n.example.com')
|
||||
->and(getFqdnWithoutPort('http://git.example.com'))->toBe('http://git.example.com');
|
||||
});
|
||||
|
||||
test('getFqdnWithoutPort preserves path segments when stripping port', function () {
|
||||
expect(getFqdnWithoutPort('http://git.example.com:80/v1/realtime'))
|
||||
->toBe('http://git.example.com/v1/realtime');
|
||||
});
|
||||
|
||||
test('getFqdnWithoutPort keeps host when credentials are present', function () {
|
||||
// Fragile strip would turn this into "http://user" — helper must not.
|
||||
expect(getFqdnWithoutPort('http://user:secret@git.example.com:80'))
|
||||
->toBe('http://git.example.com');
|
||||
});
|
||||
|
||||
test('getFqdnWithoutPort returns original string when input is not a valid url', function () {
|
||||
expect(getFqdnWithoutPort('not-a-url'))->toBe('not-a-url');
|
||||
});
|
||||
|
||||
test('fragile after/before strip corrupts credential urls (documents #8798 class of bugs)', function () {
|
||||
$fqdn = 'http://user:secret@git.example.com:80';
|
||||
|
||||
$fragile = (string) str($fqdn)
|
||||
->after('://')
|
||||
->before(':')
|
||||
->prepend(str($fqdn)->before('://')->append('://'));
|
||||
|
||||
expect($fragile)->toBe('http://user')
|
||||
->and($fragile)->not->toBe(getFqdnWithoutPort($fqdn));
|
||||
});
|
||||
|
||||
test('port comparisons must cast so string template ports match int url ports', function () {
|
||||
$envPort = Url::fromString('http://git.example.com:80')->getPort();
|
||||
$predefinedPort = '80'; // yaml / data_get style
|
||||
|
||||
expect($envPort !== $predefinedPort)->toBeTrue('strict compare wrongly treats matching ports as different')
|
||||
->and((int) $envPort !== (int) $predefinedPort)->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
test('traefikServiceNameHash is stable and length 4', function () {
|
||||
expect(traefikServiceNameHash('api.test'))->toBe(substr(md5('api.test'), 0, 4))
|
||||
->and(traefikServiceNameHash('api.test'))->toHaveLength(4)
|
||||
->and(traefikServiceNameHash('api.test'))->not->toBe(traefikServiceNameHash('api-test'));
|
||||
});
|
||||
|
||||
test('traefikSafeServiceNameSegment replaces dots and always appends hash', function () {
|
||||
$segment = traefikSafeServiceNameSegment('api.test');
|
||||
$hash = traefikServiceNameHash('api.test');
|
||||
|
||||
expect($segment)->toBe("api-test-{$hash}")
|
||||
->and($segment)->not->toContain('.')
|
||||
->and(str_ends_with($segment, "-{$hash}"))->toBeTrue();
|
||||
});
|
||||
|
||||
test('traefikSafeServiceNameSegment keeps api.test and api-test distinct', function () {
|
||||
$dot = traefikSafeServiceNameSegment('api.test');
|
||||
$hyphen = traefikSafeServiceNameSegment('api-test');
|
||||
|
||||
expect($dot)->not->toBe($hyphen)
|
||||
->and($dot)->toStartWith('api-test-')
|
||||
->and($hyphen)->toStartWith('api-test-');
|
||||
});
|
||||
|
||||
test('traefikSafeServiceNameSegment normalizes underscores and odd characters', function () {
|
||||
$segment = traefikSafeServiceNameSegment('web_api.v2');
|
||||
$hash = traefikServiceNameHash('web_api.v2');
|
||||
|
||||
expect($segment)->toBe("web-api-v2-{$hash}")
|
||||
->and($segment)->not->toContain('.')
|
||||
->and($segment)->not->toContain('_');
|
||||
});
|
||||
|
||||
test('fqdnLabelsForTraefik embeds safe hashed service segment without dotted router names', function () {
|
||||
$uuid = 'testuuid1234';
|
||||
$hashDot = traefikServiceNameHash('api.test');
|
||||
$hashHyphen = traefikServiceNameHash('api-test');
|
||||
|
||||
$dotLabels = fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: collect(['https://dot.example.com']),
|
||||
is_force_https_enabled: true,
|
||||
service_name: 'api.test',
|
||||
image: 'nginx:alpine',
|
||||
);
|
||||
|
||||
$hyphenLabels = fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: collect(['https://hyphen.example.com']),
|
||||
is_force_https_enabled: true,
|
||||
service_name: 'api-test',
|
||||
image: 'nginx:alpine',
|
||||
);
|
||||
|
||||
$dotHttps = $dotLabels->first(
|
||||
fn (string $line) => str_contains($line, "traefik.http.routers.https-0-{$uuid}-api-test-{$hashDot}.rule=")
|
||||
);
|
||||
$hyphenHttps = $hyphenLabels->first(
|
||||
fn (string $line) => str_contains($line, "traefik.http.routers.https-0-{$uuid}-api-test-{$hashHyphen}.rule=")
|
||||
);
|
||||
|
||||
expect($dotHttps)->not->toBeNull()
|
||||
->and($hyphenHttps)->not->toBeNull()
|
||||
->and($dotHttps)->not->toContain('api.test')
|
||||
->and($dotHttps)->not->toBe($hyphenHttps)
|
||||
->and($hashDot)->not->toBe($hashHyphen);
|
||||
|
||||
$ruleLines = $dotLabels->merge($hyphenLabels)->filter(
|
||||
fn (string $line) => str_contains($line, 'traefik.http.routers.') && str_contains($line, '.rule=')
|
||||
);
|
||||
|
||||
// Traefik label path must stay at traefik.http.routers.{name}.rule (5 segments).
|
||||
foreach ($ruleLines as $line) {
|
||||
$key = str($line)->before('=')->toString();
|
||||
expect(explode('.', $key))->toHaveCount(5);
|
||||
}
|
||||
});
|
||||
|
||||
test('fqdnLabelsForTraefik hyphenated services also receive a hash suffix', function () {
|
||||
$uuid = 'appuuid';
|
||||
$hash = traefikServiceNameHash('another-service');
|
||||
|
||||
$labels = fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: collect(['https://another.example.com']),
|
||||
service_name: 'another-service',
|
||||
image: 'nginx:alpine',
|
||||
);
|
||||
|
||||
$routerLine = $labels->first(
|
||||
fn (string $line) => str_contains($line, 'traefik.http.routers.https-0-') && str_contains($line, '.rule=')
|
||||
);
|
||||
|
||||
expect($routerLine)->toContain("https-0-{$uuid}-another-service-{$hash}.rule=");
|
||||
});
|
||||
Reference in new issue
Block a user