Enhance Coolify integration with gateway route deployment, add tests for new application route labels, and refactor gateway probing process.

This commit is contained in:
Jeppe Bundgaard
2026-05-20 12:59:51 +02:00
parent 24ac681365
commit 44c4b7656f
7 changed files with 1547 additions and 31 deletions
@@ -84,6 +84,11 @@ class coolify_api_client
return $this->request('POST', '/applications/private-github-app', $payload);
}
public function getApplication(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid));
}
public function updateApplication(string $uuid, array $payload): array
{
return $this->request('PATCH', '/applications/' . rawurlencode($uuid), $payload);
File diff suppressed because it is too large Load Diff
+246 -17
View File
@@ -2539,16 +2539,18 @@ class release_manager
{
$frontend = is_array($versions['frontend'] ?? null) ? $versions['frontend'] : [];
$api = is_array($versions['api'] ?? null) ? $versions['api'] : [];
$serviceSet = is_array($versions['service_set'] ?? null) ? $versions['service_set'] : [];
$targets = is_array($serviceSet['targets'] ?? null) ? $serviceSet['targets'] : [];
$frontendTarget = is_array($targets['frontend'] ?? null) ? $targets['frontend'] : null;
$apiTarget = is_array($targets['api'] ?? null) ? $targets['api'] : null;
return [
'frontend_base_url' => $this->normalizeReleasePublicBaseUrl(
$frontend['deployed_url'] ?? $channel['frontend_base_url'] ?? null,
'frontend'
),
'api_base_url' => $this->normalizeReleasePublicBaseUrl(
$api['deployed_url'] ?? $channel['api_base_url'] ?? null,
'api'
),
'frontend_base_url' => $this->normalizeReleasePublicBaseUrl($frontend['deployed_url'] ?? null, 'frontend')
?? $this->normalizeReleasePublicBaseUrl($channel['frontend_base_url'] ?? null, 'frontend')
?? (is_array($frontendTarget) ? $this->releaseTargetPublicBaseUrl($frontendTarget) : null),
'api_base_url' => $this->normalizeReleasePublicBaseUrl($api['deployed_url'] ?? null, 'api')
?? $this->normalizeReleasePublicBaseUrl($channel['api_base_url'] ?? null, 'api')
?? (is_array($apiTarget) ? $this->releaseTargetPublicBaseUrl($apiTarget) : null),
];
}
@@ -2676,9 +2678,22 @@ class release_manager
if ($resourceType === 'application') {
$applicationUpdate = $this->releaseCoolifyApplicationUpdatePayload($target, $context);
if ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
$applicationUpdate['domains'] = $publicUrl;
$applicationUpdate['is_force_https_enabled'] = true;
$applicationUpdate['force_domain_override'] = true;
$resource = [];
try {
$resource = $client->getApplication($serviceUuid);
} catch (Throwable) {
$resource = is_array($created) ? $created : [];
}
$applicationUpdate = array_replace(
$applicationUpdate,
$this->releaseCoolifyApplicationRoutePayload(
$target,
$context,
$publicUrl,
$serviceUuid,
$resource['custom_labels'] ?? null
)
);
}
if ($applicationUpdate !== []) {
$update = $client->updateApplication($serviceUuid, $applicationUpdate);
@@ -2766,6 +2781,35 @@ class release_manager
return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== '');
}
private function releaseCoolifyApplicationRoutePayload(
array $target,
array $context,
string $publicUrl,
string $resourceUuid,
mixed $existingLabels = null
): array
{
$payload = [
'domains' => $publicUrl,
'is_force_https_enabled' => true,
'force_domain_override' => true,
];
$labels = self::releaseCoolifyApplicationLabels(
$publicUrl,
$resourceUuid,
self::firstInteger($this->releaseCoolifyPortsExposes($target, $context)) ?? 80
);
if ($labels !== []) {
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
self::decodeCoolifyLabels($existingLabels),
$labels
)));
}
return $payload;
}
private function releaseCoolifyApplicationUpdatePayload(array $target, array $context): array
{
$payload = [
@@ -2787,6 +2831,145 @@ class release_manager
return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== '');
}
private static function releaseCoolifyApplicationLabels(string $publicUrl, string $resourceUuid, ?int $port = null): array
{
$resourceUuid = self::coolifyRouteLabelId($resourceUuid);
if ($resourceUuid === '') {
return [];
}
$parts = parse_url($publicUrl);
$host = trim((string)($parts['host'] ?? ''));
if ($host === '') {
return [];
}
$scheme = strtolower((string)($parts['scheme'] ?? 'https'));
$path = trim((string)($parts['path'] ?? '/'));
$path = $path !== '' ? $path : '/';
if ($path[0] !== '/') {
$path = '/' . $path;
}
$routePort = $port ?? self::firstInteger($parts['port'] ?? null) ?? 80;
$httpLabel = 'http-0-' . $resourceUuid;
$httpsLabel = 'https-0-' . $resourceUuid;
$labels = [
'traefik.enable=true',
'traefik.http.middlewares.gzip.compress=true',
'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https',
];
if ($scheme === 'https') {
$labels[] = "traefik.http.routers.{$httpsLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
$labels[] = "traefik.http.routers.{$httpsLabel}.entryPoints=https";
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
if ($path !== '/') {
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip";
} else {
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
}
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver=letsencrypt";
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.domains[0].main={$host}";
$labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
$labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http";
$labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
$labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
$labels[] = "traefik.http.routers.{$httpLabel}.middlewares=redirect-to-https";
} else {
$labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
$labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http";
$labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
$labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
if ($path !== '/') {
$labels[] = "traefik.http.middlewares.{$httpLabel}-stripprefix.stripprefix.prefixes={$path}";
$labels[] = "traefik.http.routers.{$httpLabel}.middlewares={$httpLabel}-stripprefix,gzip";
} else {
$labels[] = "traefik.http.routers.{$httpLabel}.middlewares=gzip";
}
}
sort($labels);
return $labels;
}
private static function mergeCoolifyLabels(array $existingLabels, array $generatedLabels): array
{
$merged = [];
foreach (array_merge($existingLabels, $generatedLabels) as $label) {
$label = trim((string)$label);
if ($label === '') {
continue;
}
$merged[self::coolifyLabelKey($label)] = $label;
}
return array_values($merged);
}
private static function decodeCoolifyLabels(mixed $labels): array
{
if (!is_scalar($labels)) {
return [];
}
$raw = trim((string)$labels);
if ($raw === '') {
return [];
}
$decoded = base64_decode($raw, true);
$content = $decoded !== false ? $decoded : $raw;
return array_values(array_filter(
preg_split('/\r\n|\r|\n/', (string)$content) ?: [],
static fn(string $label): bool => trim($label) !== ''
));
}
private static function coolifyLabelKey(string $label): string
{
$position = strpos($label, '=');
return $position === false ? trim($label) : trim(substr($label, 0, $position));
}
private static function coolifyRouteLabelId(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9-]+/', '-', $value) ?: '';
return trim($value, '-');
}
private static function firstInteger(mixed $value): ?int
{
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (is_float($value)) {
return $value > 0 ? (int)$value : null;
}
if (is_array($value)) {
foreach ($value as $item) {
$integer = self::firstInteger($item);
if ($integer !== null) {
return $integer;
}
}
return null;
}
if (!is_scalar($value)) {
return null;
}
if (preg_match('/\d+/', (string)$value, $matches) !== 1) {
return null;
}
$integer = (int)$matches[0];
return $integer > 0 ? $integer : null;
}
private function releaseCoolifyServicePayload(array $target, array $context, array $instance): array
{
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
@@ -3191,12 +3374,13 @@ class release_manager
private function releaseCoolifyPublicUrl(array $target, array $context): ?string
{
$explicitPublicUrl = $this->normalizeReleasePublicBaseUrl($context['coolify_public_url'] ?? null, (string)($target['app'] ?? ''));
$explicitPublicUrl = $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context);
if ($explicitPublicUrl !== null) {
return $explicitPublicUrl;
}
$raw = trim((string)($context['coolify_domain'] ?? ''));
$hasCoolifyDomain = $raw !== '';
if ($raw === '') {
$raw = trim((string)($target['health_url'] ?? ''));
}
@@ -3208,20 +3392,65 @@ class release_manager
if ($domain === null) {
throw new RuntimeException('Coolify SSL requires a DNS domain routed to the load balancer.');
}
return 'https://' . $domain;
return $hasCoolifyDomain
? $this->releaseRoutedPublicBaseUrl('https://' . $domain, $target, $context)
: $this->normalizeReleasePublicBaseUrl('https://' . $domain, (string)($target['app'] ?? ''));
}
if (preg_match('#^https?://#i', $raw) !== 1) {
$raw = 'http://' . $raw;
}
return $this->normalizeReleasePublicBaseUrl($raw, (string)($target['app'] ?? ''));
return $hasCoolifyDomain
? $this->releaseRoutedPublicBaseUrl($raw, $target, $context)
: $this->normalizeReleasePublicBaseUrl($raw, (string)($target['app'] ?? ''));
}
private function releaseRoutedPublicBaseUrl(mixed $value, array $target, array $context = []): ?string
{
$app = (string)($target['app'] ?? '');
$baseUrl = $this->normalizeReleasePublicBaseUrl($value, $app);
if ($baseUrl === null) {
return null;
}
$parts = parse_url($baseUrl);
if (!is_array($parts) || empty($parts['host'])) {
return $baseUrl;
}
$path = trim((string)($parts['path'] ?? ''), '/');
if ($path !== '') {
return $baseUrl;
}
$channelSlug = self::safeSlug((string)(
$target['channel_slug']
?? $context['channel_slug']
?? $target['release_channel']
?? $context['release_channel']
?? $target['channel']
?? $context['channel']
?? ''
));
$appSlug = self::safeSlug($app);
if ($channelSlug === '' || $appSlug === '') {
return $baseUrl;
}
$scheme = strtolower((string)($parts['scheme'] ?? 'https'));
$host = strtolower((string)$parts['host']);
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
return sprintf('%s://%s%s/%s/%s', $scheme, $host, $port, $channelSlug, $appSlug);
}
private function releaseTargetPublicBaseUrl(array $target): ?string
{
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
$context = is_array($target['deploy_context'] ?? null)
? $target['deploy_context']
: self::jsonDecode($target['deploy_context_json'] ?? null);
$app = (string)($target['app'] ?? '');
return $this->normalizeReleasePublicBaseUrl($context['coolify_public_url'] ?? null, $app)
?? $this->normalizeReleasePublicBaseUrl($context['coolify_domain'] ?? null, $app)
return $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context)
?? $this->releaseRoutedPublicBaseUrl($context['coolify_domain'] ?? null, $target, $context)
?? $this->normalizeReleasePublicBaseUrl($target['health_url'] ?? null, $app);
}
+86
View File
@@ -11044,6 +11044,36 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/superuser/coolify/load-balancer/routes/deploy:
post:
tags:
- Superuser
summary: Deploy the Coolify API route for the public gateway host
operationId: deploySuperuserCoolifyGatewayRoutes
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
dry_run:
type: boolean
default: true
enforce:
type: boolean
default: false
responses:
'200':
description: Gateway application route deploy result returned
content:
application/json:
schema:
$ref: '#/components/schemas/SuperuserCoolifyGatewayRouteDeployResponse'
'409': { $ref: '#/components/responses/Conflict' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/superuser/coolify/gateways:
get:
tags:
@@ -13274,6 +13304,62 @@ components:
type: object
additionalProperties: true
SuperuserCoolifyGatewayRouteDeployResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
ok:
type: boolean
dry_run:
type: boolean
mutated:
type: boolean
public_host:
type: string
public_url:
type: string
planned:
type: array
items:
type: object
additionalProperties: true
applied:
type: array
items:
type: object
additionalProperties: true
skipped:
type: array
items:
type: object
additionalProperties: true
errors:
type: array
items:
type: object
additionalProperties: true
warnings:
type: array
items:
type: string
coverage:
type: object
additionalProperties: true
gateways:
type: array
items:
$ref: '#/components/schemas/SuperuserCoolifyGateway'
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
SuperuserCoolifyGatewaysResponse:
type: object
properties:
@@ -52,6 +52,27 @@ class superuserCoolifyRoute
'superuser_coolify_manage' => 'Reconcile Hetzner Load Balancer targets and services for the Coolify gateway',
]);
$this->post('/superuser/coolify/load-balancer/routes/deploy', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
try {
$parameters = $this->getParametersAsArray();
$dryRun = array_key_exists('dry_run', $parameters)
? filter_var($parameters['dry_run'], FILTER_VALIDATE_BOOLEAN)
: !filter_var($parameters['enforce'] ?? false, FILTER_VALIDATE_BOOLEAN);
$result = (new coolify_manager())->deployGatewayApplicationRoutes($dryRun, $this->actorUserId());
if (($result['ok'] ?? false) !== true) {
$response->error($result, 409);
}
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_coolify_manage' => 'Deploy the Coolify API application route for the public gateway host',
]);
$this->get('/superuser/coolify/gateways', function () {
global $response;
@@ -201,6 +201,73 @@ it('plans removal only for disabled or deleted Hetzner load balancer targets', f
->toHaveKey('target_ip', '65.21.214.30');
});
it('builds gateway API auto-provision context for connected Coolify servers', function (): void {
$manager = new coolify_manager();
$contextMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteProvisionDeployContext');
$contextMethod->setAccessible(true);
$ipMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteServerPublicIp');
$ipMethod->setAccessible(true);
$server = [
'uuid' => 'server-node1',
'name' => 'node1.truckwash.io',
'public_ip' => '94.130.142.41',
'settings' => ['is_reachable' => true, 'is_usable' => true],
];
expect($ipMethod->invoke(null, $server))->toBe('94.130.142.41');
$context = $contextMethod->invoke($manager, [
'id' => 42,
'channel_slug' => 'internal',
'deploy_context_json' => json_encode([
'coolify_project_uuid' => 'project-internal',
'coolify_destination_uuid' => 'source-destination',
'coolify_git_commit_sha' => 'source-commit',
'coolify_enable_ssl' => false,
]),
], $server, '94.130.142.41', 'api-v2.truckwash.io', 'https://api-v2.truckwash.io');
expect($context)->toMatchArray([
'coolify_project_uuid' => 'project-internal',
'coolify_auto_create' => true,
'coolify_enable_ssl' => true,
'coolify_deploy_now' => true,
'coolify_domain' => 'api-v2.truckwash.io',
'coolify_public_url' => 'https://api-v2.truckwash.io',
'coolify_server_uuid' => 'server-node1',
'server_uuid' => 'server-node1',
'coolify_destination_uuid' => '',
'destination_uuid' => '',
'coolify_service_name' => 'release-internal-api-node1-truckwash-io',
'gateway_route_autoprovision' => true,
'gateway_route_source_target_id' => 42,
'gateway_route_target_ip' => '94.130.142.41',
]);
expect($context)->not->toHaveKey('coolify_git_commit_sha');
});
it('adds explicit Coolify application route labels for gateway API domains', function (): void {
$payloadMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteApplicationPayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [
'custom.keep=true',
'traefik.http.routers.https-0-api-app-uuid.entryPoints=old',
])));
$labels = explode("\n", base64_decode($payload['custom_labels'], true));
expect($payload['domains'])->toBe('https://api-v2.truckwash.io')
->and($payload['is_force_https_enabled'])->toBeTrue()
->and($payload['force_domain_override'])->toBeTrue()
->and($labels)->toContain('custom.keep=true')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.entryPoints=https')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
});
it('defines Coolify schema, route permissions, and replication integration hooks', function (): void {
$schema = file_get_contents(app_path('classes/coolify_schema_bootstrap.php'));
$manager = file_get_contents(app_path('classes/coolify_manager.php'));
@@ -230,6 +297,7 @@ it('defines Coolify schema, route permissions, and replication integration hooks
expect($route)->toContain('/superuser/coolify');
expect($route)->toContain('/superuser/coolify/load-balancer');
expect($route)->toContain('/superuser/coolify/load-balancer/reconcile');
expect($route)->toContain('/superuser/coolify/load-balancer/routes/deploy');
expect($route)->toContain('/superuser/coolify/gateways');
expect($route)->toContain('/superuser/coolify/gateways/{id}/test');
expect($route)->toContain('/superuser/coolify/instances/{id}/test');
@@ -295,6 +363,27 @@ it('defines Coolify schema, route permissions, and replication integration hooks
expect($manager)->toContain('/envs/bulk');
expect($manager)->toContain('loadBalancerSummary');
expect($manager)->toContain('reconcileLoadBalancer');
expect($manager)->toContain('deployGatewayApplicationRoutes');
expect($manager)->toContain('loadBalancerReleaseApiTargets');
expect($manager)->toContain('provisionMissingGatewayApiTargets');
expect($manager)->toContain('provision_gateway_api_target');
expect($manager)->toContain('gateway_route_autoprovision');
expect($manager)->toContain('upsertDeploymentTarget');
expect($manager)->toContain('startDeployment');
expect($manager)->toContain('verifyGatewayRoutes');
expect($manager)->toContain('recordGatewayProbe');
expect($manager)->toContain("Gateway route and Let's Encrypt certificate verification is still failing");
expect($manager)->toContain('CURLOPT_SSL_VERIFYHOST, 2');
expect($manager)->toContain('CURLOPT_SSL_VERIFYPEER, true');
expect($manager)->toContain('CURLOPT_CERTINFO');
expect($manager)->toContain('CURLINFO_SSL_VERIFYRESULT');
expect($manager)->toContain("Gateway TLS certificate was not issued by Let's Encrypt.");
expect($manager)->not->toContain('CURLOPT_SSL_VERIFYHOST, 0');
expect($manager)->not->toContain('CURLOPT_SSL_VERIFYPEER, false');
expect($manager)->toContain('information_schema.tables');
expect($manager)->not->toContain('SHOW TABLES LIKE ?');
expect($manager)->toContain('gatewayRouteApplicationPayload');
expect($manager)->toContain('gateway_application_routes_deployed');
expect($manager)->toContain('target_already_defined');
expect($manager)->toContain('REQUIRED_LOAD_BALANCER_SERVICES');
expect($manager)->toContain('skip_remove_target');
@@ -303,6 +392,7 @@ it('defines Coolify schema, route permissions, and replication integration hooks
expect($client)->toContain("request('GET', '/health', null, false)");
expect($client)->toContain('/github-apps');
expect($client)->toContain('/applications/private-github-app');
expect($client)->toContain("request('GET', '/applications/' . rawurlencode(\$uuid))");
expect($client)->toContain("'/deploy?uuid=' . rawurlencode(\$uuid)");
expect($client)->toContain('/applications/\' . rawurlencode($uuid) . \'/restart');
expect($client)->toContain('CURL_HTTP_VERSION_1_1');
@@ -322,6 +412,7 @@ it('defines Coolify schema, route permissions, and replication integration hooks
expect($openapi)->toContain('/superuser/coolify:');
expect($openapi)->toContain('operationId: getSuperuserCoolifyLoadBalancer');
expect($openapi)->toContain('operationId: reconcileSuperuserCoolifyLoadBalancer');
expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayRoutes');
expect($openapi)->toContain('operationId: listSuperuserCoolifyGateways');
expect($openapi)->toContain('operationId: testSuperuserCoolifyGateway');
expect($openapi)->toContain('operationId: discoverSuperuserCoolifyInstancePlacement');
@@ -196,10 +196,35 @@ it('creates Coolify GitHub App application payloads so pulls use the app token',
expect($payload['publish_directory'])->toBe('dist');
expect($payload['is_static'])->toBeTrue();
expect($payload['is_spa'])->toBeTrue();
expect($payload['domains'])->toBe('https://canary.example.test');
expect($payload['domains'])->toBe('https://canary.example.test/canary/frontend');
expect($payload)->not->toHaveKey('docker_compose_raw');
});
it('builds explicit Coolify application route labels for release API targets', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'api',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
], [
'coolify_ports_exposes' => '8080',
], 'https://api-v2.truckwash.io', 'api-app-uuid', base64_encode('custom.keep=true'));
$labels = explode("\n", base64_decode($payload['custom_labels'], true));
expect($payload['domains'])->toBe('https://api-v2.truckwash.io')
->and($payload['is_force_https_enabled'])->toBeTrue()
->and($payload['force_domain_override'])->toBeTrue()
->and($labels)->toContain('custom.keep=true')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
});
it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationUpdatePayload');
@@ -528,6 +553,8 @@ it('requires non-default release channel runtime URLs and preserves load balance
$runtimeUrls->setAccessible(true);
$publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl');
$publicUrl->setAccessible(true);
$targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl');
$targetPublicBaseUrl->setAccessible(true);
expect($availability->invoke($manager, [
'id' => 1,
@@ -555,11 +582,61 @@ it('requires non-default release channel runtime URLs and preserves load balance
'api_base_url' => 'https://api-v2.truckwash.io/canary/api',
]);
expect($publicUrl->invoke($manager, ['app' => 'frontend'], [
expect($runtimeUrls->invoke($manager, [
'id' => 3,
'slug' => 'internal',
'default_channel' => 0,
'frontend_base_url' => null,
'api_base_url' => null,
], [
'frontend' => ['deployed_url' => null],
'api' => ['deployed_url' => null],
'service_set' => [
'targets' => [
'frontend' => [
'app' => 'frontend',
'channel_slug' => 'internal',
'deploy_context' => [
'coolify_public_url' => 'https://api-v2.truckwash.io',
],
],
'api' => [
'app' => 'api',
'channel_slug' => 'internal',
'deploy_context' => [
'coolify_domain' => 'api-v2.truckwash.io',
],
],
],
],
]))->toBe([
'frontend_base_url' => 'https://api-v2.truckwash.io/internal/frontend',
'api_base_url' => 'https://api-v2.truckwash.io/internal/api',
]);
expect($publicUrl->invoke($manager, ['app' => 'frontend', 'channel_slug' => 'canary'], [
'coolify_public_url' => 'https://api-v2.truckwash.io/canary/frontend',
'coolify_enable_ssl' => true,
]))->toBe('https://api-v2.truckwash.io/canary/frontend');
expect($publicUrl->invoke($manager, ['app' => 'frontend', 'channel_slug' => 'internal'], [
'coolify_public_url' => 'https://api-v2.truckwash.io',
'coolify_enable_ssl' => true,
]))->toBe('https://api-v2.truckwash.io/internal/frontend');
expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'internal'], [
'coolify_domain' => 'api-v2.truckwash.io',
'coolify_enable_ssl' => true,
]))->toBe('https://api-v2.truckwash.io/internal/api');
expect($targetPublicBaseUrl->invoke($manager, [
'app' => 'frontend',
'channel_slug' => 'internal',
'deploy_context_json' => json_encode([
'coolify_public_url' => 'https://api-v2.truckwash.io',
]),
]))->toBe('https://api-v2.truckwash.io/internal/frontend');
$source = file(app_path('classes/release_manager.php'));
$methodSource = implode('', array_slice(
$source,