Enhance Coolify integration with gateway route deployment, add tests for new application route labels, and refactor gateway probing process.
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user