Fix api-v2 CORS at the edge without changing rollout flows (#330)

Add API-only Traefik CORS middleware labels while preserving configured origins and the existing rollout/load-balancer behavior.
This commit is contained in:
Jeppe B
2026-07-29 09:01:08 +02:00
committed by GitHub
parent 710baad28e
commit 448e0e50c2
6 changed files with 228 additions and 18 deletions
+67 -7
View File
@@ -5,6 +5,8 @@ namespace classes;
use RuntimeException; use RuntimeException;
use Throwable; use Throwable;
require_once __DIR__ . '/cors_policy.php';
class coolify_manager class coolify_manager
{ {
private const KINDS = ['database', 'redis', 'minio']; private const KINDS = ['database', 'redis', 'minio'];
@@ -1070,7 +1072,9 @@ class coolify_manager
$targetPublicUrl, $targetPublicUrl,
$resourceUuid, $resourceUuid,
self::resourceFirstExposedPort($resource, $target), self::resourceFirstExposedPort($resource, $target),
$resource['custom_labels'] ?? null $resource['custom_labels'] ?? null,
$app,
self::gatewayRouteTargetCorsConfig($target)
); );
$update = $resourceType === 'service' $update = $resourceType === 'service'
? $client->updateService($resourceUuid, $updatePayload) ? $client->updateService($resourceUuid, $updatePayload)
@@ -2420,7 +2424,9 @@ class coolify_manager
string $publicUrl, string $publicUrl,
string $resourceUuid = '', string $resourceUuid = '',
?int $port = null, ?int $port = null,
mixed $existingLabels = null mixed $existingLabels = null,
string $app = '',
string $corsConfig = ''
): array ): array
{ {
$decodedLabels = self::decodeCoolifyLabels($existingLabels); $decodedLabels = self::decodeCoolifyLabels($existingLabels);
@@ -2435,7 +2441,9 @@ class coolify_manager
$publicUrl, $publicUrl,
$resourceUuid, $resourceUuid,
$routePort, $routePort,
self::gatewayRouteDefaultCertResolver($publicUrl) self::gatewayRouteDefaultCertResolver($publicUrl),
$app,
$corsConfig
); );
if ($labels !== []) { if ($labels !== []) {
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
@@ -2460,6 +2468,49 @@ class coolify_manager
]; ];
} }
private static function gatewayRouteTargetCorsConfig(array $target): string
{
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
$configured = null;
foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) {
$env = $context[$key] ?? null;
if (is_array($env) && array_key_exists('CORS', $env) && is_scalar($env['CORS'])) {
$configured = (string)$env['CORS'];
}
}
foreach (['coolify_env_file', 'env'] as $key) {
$raw = $context[$key] ?? null;
if (!is_string($raw)) {
continue;
}
foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) {
$line = trim((string)$line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$envKey, $value] = explode('=', $line, 2);
if (trim($envKey) === 'CORS') {
$configured = $value;
}
}
}
if ($configured === null) {
$runtimeValue = getenv('CORS');
if ($runtimeValue !== false) {
$configured = $runtimeValue;
} elseif (array_key_exists('CORS', $_ENV ?? [])) {
$configured = (string)$_ENV['CORS'];
} elseif (array_key_exists('CORS', $_SERVER ?? [])) {
$configured = (string)$_SERVER['CORS'];
}
}
return cors_policy::withRequiredOrigins((string)($configured ?? ''));
}
private static function coolifyProxyUrl(string $publicUrl, ?int $port): string private static function coolifyProxyUrl(string $publicUrl, ?int $port): string
{ {
if ($port === null || $port <= 0) { if ($port === null || $port <= 0) {
@@ -2483,7 +2534,9 @@ class coolify_manager
string $publicUrl, string $publicUrl,
string $resourceUuid, string $resourceUuid,
?int $port = null, ?int $port = null,
?string $certResolver = null ?string $certResolver = null,
string $app = '',
string $corsConfig = ''
): array ): array
{ {
$resourceUuid = self::gatewayRouteLabelId($resourceUuid); $resourceUuid = self::gatewayRouteLabelId($resourceUuid);
@@ -2508,6 +2561,7 @@ class coolify_manager
$certResolver = trim((string)($certResolver ?? '')); $certResolver = trim((string)($certResolver ?? ''));
$httpLabel = 'http-0-' . $resourceUuid; $httpLabel = 'http-0-' . $resourceUuid;
$httpsLabel = 'https-0-' . $resourceUuid; $httpsLabel = 'https-0-' . $resourceUuid;
$isApi = strtolower(trim($app)) === 'api';
$labels = [ $labels = [
'traefik.enable=true', 'traefik.enable=true',
'traefik.http.middlewares.gzip.compress=true', 'traefik.http.middlewares.gzip.compress=true',
@@ -2521,12 +2575,18 @@ class coolify_manager
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}"; $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}"; $labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
} }
$httpsMiddlewares = [];
if ($isApi) {
$corsMiddleware = "{$httpsLabel}-cors";
$labels = array_merge($labels, cors_policy::traefikHeadersMiddlewareLabels($corsMiddleware, $corsConfig));
$httpsMiddlewares[] = $corsMiddleware;
}
if ($path !== '/') { if ($path !== '/') {
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}"; $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip"; $httpsMiddlewares[] = "{$httpsLabel}-stripprefix";
} else {
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
} }
$httpsMiddlewares[] = 'gzip';
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares);
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
if ($certResolver !== '') { if ($certResolver !== '') {
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
@@ -88,6 +88,39 @@ class cors_policy
return self::REQUIRED_ALLOWED_ORIGINS; return self::REQUIRED_ALLOWED_ORIGINS;
} }
/**
* @return array<int,string>
*/
public static function traefikHeadersMiddlewareLabels(string $middlewareName, string $corsConfig = ''): array
{
$middlewareName = trim($middlewareName);
if ($middlewareName === '' || preg_match('/^[a-zA-Z0-9-]+$/', $middlewareName) !== 1) {
return [];
}
$allowedHeaders = array_values(array_filter(
array_map('trim', explode(',', self::ALLOWED_HEADERS)),
static fn(string $header): bool => $header !== '' && $header !== '*'
));
$allowedMethods = array_values(array_filter(array_map('trim', explode(',', self::ALLOWED_METHODS))));
$exposedHeaders = array_values(array_filter(array_map('trim', explode(',', self::EXPOSED_HEADERS))));
$prefix = "traefik.http.middlewares.{$middlewareName}.headers";
$allowedOrigins = self::allowedOrigins($corsConfig);
$originLabel = $allowedOrigins === ['*']
? "{$prefix}.accesscontrolalloworiginlistregex=^(https?://[^/]+|capacitor://[^/]+)$"
: "{$prefix}.accesscontrolalloworiginlist=" . implode(',', $allowedOrigins);
return [
"{$prefix}.accesscontrolallowcredentials=true",
"{$prefix}.accesscontrolallowheaders=" . implode(',', $allowedHeaders),
"{$prefix}.accesscontrolallowmethods=" . implode(',', $allowedMethods),
$originLabel,
"{$prefix}.accesscontrolexposeheaders=" . implode(',', $exposedHeaders),
"{$prefix}.accesscontrolmaxage=" . self::MAX_AGE_SECONDS,
"{$prefix}.addvaryheader=true",
];
}
/** /**
* @return array<int,string> * @return array<int,string>
*/ */
+16 -5
View File
@@ -8408,7 +8408,9 @@ class release_manager
$publicUrl, $publicUrl,
$resourceUuid, $resourceUuid,
$routePort, $routePort,
self::gatewayRouteDefaultCertResolver($publicUrl) self::gatewayRouteDefaultCertResolver($publicUrl),
(string)($target['app'] ?? ''),
(string)($this->releaseCoolifyRuntimeEnv($target, $context)['CORS'] ?? '')
); );
if ($labels !== []) { if ($labels !== []) {
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
@@ -8461,7 +8463,9 @@ class release_manager
string $publicUrl, string $publicUrl,
string $resourceUuid, string $resourceUuid,
?int $port = null, ?int $port = null,
?string $certResolver = null ?string $certResolver = null,
string $app = '',
string $corsConfig = ''
): array ): array
{ {
$resourceUuid = self::coolifyRouteLabelId($resourceUuid); $resourceUuid = self::coolifyRouteLabelId($resourceUuid);
@@ -8487,6 +8491,7 @@ class release_manager
$httpLabel = 'http-0-' . $resourceUuid; $httpLabel = 'http-0-' . $resourceUuid;
$httpsLabel = 'https-0-' . $resourceUuid; $httpsLabel = 'https-0-' . $resourceUuid;
$priority = (string)(1000 + strlen($path)); $priority = (string)(1000 + strlen($path));
$isApi = strtolower(trim($app)) === 'api';
$labels = [ $labels = [
'traefik.enable=true', 'traefik.enable=true',
'traefik.http.middlewares.gzip.compress=true', 'traefik.http.middlewares.gzip.compress=true',
@@ -8501,12 +8506,18 @@ class release_manager
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}"; $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}"; $labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
} }
$httpsMiddlewares = [];
if ($isApi) {
$corsMiddleware = "{$httpsLabel}-cors";
$labels = array_merge($labels, cors_policy::traefikHeadersMiddlewareLabels($corsMiddleware, $corsConfig));
$httpsMiddlewares[] = $corsMiddleware;
}
if ($path !== '/') { if ($path !== '/') {
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}"; $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip"; $httpsMiddlewares[] = "{$httpsLabel}-stripprefix";
} else {
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
} }
$httpsMiddlewares[] = 'gzip';
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares);
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
if ($certResolver !== '') { if ($certResolver !== '') {
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
@@ -5,6 +5,7 @@ app_require('classes/coolify_manager.php');
use classes\coolify_api_client; use classes\coolify_api_client;
use classes\coolify_manager; use classes\coolify_manager;
use classes\cors_policy;
class CoolifyManagerHetznerTargetSetFake class CoolifyManagerHetznerTargetSetFake
{ {
@@ -418,9 +419,10 @@ it('adds explicit Coolify application route labels for gateway API domains', fun
$payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [ $payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [
'custom.keep=true', 'custom.keep=true',
'traefik.http.routers.https-0-api-app-uuid.entryPoints=old', 'traefik.http.routers.https-0-api-app-uuid.entryPoints=old',
'traefik.http.routers.https-0-api-app-uuid.middlewares=legacy',
'traefik.http.routers.https-0-api-app-uuid.tls.certresolver=dns-cloudflare', 'traefik.http.routers.https-0-api-app-uuid.tls.certresolver=dns-cloudflare',
'traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=9090', 'traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=9090',
]))); ])), 'api', 'https://partner.example.test/app');
$labels = explode("\n", base64_decode($payload['custom_labels'], true)); $labels = explode("\n", base64_decode($payload['custom_labels'], true));
expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080') expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080')
@@ -429,6 +431,12 @@ it('adds explicit Coolify application route labels for gateway API domains', fun
->and($labels)->toContain('custom.keep=true') ->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.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.entryPoints=https')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,gzip')
->and($labels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolallowcredentials=true')
->and($labels)->toContain(
'traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlist='
. implode(',', cors_policy::allowedOrigins('https://partner.example.test/app'))
)
->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.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.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'); ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
@@ -449,21 +457,49 @@ it('adds explicit Coolify application route labels for gateway API domains', fun
'app' => 'frontend', 'app' => 'frontend',
]))->toBe('https://api-v2.truckwash.io/internal/frontend'); ]))->toBe('https://api-v2.truckwash.io/internal/frontend');
$pathPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/api', 'api-app-uuid', 8080, ''); $pathPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/api', 'api-app-uuid', 8080, '', 'api', '*');
$pathLabels = explode("\n", base64_decode($pathPayload['custom_labels'], true)); $pathLabels = explode("\n", base64_decode($pathPayload['custom_labels'], true));
expect($pathPayload['domains'])->toBe('https://api-v2.truckwash.io:8080/internal/api') expect($pathPayload['domains'])->toBe('https://api-v2.truckwash.io:8080/internal/api')
->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)') ->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)')
->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-stripprefix.stripprefix.prefixes=/internal/api') ->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-stripprefix.stripprefix.prefixes=/internal/api')
->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-stripprefix,gzip'); ->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlistregex=^(https?://[^/]+|capacitor://[^/]+)$')
->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,https-0-api-app-uuid-stripprefix,gzip');
$frontendPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', 80, ''); $frontendPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', 80, '', 'frontend');
$frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true)); $frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true));
expect($frontendPayload['domains'])->toBe('https://api-v2.truckwash.io:80/internal/frontend') expect($frontendPayload['domains'])->toBe('https://api-v2.truckwash.io:80/internal/frontend')
->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/frontend`)') ->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/frontend`)')
->and($frontendLabels)->toContain('traefik.http.middlewares.https-0-frontend-app-uuid-stripprefix.stripprefix.prefixes=/internal/frontend') ->and($frontendLabels)->toContain('traefik.http.middlewares.https-0-frontend-app-uuid-stripprefix.stripprefix.prefixes=/internal/frontend')
->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip'); ->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip');
expect(implode("\n", $frontendLabels))->not->toContain('-cors');
});
it('resolves gateway CORS from deployment context with the same precedence as runtime env', function (): void {
$method = new ReflectionMethod(coolify_manager::class, 'gatewayRouteTargetCorsConfig');
$config = $method->invoke(null, [
'deploy_context_json' => json_encode([
'coolify_env' => [
'CORS' => 'https://array.example.test/app',
],
'coolify_env_file' => "CORS=https://file.example.test/path\nOTHER=value",
'env' => "CORS=*\n",
]),
]);
expect($config)->toBe('*');
$requiredConfig = $method->invoke(null, [
'deploy_context_json' => json_encode([
'runtime_env' => [
'CORS' => 'https://partner.example.test/app',
],
]),
]);
expect(explode(',', $requiredConfig))
->toContain('https://partner.example.test')
->toContain('https://truckwash.io');
}); });
it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void { it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void {
@@ -27,6 +27,51 @@ it('merges required release and existing frontend origins into configured CORS',
expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api'); expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api');
}); });
it('builds credential-safe Traefik CORS middleware labels from the canonical policy', function (): void {
$labels = cors_policy::traefikHeadersMiddlewareLabels(
'https-0-api-app-cors',
'https://partner.example.test/app'
);
$allowedHeadersLabel = current(array_filter(
$labels,
static fn(string $label): bool => str_contains($label, '.accesscontrolallowheaders=')
));
expect($labels)
->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolallowcredentials=true')
->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolallowmethods=GET,POST,PUT,PATCH,DELETE,OPTIONS')
->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolexposeheaders=Server-Timing')
->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolmaxage=86400')
->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.addvaryheader=true');
expect($labels)
->toContain(
'traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolalloworiginlist='
. implode(',', cors_policy::allowedOrigins('https://partner.example.test/app'))
);
expect($allowedHeadersLabel)
->toContain('Content-Type')
->toContain('Authorization')
->toContain('X-Customer-Number')
->toContain('X-Release-Trace')
->toContain('X-Release-Channel')
->toContain('X-Frontend-Version')
->toContain('Cache-Control')
->toContain('Pragma')
->not->toContain(',*');
expect(cors_policy::traefikHeadersMiddlewareLabels('invalid middleware'))->toBe([]);
});
it('uses an origin-reflecting Traefik regex for credentialed wildcard CORS', function (): void {
$labels = cors_policy::traefikHeadersMiddlewareLabels('https-0-api-app-cors', '*');
expect($labels)
->toContain(
'traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolalloworiginlistregex='
. '^(https?://[^/]+|capacitor://[^/]+)$'
);
expect(implode("\n", $labels))->not->toContain('accesscontrolalloworiginlist=*');
});
it('requires a syntactically exact request origin', function (): void { it('requires a syntactically exact request origin', function (): void {
expect(cors_policy::normalizeRequestOrigin('capacitor://localhost')) expect(cors_policy::normalizeRequestOrigin('capacitor://localhost'))
->toBe('capacitor://localhost'); ->toBe('capacitor://localhost');
@@ -5,6 +5,7 @@ app_require('classes/release_manager_schema_bootstrap.php');
app_require('classes/coolify_api_client.php'); app_require('classes/coolify_api_client.php');
use classes\coolify_api_client; use classes\coolify_api_client;
use classes\cors_policy;
use classes\release_manager; use classes\release_manager;
class ReleaseManagerCoolifyEnvFake extends coolify_api_client class ReleaseManagerCoolifyEnvFake extends coolify_api_client
@@ -584,6 +585,9 @@ it('builds explicit Coolify application route labels for release API targets', f
'branch' => 'master', 'branch' => 'master',
], [ ], [
'coolify_ports_exposes' => '8080', 'coolify_ports_exposes' => '8080',
'runtime_env' => [
'CORS' => 'https://partner.example.test/app',
],
], 'https://api-v2.truckwash.io', 'api-app-uuid', base64_encode('custom.keep=true')); ], 'https://api-v2.truckwash.io', 'api-app-uuid', base64_encode('custom.keep=true'));
$labels = explode("\n", base64_decode($payload['custom_labels'], true)); $labels = explode("\n", base64_decode($payload['custom_labels'], true));
@@ -593,9 +597,29 @@ it('builds explicit Coolify application route labels for release API targets', f
->and($labels)->toContain('custom.keep=true') ->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.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001') ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,gzip')
->and($labels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolallowcredentials=true')
->and($labels)->toContain(
'traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlist='
. implode(',', cors_policy::allowedOrigins('https://partner.example.test/app'))
)
->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.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.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'); ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
$frontendPayload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'frontend',
'repository' => 'copenhagentruckwash/pleno-vue',
'branch' => 'master',
], [
'coolify_ports_exposes' => '80',
], 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', '');
$frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true));
expect($frontendLabels)
->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip');
expect(implode("\n", $frontendLabels))->not->toContain('-cors');
}); });
it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void { it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void {
@@ -1560,12 +1584,13 @@ it('requires non-default release channel runtime URLs and preserves load balance
'https://api-v2.truckwash.io/internal/api', 'https://api-v2.truckwash.io/internal/api',
'release-api-internal', 'release-api-internal',
80, 80,
'letsencrypt' 'letsencrypt',
'api'
); );
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)'); expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)');
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013'); expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013');
expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api'); expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api');
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-stripprefix,gzip'); expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-cors,https-0-release-api-internal-stripprefix,gzip');
$source = file(app_path('classes/release_manager.php')); $source = file(app_path('classes/release_manager.php'));
$methodSource = implode('', array_slice( $methodSource = implode('', array_slice(