From 44c4b7656fc60fb24458c8270eb1ece85ed7ca51 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 20 May 2026 12:59:51 +0200 Subject: [PATCH] Enhance Coolify integration with gateway route deployment, add tests for new application route labels, and refactor gateway probing process. --- .../nginx/app/classes/coolify_api_client.php | 5 + .../nginx/app/classes/coolify_manager.php | 1031 ++++++++++++++++- .../nginx/app/classes/release_manager.php | 263 ++++- services/nginx/app/openapi.yaml | 86 ++ .../app/routes/superuserCoolifyRoute.php | 21 + .../tests/Unit/Coolify/CoolifyManagerTest.php | 91 ++ .../ReleaseManager/ReleaseManagerTest.php | 81 +- 7 files changed, 1547 insertions(+), 31 deletions(-) diff --git a/services/nginx/app/classes/coolify_api_client.php b/services/nginx/app/classes/coolify_api_client.php index b6aa1be1..1ed88802 100644 --- a/services/nginx/app/classes/coolify_api_client.php +++ b/services/nginx/app/classes/coolify_api_client.php @@ -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); diff --git a/services/nginx/app/classes/coolify_manager.php b/services/nginx/app/classes/coolify_manager.php index 2916ea39..453ce9fc 100644 --- a/services/nginx/app/classes/coolify_manager.php +++ b/services/nginx/app/classes/coolify_manager.php @@ -10,6 +10,8 @@ class coolify_manager private const KINDS = ['database', 'redis', 'minio']; private const RESOURCE_TYPE_SERVICE = 'service'; private const DEFAULT_PUBLIC_GATEWAY_HOST = 'api-v2.truckwash.io'; + private const GATEWAY_ROUTE_VERIFY_ATTEMPTS = 24; + private const GATEWAY_ROUTE_VERIFY_DELAY_SECONDS = 5; private const REQUIRED_LOAD_BALANCER_SERVICES = [ ['protocol' => 'http', 'listen_port' => 80, 'destination_port' => 80], ['protocol' => 'tcp', 'listen_port' => 443, 'destination_port' => 443], @@ -812,14 +814,7 @@ class coolify_manager $publicHost = $this->coolifyConfigValue('public_gateway_host', self::DEFAULT_PUBLIC_GATEWAY_HOST); $result = $this->probeGatewayTarget((string)$gateway['target_ip'], $publicHost); $state = ($result['ok'] ?? false) === true ? 'ok' : 'down'; - - $this->execute( - "UPDATE coolify_instance_gateways - SET health_state = ?, last_probe_json = ?, last_probed_at = NOW() - WHERE id = ?", - 'ssi', - [$state, self::jsonEncode($result), $gatewayId] - ); + $this->recordGatewayProbe($gatewayId, $result); $this->audit(null, isset($gateway['instance_id']) ? (int)$gateway['instance_id'] : null, null, 'load_balancer_gateway_tested', $actorUserId, $state === 'ok' ? 'info' : 'warning', [ 'gateway_id' => $gatewayId, 'target_ip' => $gateway['target_ip'] ?? null, @@ -964,6 +959,217 @@ class coolify_manager ]; } + public function deployGatewayApplicationRoutes(bool $dryRun = true, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $config = $this->loadBalancerConfig(); + $publicHost = trim((string)$config['public_gateway_host']); + if ($publicHost === '') { + throw new RuntimeException('Public gateway host is required before deploying application routes.'); + } + if (!self::isPublicDnsName($publicHost)) { + throw new RuntimeException('Public gateway host must be a DNS name.'); + } + + $publicUrl = 'https://' . $publicHost; + $targets = $this->loadBalancerReleaseApiTargets(); + $planned = []; + $applied = []; + $skipped = []; + $errors = []; + $warnings = []; + $coveredTargetIps = []; + $gatewayRows = $this->listLoadBalancerGateways(true); + $enabledGatewayIps = array_values(array_unique(array_map( + static fn(array $gateway): string => (string)$gateway['target_ip'], + array_filter( + $gatewayRows, + static fn(array $gateway): bool => !empty($gateway['enabled']) && empty($gateway['deleted_at']) + ) + ))); + + if ($targets === []) { + $warnings[] = 'No Coolify-backed API release target is configured for the gateway host.'; + } + + foreach ($targets as $target) { + $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); + $instanceId = (int)($target['coolify_instance_id'] ?? 0); + $resourceType = $this->gatewayRouteResourceType($target); + $action = [ + 'type' => 'deploy_gateway_route', + 'target_id' => (int)($target['id'] ?? 0), + 'channel_slug' => $target['channel_slug'] ?? null, + 'app' => (string)($target['app'] ?? 'api'), + 'resource_uuid' => $resourceUuid, + 'resource_type' => $resourceType, + 'public_url' => $publicUrl, + 'deploy' => true, + ]; + + if ($instanceId <= 0 || $resourceUuid === '') { + $skipped[] = array_replace($action, ['reason' => 'missing_coolify_resource']); + continue; + } + + try { + $instance = $this->getInstance($instanceId); + $client = $this->clientForInstance($instance); + $resource = $resourceType === 'service' + ? $client->getService($resourceUuid) + : $client->getApplication($resourceUuid); + $targetIp = self::resourceServerIp($resource); + if ($targetIp !== null) { + $coveredTargetIps[] = $targetIp; + $action['target_ip'] = $targetIp; + } + $action['current_public_url'] = self::resourcePublicUrl($resource); + $planned[] = $action; + + if ($dryRun) { + continue; + } + + $updatePayload = $resourceType === 'service' + ? self::gatewayRouteServicePayload($publicUrl, (string)($target['app'] ?? 'api')) + : self::gatewayRouteApplicationPayload( + $publicUrl, + $resourceUuid, + self::resourceFirstExposedPort($resource, $target), + $resource['custom_labels'] ?? null + ); + $update = $resourceType === 'service' + ? $client->updateService($resourceUuid, $updatePayload) + : $client->updateApplication($resourceUuid, $updatePayload); + $deployment = $client->deployResource($resourceUuid, false); + $this->persistGatewayRouteTargetContext((int)$target['id'], $target, $publicHost, $publicUrl); + $applied[] = array_replace($action, [ + 'updated' => self::redactCoolifyResponse($update), + 'deployment' => self::redactCoolifyResponse($deployment), + ]); + } catch (Throwable $throwable) { + $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); + } + } + + $coveredTargetIps = array_values(array_unique(array_filter($coveredTargetIps))); + $uncoveredGatewayIps = array_values(array_diff($enabledGatewayIps, $coveredTargetIps)); + + if ($uncoveredGatewayIps !== [] && $targets !== []) { + $provisioned = $this->provisionMissingGatewayApiTargets( + $uncoveredGatewayIps, + $targets, + $publicHost, + $publicUrl, + $dryRun, + $actorUserId + ); + $planned = array_merge($planned, $provisioned['planned']); + $applied = array_merge($applied, $provisioned['applied']); + $skipped = array_merge($skipped, $provisioned['skipped']); + $errors = array_merge($errors, $provisioned['errors']); + $warnings = array_merge($warnings, $provisioned['warnings']); + $coveredTargetIps = array_values(array_unique(array_merge($coveredTargetIps, $provisioned['covered_target_ips']))); + $uncoveredGatewayIps = array_values(array_diff($enabledGatewayIps, $coveredTargetIps)); + } + + if ($uncoveredGatewayIps !== [] && $coveredTargetIps !== []) { + $warnings[] = 'No managed Coolify API application route was found for gateway targets: ' . implode(', ', $uncoveredGatewayIps) . '.'; + } + + $verification = null; + if (!$dryRun && $coveredTargetIps !== []) { + $verification = $this->verifyGatewayRoutes($gatewayRows, $coveredTargetIps, $publicHost); + if (($verification['ok'] ?? false) !== true) { + $warnings[] = "Gateway route and Let's Encrypt certificate verification is still failing for: " . implode(', ', $verification['failed_target_ips'] ?? []) . '.'; + } + } + + $this->audit(null, null, null, $dryRun ? 'gateway_application_routes_planned' : 'gateway_application_routes_deployed', $actorUserId, $errors === [] ? 'info' : 'warning', [ + 'dry_run' => $dryRun, + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'verification' => $verification, + ]); + + return [ + 'ok' => $errors === [], + 'dry_run' => $dryRun, + 'mutated' => !$dryRun && $errors === [], + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'verification' => $verification, + 'coverage' => [ + 'enabled_gateway_ips' => $enabledGatewayIps, + 'covered_target_ips' => $coveredTargetIps, + 'uncovered_gateway_ips' => $uncoveredGatewayIps, + ], + 'gateways' => $this->listLoadBalancerGateways(), + ]; + } + + private function verifyGatewayRoutes(array $gatewayRows, array $targetIps, string $publicHost): array + { + $targetIps = array_values(array_unique(array_filter(array_map('strval', $targetIps)))); + $pending = []; + $results = []; + $startedAt = microtime(true); + + foreach ($gatewayRows as $gateway) { + if (empty($gateway['enabled']) || !empty($gateway['deleted_at'])) { + continue; + } + $targetIp = (string)($gateway['target_ip'] ?? ''); + if ($targetIp === '' || !in_array($targetIp, $targetIps, true)) { + continue; + } + $pending[$targetIp] = $gateway; + } + + for ($attempt = 1; $attempt <= self::GATEWAY_ROUTE_VERIFY_ATTEMPTS && $pending !== []; $attempt++) { + foreach ($pending as $targetIp => $gateway) { + $probe = $this->probeGatewayTarget($targetIp, $publicHost); + $probe['attempt'] = $attempt; + $probe['max_attempts'] = self::GATEWAY_ROUTE_VERIFY_ATTEMPTS; + $this->recordGatewayProbe((int)($gateway['id'] ?? 0), $probe); + $results[$targetIp] = [ + 'gateway_id' => (int)($gateway['id'] ?? 0), + 'hostname' => $gateway['hostname'] ?? null, + 'target_ip' => $targetIp, + 'ok' => (bool)($probe['ok'] ?? false), + 'probe' => $probe, + ]; + + if (($probe['ok'] ?? false) === true) { + unset($pending[$targetIp]); + } + } + + if ($pending !== [] && $attempt < self::GATEWAY_ROUTE_VERIFY_ATTEMPTS) { + sleep(self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS); + } + } + + return [ + 'ok' => $pending === [], + 'attempts' => self::GATEWAY_ROUTE_VERIFY_ATTEMPTS, + 'delay_seconds' => self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS, + 'elapsed_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'results' => array_values($results), + 'failed_target_ips' => array_values(array_keys($pending)), + ]; + } + public function loadBalancerAutomationEnabled(): bool { $this->ensureSchema(); @@ -973,6 +1179,645 @@ class coolify_manager && $config['token_set']; } + private function loadBalancerReleaseApiTargets(): array + { + foreach (['release_deployment_targets', 'release_channels', 'release_deployments'] as $table) { + if (!$this->tableExists($table)) { + return []; + } + } + + return $this->selectRows( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, + d.id AS latest_deployment_id, d.status AS latest_deployment_status, + d.completed_at AS latest_deployment_completed_at, + d.commit_sha AS latest_deployment_commit_sha, + v.version_label AS latest_version_label + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + LEFT JOIN ( + SELECT d1.* + FROM release_deployments d1 + INNER JOIN ( + SELECT target_id, MAX(id) AS id + FROM release_deployments + WHERE app = 'api' AND status IN ('active', 'deployed') + GROUP BY target_id + ) latest ON latest.id = d1.id + ) d ON d.target_id = t.id + LEFT JOIN release_versions v ON v.id = d.version_id + WHERE t.deleted_at IS NULL + AND c.deleted_at IS NULL + AND c.enabled = 1 + AND t.app = 'api' + AND t.coolify_instance_id IS NOT NULL + AND t.coolify_service_uuid IS NOT NULL + AND TRIM(t.coolify_service_uuid) <> '' + AND d.id IS NOT NULL + ORDER BY CASE WHEN d.status = 'active' THEN 0 WHEN d.status = 'deployed' THEN 1 ELSE 2 END, + d.completed_at DESC, t.id DESC" + ); + } + + private function provisionMissingGatewayApiTargets( + array $uncoveredGatewayIps, + array $targets, + string $publicHost, + string $publicUrl, + bool $dryRun, + ?int $actorUserId + ): array { + $planned = []; + $applied = []; + $skipped = []; + $errors = []; + $warnings = []; + $coveredTargetIps = []; + $sourceTarget = $this->gatewayRouteProvisionSourceTarget($targets); + + foreach ($uncoveredGatewayIps as $targetIp) { + $action = [ + 'type' => 'provision_gateway_api_target', + 'target_ip' => $targetIp, + 'public_url' => $publicUrl, + 'dry_run' => $dryRun, + ]; + + if ($sourceTarget === null) { + $skipped[] = array_replace($action, ['reason' => 'missing_source_api_target']); + continue; + } + + $action['source_target_id'] = (int)($sourceTarget['id'] ?? 0); + $serverMatch = $this->gatewayRouteServerForTargetIp($sourceTarget, $targetIp); + if (($serverMatch['error'] ?? '') !== '') { + $errors[] = array_replace($action, ['error' => $serverMatch['error']]); + continue; + } + if (($serverMatch['ambiguous'] ?? false) === true) { + $skipped[] = array_replace($action, ['reason' => 'ambiguous_coolify_server', 'matches' => $serverMatch['matches'] ?? []]); + continue; + } + $server = is_array($serverMatch['server'] ?? null) ? $serverMatch['server'] : null; + $serverUuid = trim((string)($server['uuid'] ?? '')); + if ($server === null || $serverUuid === '') { + $skipped[] = array_replace($action, ['reason' => 'coolify_server_not_found']); + continue; + } + + $action['coolify_instance_id'] = (int)($serverMatch['instance_id'] ?? $sourceTarget['coolify_instance_id'] ?? 0); + $action['server_uuid'] = $serverUuid; + $action['server_name'] = $server['name'] ?? null; + $planned[] = $action; + + if ($dryRun) { + $coveredTargetIps[] = $targetIp; + continue; + } + + try { + if (!class_exists(release_manager::class) && function_exists('app_require')) { + app_require('classes/release_manager.php'); + } + if (!class_exists(release_manager::class)) { + throw new RuntimeException('Release Manager is required to auto-provision gateway API targets.'); + } + + $releaseManager = new release_manager(); + $deploymentTarget = $this->gatewayRouteExistingDeploymentTargetForServer($sourceTarget, $action['coolify_instance_id'], $serverUuid); + if ($deploymentTarget === null) { + $deploymentTarget = $releaseManager->upsertDeploymentTarget([ + 'channel_id' => (int)$sourceTarget['channel_id'], + 'app' => 'api', + 'coolify_instance_id' => $action['coolify_instance_id'], + 'coolify_service_uuid' => '', + 'repository' => (string)($sourceTarget['repository'] ?? ''), + 'branch' => (string)($sourceTarget['branch'] ?? 'master'), + 'auto_deploy' => !isset($sourceTarget['auto_deploy']) || (int)$sourceTarget['auto_deploy'] === 1, + 'health_url' => $publicUrl . '/ping', + 'deploy_context' => $this->gatewayRouteProvisionDeployContext($sourceTarget, $server, $targetIp, $publicHost, $publicUrl), + ], $actorUserId); + } + + $commitSha = trim((string)($sourceTarget['latest_deployment_commit_sha'] ?? '')); + $deployment = $releaseManager->startDeployment([ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'channel_id' => (int)$sourceTarget['channel_id'], + 'app' => 'api', + 'repository' => (string)($sourceTarget['repository'] ?? ''), + 'branch' => (string)($sourceTarget['branch'] ?? 'master'), + 'commit_mode' => $commitSha !== '' ? 'specific' : 'latest', + 'commit_sha' => $commitSha, + 'version_label' => $this->gatewayRouteProvisionVersionLabel($sourceTarget), + 'deployed_url' => $publicUrl, + 'metadata' => [ + 'gateway_route_autoprovision' => true, + 'source_target_id' => (int)($sourceTarget['id'] ?? 0), + 'target_ip' => $targetIp, + 'server_uuid' => $serverUuid, + ], + ], $actorUserId); + + if ((string)($deployment['status'] ?? '') !== 'deployed') { + $errors[] = array_replace($action, [ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'error' => (string)($deployment['error_message'] ?? 'Auto-provisioned API target deployment did not complete.'), + ]); + continue; + } + + $applied[] = array_replace($action, [ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'status' => $deployment['status'] ?? null, + ]); + $coveredTargetIps[] = $targetIp; + } catch (Throwable $throwable) { + $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); + } + } + + return [ + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'covered_target_ips' => $coveredTargetIps, + ]; + } + + private function gatewayRouteProvisionSourceTarget(array $targets): ?array + { + foreach ($targets as $target) { + if ((int)($target['coolify_instance_id'] ?? 0) <= 0) { + continue; + } + if (trim((string)($target['repository'] ?? '')) === '') { + continue; + } + return $target; + } + + return null; + } + + private function gatewayRouteServerForTargetIp(array $sourceTarget, string $targetIp): array + { + $instanceId = (int)($sourceTarget['coolify_instance_id'] ?? 0); + if ($instanceId <= 0) { + return ['server' => null]; + } + + try { + $instance = $this->getInstance($instanceId); + $servers = $this->coolifyCollection($this->clientForInstance($instance)->listServers()); + } catch (Throwable $throwable) { + return ['server' => null, 'error' => $throwable->getMessage()]; + } + + $matches = []; + foreach ($servers as $server) { + if (!is_array($server)) { + continue; + } + if (!self::gatewayRouteServerIsUsable($server)) { + continue; + } + if (self::gatewayRouteServerPublicIp($server) === $targetIp) { + $matches[] = $server; + } + } + + if (count($matches) > 1) { + return [ + 'server' => null, + 'ambiguous' => true, + 'matches' => array_map(static fn(array $server): array => [ + 'uuid' => $server['uuid'] ?? null, + 'name' => $server['name'] ?? null, + 'ip' => self::gatewayRouteServerPublicIp($server), + ], $matches), + ]; + } + + return [ + 'server' => $matches[0] ?? null, + 'instance_id' => $instanceId, + ]; + } + + private function gatewayRouteExistingDeploymentTargetForServer(array $sourceTarget, int $instanceId, string $serverUuid): ?array + { + if ($instanceId <= 0 || $serverUuid === '') { + return null; + } + + $rows = $this->selectRows( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + WHERE t.deleted_at IS NULL + AND t.channel_id = ? + AND t.app = 'api' + AND t.coolify_instance_id = ? + AND t.repository = ? + AND t.branch = ? + ORDER BY t.id DESC", + 'iiss', + [ + (int)$sourceTarget['channel_id'], + $instanceId, + (string)($sourceTarget['repository'] ?? ''), + (string)($sourceTarget['branch'] ?? ''), + ] + ); + + foreach ($rows as $row) { + $context = self::jsonDecode($row['deploy_context_json'] ?? null); + $candidateUuid = trim((string)($context['coolify_server_uuid'] ?? $context['server_uuid'] ?? '')); + if ($candidateUuid === $serverUuid) { + return $row; + } + } + + return null; + } + + private function gatewayRouteProvisionDeployContext(array $sourceTarget, array $server, string $targetIp, string $publicHost, string $publicUrl): array + { + $context = self::jsonDecode($sourceTarget['deploy_context_json'] ?? null); + $serverUuid = trim((string)($server['uuid'] ?? '')); + $channelSlug = self::gatewayRouteSlug((string)($sourceTarget['channel_slug'] ?? $sourceTarget['channel_id'] ?? 'release'), 'release'); + $serverSlug = self::gatewayRouteSlug((string)($server['name'] ?? $targetIp), 'server'); + $serviceName = substr('release-' . $channelSlug . '-api-' . $serverSlug, 0, 64); + + $context['coolify_auto_create'] = true; + $context['coolify_enable_ssl'] = true; + $context['coolify_deploy_now'] = true; + $context['coolify_domain'] = $publicHost; + $context['coolify_public_url'] = $publicUrl; + $context['coolify_server_uuid'] = $serverUuid; + $context['server_uuid'] = $serverUuid; + $context['coolify_destination_uuid'] = ''; + $context['destination_uuid'] = ''; + unset($context['coolify_git_commit_sha'], $context['git_commit_sha'], $context['commit_sha'], $context['commit']); + $context['coolify_service_name'] = $serviceName; + $context['coolify_application_name'] = $serviceName; + $context['gateway_route_autoprovision'] = true; + $context['gateway_route_source_target_id'] = (int)($sourceTarget['id'] ?? 0); + $context['gateway_route_target_ip'] = $targetIp; + + return $context; + } + + private function gatewayRouteProvisionVersionLabel(array $sourceTarget): string + { + $label = trim((string)($sourceTarget['latest_version_label'] ?? $sourceTarget['version_label'] ?? '')); + if ($label !== '') { + return $label; + } + + $channelSlug = self::gatewayRouteSlug((string)($sourceTarget['channel_slug'] ?? 'release'), 'release'); + return $channelSlug . '-api-gateway-' . date('Ymd-His'); + } + + private static function gatewayRouteServerIsUsable(array $server): bool + { + $settings = is_array($server['settings'] ?? null) ? $server['settings'] : []; + return ($settings['is_reachable'] ?? true) !== false && ($settings['is_usable'] ?? true) !== false; + } + + private static function gatewayRouteServerPublicIp(array $server): ?string + { + foreach ([ + 'public_ip', + 'publicIp', + 'public_ipv4', + 'publicIpv4', + 'ip', + 'address', + 'hostname', + 'fqdn', + 'domain', + 'name', + ] as $key) { + $ip = self::publicIpFromHost($server[$key] ?? null); + if ($ip !== null) { + return $ip; + } + } + + return self::publicIpFromHost(self::publicServerHostFromCoolifyServer($server)); + } + + private static function gatewayRouteSlug(string $value, string $fallback): string + { + $slug = strtolower(trim($value)); + $slug = preg_replace('/[^a-z0-9-]+/', '-', $slug) ?: ''; + $slug = trim($slug, '-'); + return substr($slug !== '' ? $slug : $fallback, 0, 40); + } + + private function gatewayRouteResourceType(array $target): string + { + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $type = strtolower(trim((string)($context['coolify_resource_type'] ?? $context['resource_type'] ?? ''))); + if (in_array($type, ['service', 'docker-compose', 'compose'], true)) { + return 'service'; + } + + return 'application'; + } + + private static function resourceServerIp(array $resource): ?string + { + $servers = []; + foreach ([ + $resource['destination']['server'] ?? null, + $resource['server'] ?? null, + $resource['server_details'] ?? null, + ] as $server) { + if (is_array($server)) { + $servers[] = $server; + } + } + + foreach ($servers as $server) { + $host = self::publicServerHostFromCoolifyServer($server); + $ip = self::publicIpFromHost($host); + if ($ip !== null) { + return $ip; + } + } + + foreach ([ + 'public_ip', + 'publicIp', + 'public_ipv4', + 'publicIpv4', + 'server_ip', + 'serverIp', + 'ip', + ] as $key) { + $ip = self::publicIpFromHost($resource[$key] ?? null); + if ($ip !== null) { + return $ip; + } + } + + return null; + } + + private static function resourcePublicUrl(array $resource): ?string + { + foreach (['fqdn', 'domains', 'domain', 'url'] as $key) { + $url = self::firstPublicUrl($resource[$key] ?? null); + if ($url !== null) { + return $url; + } + } + + return self::firstPublicUrl($resource['urls'] ?? null); + } + + private static function gatewayRouteApplicationPayload( + string $publicUrl, + string $resourceUuid = '', + ?int $port = null, + mixed $existingLabels = null + ): array + { + $payload = [ + 'domains' => $publicUrl, + 'is_force_https_enabled' => true, + 'force_domain_override' => true, + ]; + + $labels = self::gatewayRouteApplicationLabels($publicUrl, $resourceUuid, $port); + if ($labels !== []) { + $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( + self::decodeCoolifyLabels($existingLabels), + $labels + ))); + } + + return $payload; + } + + private static function gatewayRouteServicePayload(string $publicUrl, string $app): array + { + return [ + 'urls' => [ + [ + 'name' => trim($app) !== '' ? $app : 'api', + 'url' => $publicUrl, + ], + ], + 'force_domain_override' => true, + ]; + } + + private static function gatewayRouteApplicationLabels(string $publicUrl, string $resourceUuid, ?int $port = null): array + { + $resourceUuid = self::gatewayRouteLabelId($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 gatewayRouteLabelId(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('/[^a-z0-9-]+/', '-', $value) ?: ''; + return trim($value, '-'); + } + + private static function resourceFirstExposedPort(array $resource, array $target = []): ?int + { + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + foreach ([ + $resource['ports_exposes'] ?? null, + $resource['portsExposes'] ?? null, + $context['coolify_ports_exposes'] ?? null, + $context['ports_exposes'] ?? null, + $context['coolify_port'] ?? null, + $context['port'] ?? null, + ] as $value) { + $port = self::firstInteger($value); + if ($port !== null) { + return $port; + } + } + + return null; + } + + 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 persistGatewayRouteTargetContext(int $targetId, array $target, string $publicHost, string $publicUrl): void + { + if ($targetId <= 0) { + return; + } + + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $context['coolify_enable_ssl'] = true; + $context['coolify_domain'] = $publicHost; + $context['coolify_public_url'] = $publicUrl; + + $this->execute( + 'UPDATE release_deployment_targets SET deploy_context_json = ? WHERE id = ?', + 'si', + [self::jsonEncode($context), $targetId] + ); + + if (!$this->tableExists('release_deployments')) { + return; + } + + $deployment = $this->selectOne( + "SELECT id FROM release_deployments + WHERE target_id = ? AND app = 'api' AND status IN ('active', 'deployed') + ORDER BY id DESC + LIMIT 1", + 'i', + [$targetId] + ); + if ($deployment === null) { + return; + } + + $this->execute( + 'UPDATE release_deployments SET deployment_url = ? WHERE id = ?', + 'si', + [$publicUrl, (int)$deployment['id']] + ); + } + private function shouldRetryProvisioning(array $target, ?array $host = null): bool { if (trim((string)($target['resource_uuid'] ?? '')) === '') { @@ -1911,26 +2756,114 @@ class coolify_manager curl_setopt($curl, CURLOPT_NOSIGNAL, true); curl_setopt($curl, CURLOPT_HTTPHEADER, ['Accept: application/json']); curl_setopt($curl, CURLOPT_RESOLVE, [$publicHost . ':443:' . $targetIp]); - curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0); - curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2); + curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true); + if (defined('CURLOPT_CERTINFO')) { + curl_setopt($curl, CURLOPT_CERTINFO, true); + } $raw = curl_exec($curl); $error = curl_error($curl); $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $sslVerifyResult = (int)curl_getinfo($curl, CURLINFO_SSL_VERIFYRESULT); + $certificateInfo = defined('CURLINFO_CERTINFO') + ? curl_getinfo($curl, CURLINFO_CERTINFO) + : []; + $certificate = self::gatewayProbeCertificate($certificateInfo); curl_close($curl); + $trustedCertificate = $sslVerifyResult === 0; + $letsencryptCertificate = (bool)($certificate['is_letsencrypt'] ?? false); + $probeError = $raw === false ? $error : null; + if ($probeError === null && !$trustedCertificate) { + $probeError = 'Gateway TLS certificate verification failed.'; + } + if ($probeError === null && !$letsencryptCertificate) { + $probeError = "Gateway TLS certificate was not issued by Let's Encrypt."; + } + return [ - 'ok' => $raw !== false && $status >= 200 && $status < 300, + 'ok' => $raw !== false && $status >= 200 && $status < 300 && $trustedCertificate && $letsencryptCertificate, 'status_code' => $status ?: null, 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), 'host' => $publicHost, 'target_ip' => $targetIp, 'path' => '/ping', - 'error' => $raw === false ? $error : null, + 'error' => $probeError, + 'tls' => [ + 'verified' => $trustedCertificate, + 'ssl_verify_result' => $sslVerifyResult, + 'is_letsencrypt' => $letsencryptCertificate, + 'certificate' => $certificate, + ], 'checked_at' => date('c'), ]; } + private static function gatewayProbeCertificate(mixed $certificateInfo): ?array + { + if (!is_array($certificateInfo) || !is_array($certificateInfo[0] ?? null)) { + return null; + } + + $leaf = $certificateInfo[0]; + $issuer = self::certificateInfoValue($leaf, ['Issuer', 'issuer']); + $subject = self::certificateInfoValue($leaf, ['Subject', 'subject']); + $startDate = self::certificateInfoValue($leaf, ['Start date', 'Start Date', 'start date', 'startDate']); + $expireDate = self::certificateInfoValue($leaf, ['Expire date', 'Expire Date', 'expire date', 'expireDate']); + $expiresAt = self::certificateTimestamp($expireDate); + + return [ + 'subject' => $subject, + 'issuer' => $issuer, + 'start_date' => $startDate, + 'expire_date' => $expireDate, + 'expires_at' => $expiresAt !== null ? date('c', $expiresAt) : null, + 'days_until_expiry' => $expiresAt !== null ? (int)floor(($expiresAt - time()) / 86400) : null, + 'is_letsencrypt' => stripos((string)$issuer, "Let's Encrypt") !== false, + ]; + } + + private static function certificateInfoValue(array $certificate, array $keys): ?string + { + foreach ($keys as $key) { + if (isset($certificate[$key]) && is_scalar($certificate[$key])) { + $value = trim((string)$certificate[$key]); + if ($value !== '') { + return $value; + } + } + } + + return null; + } + + private static function certificateTimestamp(?string $value): ?int + { + if ($value === null || trim($value) === '') { + return null; + } + + $timestamp = strtotime($value); + return $timestamp === false ? null : $timestamp; + } + + private function recordGatewayProbe(int $gatewayId, array $probe): void + { + if ($gatewayId <= 0) { + return; + } + + $state = ($probe['ok'] ?? false) === true ? 'ok' : 'down'; + $this->execute( + "UPDATE coolify_instance_gateways + SET health_state = ?, last_probe_json = ?, last_probed_at = NOW() + WHERE id = ?", + 'ssi', + [$state, self::jsonEncode($probe), $gatewayId] + ); + } + private function availabilityStateForHost(array $host): string { $role = (string)($host['role'] ?? ''); @@ -2596,6 +3529,62 @@ class coolify_manager return $host; } + private static function publicIpFromHost(mixed $value): ?string + { + $host = self::publicServerHostCandidate($value); + if ($host === null) { + return null; + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return $host; + } + + foreach (@gethostbynamel($host) ?: [] as $address) { + $address = self::publicServerHostCandidate($address); + if ($address !== null && filter_var($address, FILTER_VALIDATE_IP) !== false) { + return $address; + } + } + + return null; + } + + private static function firstPublicUrl(mixed $value): ?string + { + if (is_string($value)) { + foreach (preg_split('/[\s,]+/', trim($value)) ?: [] as $candidate) { + $candidate = trim($candidate); + if ($candidate !== '') { + return $candidate; + } + } + return null; + } + + if (!is_array($value)) { + return null; + } + + foreach (['url', 'fqdn', 'domain', 'domains'] as $key) { + if (array_key_exists($key, $value)) { + $candidate = self::firstPublicUrl($value[$key]); + if ($candidate !== null) { + return $candidate; + } + } + } + + foreach ($value as $entry) { + $candidate = self::firstPublicUrl($entry); + if ($candidate !== null) { + return $candidate; + } + } + + return null; + } + private static function isDockerLocalOrLoopbackHost(string $host): bool { $normalized = strtolower(trim($host, '[]')); @@ -2735,6 +3724,24 @@ class coolify_manager ); } + private function tableExists(string $table): bool + { + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? ''; + if ($table === '') { + return false; + } + + try { + return $this->selectOne( + 'SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1', + 's', + [$table] + ) !== null; + } catch (Throwable) { + return false; + } + } + private function selectOne(string $sql, string $types = '', array $params = []): ?array { $rows = $this->selectRows($sql, $types, $params); diff --git a/services/nginx/app/classes/release_manager.php b/services/nginx/app/classes/release_manager.php index a49f56c3..f5bee7f6 100644 --- a/services/nginx/app/classes/release_manager.php +++ b/services/nginx/app/classes/release_manager.php @@ -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); } diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 350fcb82..648ae5c3 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -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: diff --git a/services/nginx/app/routes/superuserCoolifyRoute.php b/services/nginx/app/routes/superuserCoolifyRoute.php index 4fee2f92..9a6165c6 100644 --- a/services/nginx/app/routes/superuserCoolifyRoute.php +++ b/services/nginx/app/routes/superuserCoolifyRoute.php @@ -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; diff --git a/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php b/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php index 3cca5601..d90aafd5 100644 --- a/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php +++ b/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php @@ -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'); diff --git a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php index f454642b..9f7dda86 100644 --- a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php +++ b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php @@ -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,