From c5a12717982e3803bf2979f3aafc1ab5df1f167c Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 20 May 2026 16:45:50 +0200 Subject: [PATCH] Enhance Coolify API deployment with improved gateway route handling and extensive test coverage. Add new methods for service updates and ensure Composer vendor sanity checks in PHP container. --- Dockerfile | 5 +- .../nginx/app/classes/coolify_manager.php | 852 +++++++++++++++++- .../app/classes/hetzner_cloud_client.php | 34 +- .../nginx/app/classes/release_manager.php | 155 +++- services/nginx/app/composer.json | 4 + services/nginx/app/composer.lock | 6 +- .../modules/washcertificates/composer.json | 3 + .../modules/washcertificates/composer.lock | 12 +- services/nginx/app/openapi.yaml | 33 + .../app/routes/superuserCoolifyRoute.php | 24 + .../composer-entrypoint-autoload-recovery.sh | 69 ++ .../tests/Unit/Coolify/CoolifyManagerTest.php | 234 ++++- .../ReleaseManager/ReleaseManagerTest.php | 34 +- .../Unit/Tooling/ComposerEntrypointTest.php | 20 + services/php/Dockerfile | 3 +- services/php/docker-entrypoint.sh | 57 ++ 16 files changed, 1477 insertions(+), 68 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Tooling/ComposerEntrypointTest.php diff --git a/Dockerfile b/Dockerfile index fb3e0914..66cde638 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,8 @@ COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer # Runtime bootstrap: lightweight entrypoint to ensure Composer deps exist when app is bind-mounted COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -RUN chmod +x /usr/local/bin/docker-entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \ + && chmod +x /usr/local/bin/docker-entrypoint.sh # Install PHP dependencies through Composer (only where composer.json exists) # Main app dependencies @@ -72,4 +73,4 @@ EXPOSE 80 443 ENTRYPOINT ["docker-entrypoint.sh"] # Start services when no command is provided (docker-compose overrides this with ["php-fpm"]) -CMD ["php-fpm"] \ No newline at end of file +CMD ["php-fpm"] diff --git a/services/nginx/app/classes/coolify_manager.php b/services/nginx/app/classes/coolify_manager.php index 453ce9fc..a2f67865 100644 --- a/services/nginx/app/classes/coolify_manager.php +++ b/services/nginx/app/classes/coolify_manager.php @@ -12,9 +12,23 @@ class coolify_manager 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 GATEWAY_CERT_BOOTSTRAP_ATTEMPTS = 18; + private const GATEWAY_ROUTE_DEPLOYMENT_WAIT_ATTEMPTS = 72; + private const GATEWAY_LOAD_BALANCER_TARGET_WAIT_ATTEMPTS = 10; private const REQUIRED_LOAD_BALANCER_SERVICES = [ - ['protocol' => 'http', 'listen_port' => 80, 'destination_port' => 80], - ['protocol' => 'tcp', 'listen_port' => 443, 'destination_port' => 443], + [ + 'protocol' => 'http', + 'listen_port' => 80, + 'destination_port' => 80, + 'health_check' => ['protocol' => 'tcp', 'port' => 80, 'interval' => 15, 'timeout' => 10, 'retries' => 3], + 'http' => ['redirect_http' => false, 'sticky_sessions' => false, 'cookie_name' => 'HCLBSTICKY', 'cookie_lifetime' => 300], + ], + [ + 'protocol' => 'tcp', + 'listen_port' => 443, + 'destination_port' => 443, + 'health_check' => ['protocol' => 'tcp', 'port' => 443, 'interval' => 15, 'timeout' => 10, 'retries' => 3], + ], ]; /** @var callable|null */ @@ -904,7 +918,16 @@ class coolify_manager $config['load_balancer_id'], (string)$action['protocol'], (int)$action['listen_port'], - (int)$action['destination_port'] + (int)$action['destination_port'], + $action + ); + } elseif ($type === 'update_service') { + $client->updateService( + $config['load_balancer_id'], + (string)$action['protocol'], + (int)$action['listen_port'], + (int)$action['destination_port'], + $action ); } else { $skipped[] = array_replace($action, ['reason' => 'unknown_action']); @@ -979,6 +1002,7 @@ class coolify_manager $errors = []; $warnings = []; $coveredTargetIps = []; + $deploymentWaitItems = []; $gatewayRows = $this->listLoadBalancerGateways(true); $enabledGatewayIps = array_values(array_unique(array_map( static fn(array $gateway): string => (string)$gateway['target_ip'], @@ -1031,7 +1055,11 @@ class coolify_manager } $updatePayload = $resourceType === 'service' - ? self::gatewayRouteServicePayload($publicUrl, (string)($target['app'] ?? 'api')) + ? self::gatewayRouteServicePayload( + $publicUrl, + (string)($target['app'] ?? 'api'), + self::resourceFirstExposedPort($resource, $target) + ) : self::gatewayRouteApplicationPayload( $publicUrl, $resourceUuid, @@ -1042,6 +1070,14 @@ class coolify_manager ? $client->updateService($resourceUuid, $updatePayload) : $client->updateApplication($resourceUuid, $updatePayload); $deployment = $client->deployResource($resourceUuid, false); + $deploymentWaitItems = array_merge( + $deploymentWaitItems, + self::coolifyDeploymentWaitItems($deployment, $instanceId, $resourceUuid, [ + 'target_id' => (int)($target['id'] ?? 0), + 'target_ip' => $targetIp, + 'resource_type' => $resourceType, + ]) + ); $this->persistGatewayRouteTargetContext((int)$target['id'], $target, $publicHost, $publicUrl); $applied[] = array_replace($action, [ 'updated' => self::redactCoolifyResponse($update), @@ -1069,6 +1105,7 @@ class coolify_manager $skipped = array_merge($skipped, $provisioned['skipped']); $errors = array_merge($errors, $provisioned['errors']); $warnings = array_merge($warnings, $provisioned['warnings']); + $deploymentWaitItems = array_merge($deploymentWaitItems, $provisioned['deployment_wait_items'] ?? []); $coveredTargetIps = array_values(array_unique(array_merge($coveredTargetIps, $provisioned['covered_target_ips']))); $uncoveredGatewayIps = array_values(array_diff($enabledGatewayIps, $coveredTargetIps)); } @@ -1077,13 +1114,24 @@ class coolify_manager $warnings[] = 'No managed Coolify API application route was found for gateway targets: ' . implode(', ', $uncoveredGatewayIps) . '.'; } + $certificateBootstrap = null; $verification = null; - if (!$dryRun && $coveredTargetIps !== []) { + $deploymentWait = null; + if (!$dryRun && $deploymentWaitItems !== []) { + $deploymentWait = $this->waitForCoolifyDeployments($deploymentWaitItems); + if (($deploymentWait['ok'] ?? false) !== true) { + $warnings[] = 'Coolify deployments are still running; Let\'s Encrypt bootstrap was deferred until the next route deploy.'; + } + } + if (!$dryRun && $coveredTargetIps !== [] && ($deploymentWait === null || ($deploymentWait['ok'] ?? false) === true)) { + $certificateBootstrap = $this->bootstrapGatewayCertificates($coveredTargetIps, $publicHost, $config); + $warnings = array_merge($warnings, $certificateBootstrap['warnings'] ?? []); $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'] ?? []) . '.'; } } + $errors = array_merge($errors, self::gatewayRouteHealthErrors($certificateBootstrap, $verification)); $this->audit(null, null, null, $dryRun ? 'gateway_application_routes_planned' : 'gateway_application_routes_deployed', $actorUserId, $errors === [] ? 'info' : 'warning', [ 'dry_run' => $dryRun, @@ -1094,6 +1142,8 @@ class coolify_manager 'skipped' => $skipped, 'errors' => $errors, 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'certificate_bootstrap' => $certificateBootstrap, 'verification' => $verification, ]); @@ -1108,6 +1158,8 @@ class coolify_manager 'skipped' => $skipped, 'errors' => $errors, 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'certificate_bootstrap' => $certificateBootstrap, 'verification' => $verification, 'coverage' => [ 'enabled_gateway_ips' => $enabledGatewayIps, @@ -1118,6 +1170,346 @@ class coolify_manager ]; } + public function deployGatewayApiCode(bool $dryRun = true, bool $deployRoutes = 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 API code.'); + } + 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 = []; + $deploymentWaitItems = []; + + if ($targets === []) { + $warnings[] = 'No Coolify-backed API release target is configured for the gateway host.'; + } + + if (!$dryRun) { + 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 deploy gateway API code.'); + } + } + + $releaseManager = !$dryRun ? new release_manager() : null; + foreach ($targets as $target) { + $repository = trim((string)($target['repository'] ?? '')); + $branch = trim((string)($target['branch'] ?? 'master')) ?: 'master'; + $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); + $instanceId = (int)($target['coolify_instance_id'] ?? 0); + $action = [ + 'type' => 'deploy_gateway_api_code', + 'target_id' => (int)($target['id'] ?? 0), + 'channel_id' => (int)($target['channel_id'] ?? 0), + 'channel_slug' => $target['channel_slug'] ?? null, + 'app' => 'api', + 'repository' => $repository, + 'branch' => $branch, + 'resource_uuid' => $resourceUuid, + 'resource_type' => $this->gatewayRouteResourceType($target), + 'public_url' => $publicUrl, + 'commit_mode' => 'latest', + ]; + + if ((int)($target['id'] ?? 0) <= 0 || (int)($target['channel_id'] ?? 0) <= 0) { + $skipped[] = array_replace($action, ['reason' => 'missing_release_target']); + continue; + } + if ($instanceId <= 0 || $resourceUuid === '') { + $skipped[] = array_replace($action, ['reason' => 'missing_coolify_resource']); + continue; + } + if ($repository === '') { + $skipped[] = array_replace($action, ['reason' => 'missing_repository']); + continue; + } + + $planned[] = $action; + if ($dryRun) { + continue; + } + + try { + $deployment = $releaseManager->startDeployment([ + 'target_id' => (int)$target['id'], + 'channel_id' => (int)$target['channel_id'], + 'app' => 'api', + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => 'latest', + 'version_label' => $this->gatewayApiCodeVersionLabel($target), + 'deployed_url' => $publicUrl, + 'metadata' => [ + 'gateway_api_code_deploy' => true, + 'public_host' => $publicHost, + 'previous_deployment_id' => isset($target['latest_deployment_id']) ? (int)$target['latest_deployment_id'] : null, + 'previous_commit_sha' => $target['latest_deployment_commit_sha'] ?? null, + ], + ], $actorUserId); + + if ((string)($deployment['status'] ?? '') !== 'deployed') { + $errors[] = array_replace($action, [ + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'status' => $deployment['status'] ?? null, + 'error' => (string)($deployment['error_message'] ?? 'API code deployment did not complete.'), + 'deployment' => $deployment, + ]); + continue; + } + + $deploymentResult = is_array($deployment['result'] ?? null) ? $deployment['result'] : []; + $deploymentWaitItems = array_merge( + $deploymentWaitItems, + self::coolifyDeploymentWaitItems($deploymentResult['deployment'] ?? [], $instanceId, (string)($deploymentResult['service_uuid'] ?? $resourceUuid), [ + 'target_id' => (int)$target['id'], + 'target_ip' => self::targetIpFromDeploymentResult($deploymentResult), + 'resource_type' => (string)($deploymentResult['resource_type'] ?? $action['resource_type']), + ]) + ); + + $applied[] = array_replace($action, [ + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'version_id' => $deployment['version_id'] ?? null, + 'commit_sha' => $deployment['commit_sha'] ?? null, + 'deployment' => $deployment, + ]); + } catch (Throwable $throwable) { + $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); + } + } + + $deploymentWait = null; + if (!$dryRun && $deploymentWaitItems !== []) { + $deploymentWait = $this->waitForCoolifyDeployments($deploymentWaitItems); + if (($deploymentWait['ok'] ?? false) !== true) { + $warnings[] = 'Coolify API code deployments are still running; route and certificate deploy was deferred until the next run.'; + } + } + + $routeDeploy = null; + if (!$dryRun + && $deployRoutes + && $errors === [] + && ($deploymentWait === null || ($deploymentWait['ok'] ?? false) === true)) { + $routeDeploy = $this->deployGatewayApplicationRoutes(false, $actorUserId); + $warnings = array_merge($warnings, $routeDeploy['warnings'] ?? []); + if (($routeDeploy['ok'] ?? false) !== true) { + $errors[] = [ + 'type' => 'deploy_gateway_route_after_code', + 'error' => 'Gateway API code deployed, but route and certificate deployment did not complete.', + 'route_deploy' => $routeDeploy, + ]; + } + } + + $ok = $errors === []; + $this->audit(null, null, null, $dryRun ? 'gateway_api_code_deploy_planned' : 'gateway_api_code_deployed', $actorUserId, $ok ? 'info' : 'warning', [ + 'dry_run' => $dryRun, + 'deploy_routes' => $deployRoutes, + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'route_deploy' => $routeDeploy, + ]); + + return [ + 'ok' => $ok, + 'dry_run' => $dryRun, + 'mutated' => !$dryRun && $ok, + 'deploy_routes' => $deployRoutes, + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'route_deploy' => $routeDeploy, + 'gateways' => $this->listLoadBalancerGateways(), + ]; + } + + private function gatewayApiCodeVersionLabel(array $target): string + { + $channelSlug = trim((string)($target['channel_slug'] ?? 'gateway')); + $channelSlug = strtolower($channelSlug); + $channelSlug = preg_replace('/[^a-z0-9]+/', '-', $channelSlug) ?: ''; + $channelSlug = trim($channelSlug, '-') ?: 'gateway'; + return $channelSlug . '-api-' . date('Y-m-d-His'); + } + + private static function targetIpFromDeploymentResult(array $deploymentResult): ?string + { + $candidates = [ + $deploymentResult['target_ip'] ?? null, + $deploymentResult['server_ip'] ?? null, + ]; + foreach (['server', 'created', 'updated'] as $key) { + $row = is_array($deploymentResult[$key] ?? null) ? $deploymentResult[$key] : []; + $server = is_array($row['server'] ?? null) ? $row['server'] : ($key === 'server' ? $row : []); + $candidates[] = $server['ip'] ?? null; + $candidates[] = $server['public_ip'] ?? null; + } + + foreach ($candidates as $value) { + $value = trim((string)$value); + if ($value !== '' && filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $value; + } + } + + return null; + } + + private static function coolifyDeploymentWaitItems(array $deploymentResponse, int $instanceId, string $resourceUuid, array $context = []): array + { + if ($instanceId <= 0) { + return []; + } + + $items = []; + $deployments = is_array($deploymentResponse['deployments'] ?? null) + ? $deploymentResponse['deployments'] + : [$deploymentResponse]; + foreach ($deployments as $deployment) { + if (!is_array($deployment)) { + continue; + } + $deploymentUuid = trim((string)($deployment['deployment_uuid'] ?? $deployment['uuid'] ?? '')); + if ($deploymentUuid === '') { + continue; + } + $items[] = array_replace($context, [ + 'instance_id' => $instanceId, + 'resource_uuid' => trim((string)($deployment['resource_uuid'] ?? $resourceUuid)), + 'deployment_uuid' => $deploymentUuid, + ]); + } + + return $items; + } + + private function waitForCoolifyDeployments(array $items): array + { + $pending = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $deploymentUuid = trim((string)($item['deployment_uuid'] ?? '')); + $instanceId = (int)($item['instance_id'] ?? 0); + if ($deploymentUuid === '' || $instanceId <= 0) { + continue; + } + $pending[$deploymentUuid] = array_replace($item, [ + 'deployment_uuid' => $deploymentUuid, + 'instance_id' => $instanceId, + 'status' => 'queued', + 'last_seen' => null, + ]); + } + + $results = []; + $startedAt = microtime(true); + if ($pending === []) { + return [ + 'ok' => true, + 'skipped' => true, + 'results' => [], + 'pending' => [], + ]; + } + + for ($attempt = 1; $attempt <= self::GATEWAY_ROUTE_DEPLOYMENT_WAIT_ATTEMPTS && $pending !== []; $attempt++) { + $runningByInstance = []; + foreach (array_unique(array_map(static fn(array $item): int => (int)$item['instance_id'], $pending)) as $instanceId) { + try { + $runningByInstance[$instanceId] = $this->coolifyCollection( + $this->clientForInstance($this->getInstance($instanceId))->listDeployments() + ); + } catch (Throwable $throwable) { + foreach ($pending as $uuid => $item) { + if ((int)$item['instance_id'] !== $instanceId) { + continue; + } + $pending[$uuid]['status'] = 'unknown'; + $pending[$uuid]['error'] = $throwable->getMessage(); + } + $runningByInstance[$instanceId] = []; + } + } + + foreach ($pending as $uuid => $item) { + $running = self::findCoolifyDeployment($runningByInstance[(int)$item['instance_id']] ?? [], $uuid); + if ($running === null) { + $results[$uuid] = array_replace($item, [ + 'status' => 'finished_or_not_running', + 'attempt' => $attempt, + ]); + unset($pending[$uuid]); + continue; + } + + $status = strtolower(trim((string)($running['status'] ?? 'running'))); + $pending[$uuid]['status'] = $status; + $pending[$uuid]['last_seen'] = self::redactCoolifyResponse($running); + $pending[$uuid]['attempt'] = $attempt; + + if (in_array($status, ['finished', 'success', 'succeeded', 'failed', 'cancelled', 'canceled'], true)) { + $results[$uuid] = $pending[$uuid]; + unset($pending[$uuid]); + } + } + + if ($pending !== [] && $attempt < self::GATEWAY_ROUTE_DEPLOYMENT_WAIT_ATTEMPTS) { + sleep(self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS); + } + } + + return [ + 'ok' => $pending === [], + 'skipped' => false, + 'attempts' => self::GATEWAY_ROUTE_DEPLOYMENT_WAIT_ATTEMPTS, + 'delay_seconds' => self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS, + 'elapsed_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'results' => array_values($results), + 'pending' => array_values($pending), + ]; + } + + private static function findCoolifyDeployment(array $deployments, string $deploymentUuid): ?array + { + foreach ($deployments as $deployment) { + if (!is_array($deployment)) { + continue; + } + if (trim((string)($deployment['deployment_uuid'] ?? $deployment['uuid'] ?? '')) === $deploymentUuid) { + return $deployment; + } + } + + return null; + } + private function verifyGatewayRoutes(array $gatewayRows, array $targetIps, string $publicHost): array { $targetIps = array_values(array_unique(array_filter(array_map('strval', $targetIps)))); @@ -1170,6 +1562,203 @@ class coolify_manager ]; } + private static function gatewayRouteHealthErrors(?array $certificateBootstrap, ?array $verification): array + { + $errors = []; + + if (is_array($certificateBootstrap) && ($certificateBootstrap['ok'] ?? true) !== true) { + $errors[] = [ + 'type' => 'certificate_bootstrap_failed', + 'error' => "Let's Encrypt certificate bootstrap failed for one or more gateway targets.", + 'failed_target_ips' => self::failedCertificateBootstrapTargetIps($certificateBootstrap), + 'reason' => $certificateBootstrap['reason'] ?? null, + 'certificate_bootstrap' => $certificateBootstrap, + ]; + } + + if (is_array($verification) && ($verification['ok'] ?? true) !== true) { + $failedTargetIps = array_values(array_unique(array_filter(array_map( + static fn(mixed $targetIp): string => trim((string)$targetIp), + $verification['failed_target_ips'] ?? [] + )))); + $errors[] = [ + 'type' => 'gateway_route_verification_failed', + 'error' => "Gateway route and Let's Encrypt certificate verification is still failing.", + 'failed_target_ips' => $failedTargetIps, + 'verification' => $verification, + ]; + } + + return $errors; + } + + private static function failedCertificateBootstrapTargetIps(array $certificateBootstrap): array + { + $failedTargetIps = []; + foreach (($certificateBootstrap['results'] ?? []) as $result) { + if (!is_array($result) || ($result['ok'] ?? false) === true) { + continue; + } + $targetIp = trim((string)($result['target_ip'] ?? '')); + if ($targetIp !== '') { + $failedTargetIps[] = $targetIp; + } + } + + return array_values(array_unique($failedTargetIps)); + } + + private function bootstrapGatewayCertificates(array $targetIps, string $publicHost, array $config): array + { + $targetIps = array_values(array_unique(array_filter(array_map('strval', $targetIps)))); + $result = [ + 'ok' => true, + 'skipped' => false, + 'results' => [], + 'warnings' => [], + 'restored' => false, + ]; + + if (count($targetIps) < 2) { + $result['skipped'] = true; + $result['reason'] = 'single_target'; + return $result; + } + + if (empty($config['automation_enabled']) + || ($config['automation_mode'] ?? '') !== 'enforce' + || trim((string)($config['load_balancer_id'] ?? '')) === '' + || trim((string)($config['token'] ?? '')) === '') { + $result['skipped'] = true; + $result['ok'] = false; + $result['reason'] = 'load_balancer_enforce_required'; + $result['warnings'][] = "Let's Encrypt certificate bootstrap requires Hetzner load balancer automation in enforce mode."; + return $result; + } + + $client = $this->hetznerClient((string)$config['token']); + $loadBalancerId = (string)$config['load_balancer_id']; + $originalTargetIps = self::loadBalancerIpTargets($client->getLoadBalancer($loadBalancerId)); + $restoreTargetIps = $originalTargetIps !== [] ? $originalTargetIps : $targetIps; + + try { + foreach ($targetIps as $targetIp) { + $targetResult = [ + 'target_ip' => $targetIp, + 'ok' => false, + 'isolated' => false, + 'attempts' => self::GATEWAY_CERT_BOOTSTRAP_ATTEMPTS, + 'last_probe' => null, + ]; + + try { + $this->setLoadBalancerIpTargets($client, $loadBalancerId, [$targetIp]); + $targetResult['isolated'] = true; + + for ($attempt = 1; $attempt <= self::GATEWAY_CERT_BOOTSTRAP_ATTEMPTS; $attempt++) { + $probe = $this->probeGatewayPublicHost($publicHost); + $probe['attempt'] = $attempt; + $probe['target_ip'] = $targetIp; + $targetResult['last_probe'] = $probe; + if (($probe['ok'] ?? false) === true) { + $targetResult['ok'] = true; + break; + } + if ($attempt < self::GATEWAY_CERT_BOOTSTRAP_ATTEMPTS) { + sleep(self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS); + } + } + } catch (Throwable $throwable) { + $targetResult['error'] = $throwable->getMessage(); + } + + if (($targetResult['ok'] ?? false) !== true) { + $result['ok'] = false; + $result['warnings'][] = "Let's Encrypt certificate bootstrap failed for gateway target {$targetIp}."; + } + $result['results'][] = $targetResult; + } + } finally { + try { + $this->setLoadBalancerIpTargets($client, $loadBalancerId, $restoreTargetIps); + $result['restored'] = true; + } catch (Throwable $throwable) { + $result['ok'] = false; + $result['restore_error'] = $throwable->getMessage(); + $result['warnings'][] = 'Failed to restore Hetzner load balancer targets after certificate bootstrap: ' . $throwable->getMessage(); + } + } + + return $result; + } + + private function setLoadBalancerIpTargets(object $client, string $loadBalancerId, array $desiredIps): array + { + $desiredIps = array_values(array_unique(array_filter(array_map('strval', $desiredIps)))); + if ($desiredIps === []) { + throw new RuntimeException('At least one load balancer target must remain attached.'); + } + + $loadBalancer = $client->getLoadBalancer($loadBalancerId); + $currentIps = self::loadBalancerIpTargets($loadBalancer); + $actions = []; + + foreach (array_diff($desiredIps, $currentIps) as $ip) { + try { + $client->addIpTarget($loadBalancerId, $ip); + $actions[] = ['type' => 'add_target', 'target_ip' => $ip]; + } catch (hetzner_cloud_api_exception $exception) { + if ($exception->apiCode() !== 'target_already_defined') { + throw $exception; + } + $actions[] = ['type' => 'add_target', 'target_ip' => $ip, 'already_defined' => true]; + } + } + + $this->waitForLoadBalancerIpTargetsToInclude($client, $loadBalancerId, $desiredIps); + $loadBalancer = $client->getLoadBalancer($loadBalancerId); + $currentIps = self::loadBalancerIpTargets($loadBalancer); + + foreach (array_diff($currentIps, $desiredIps) as $ip) { + $client->removeIpTarget($loadBalancerId, $ip); + $actions[] = ['type' => 'remove_target', 'target_ip' => $ip]; + } + + $this->waitForLoadBalancerIpTargets($client, $loadBalancerId, $desiredIps); + return $actions; + } + + private function waitForLoadBalancerIpTargetsToInclude(object $client, string $loadBalancerId, array $requiredIps): void + { + $requiredIps = array_values(array_unique(array_filter(array_map('strval', $requiredIps)))); + for ($attempt = 1; $attempt <= self::GATEWAY_LOAD_BALANCER_TARGET_WAIT_ATTEMPTS; $attempt++) { + $currentIps = self::loadBalancerIpTargets($client->getLoadBalancer($loadBalancerId)); + if (array_diff($requiredIps, $currentIps) === []) { + return; + } + sleep(1); + } + + throw new RuntimeException('Timed out waiting for Hetzner load balancer targets to attach.'); + } + + private function waitForLoadBalancerIpTargets(object $client, string $loadBalancerId, array $desiredIps): void + { + $desiredIps = array_values(array_unique(array_filter(array_map('strval', $desiredIps)))); + sort($desiredIps); + + for ($attempt = 1; $attempt <= self::GATEWAY_LOAD_BALANCER_TARGET_WAIT_ATTEMPTS; $attempt++) { + $currentIps = self::loadBalancerIpTargets($client->getLoadBalancer($loadBalancerId)); + sort($currentIps); + if ($currentIps === $desiredIps) { + return; + } + sleep(1); + } + + throw new RuntimeException('Timed out waiting for Hetzner load balancer target changes.'); + } + public function loadBalancerAutomationEnabled(): bool { $this->ensureSchema(); @@ -1233,6 +1822,7 @@ class coolify_manager $errors = []; $warnings = []; $coveredTargetIps = []; + $deploymentWaitItems = []; $sourceTarget = $this->gatewayRouteProvisionSourceTarget($targets); foreach ($uncoveredGatewayIps as $targetIp) { @@ -1332,6 +1922,15 @@ class coolify_manager 'deployment_id' => (int)($deployment['id'] ?? 0), 'status' => $deployment['status'] ?? null, ]); + $deploymentResult = is_array($deployment['result'] ?? null) ? $deployment['result'] : []; + $deploymentWaitItems = array_merge( + $deploymentWaitItems, + self::coolifyDeploymentWaitItems($deploymentResult['deployment'] ?? [], $action['coolify_instance_id'], (string)($deploymentResult['service_uuid'] ?? ''), [ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'target_ip' => $targetIp, + 'resource_type' => (string)($deploymentResult['resource_type'] ?? 'application'), + ]) + ); $coveredTargetIps[] = $targetIp; } catch (Throwable $throwable) { $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); @@ -1345,6 +1944,7 @@ class coolify_manager 'errors' => $errors, 'warnings' => $warnings, 'covered_target_ips' => $coveredTargetIps, + 'deployment_wait_items' => $deploymentWaitItems, ]; } @@ -1456,13 +2056,36 @@ class coolify_manager $context['coolify_auto_create'] = true; $context['coolify_enable_ssl'] = true; $context['coolify_deploy_now'] = true; + $context['coolify_build_pack'] = 'dockerfile'; + $context['coolify_dockerfile_location'] = '/Dockerfile.coolify-api'; + $context['coolify_ports_exposes'] = '80'; + $context['coolify_port'] = '80'; $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']); + unset( + $context['coolify_base_directory'], + $context['base_directory'], + $context['coolify_docker_compose_location'], + $context['docker_compose_location'], + $context['coolify_dockerfile'], + $context['dockerfile'], + $context['coolify_git_commit_sha'], + $context['git_commit_sha'], + $context['commit_sha'], + $context['commit'], + $context['coolify_is_static'], + $context['is_static'], + $context['coolify_is_spa'], + $context['is_spa'], + $context['coolify_publish_directory'], + $context['publish_directory'], + $context['coolify_start_command'], + $context['start_command'] + ); $context['coolify_service_name'] = $serviceName; $context['coolify_application_name'] = $serviceName; $context['gateway_route_autoprovision'] = true; @@ -1589,16 +2212,23 @@ class coolify_manager mixed $existingLabels = null ): array { + $decodedLabels = self::decodeCoolifyLabels($existingLabels); + $routePort = $port ?? self::coolifyLabelFirstServicePort($decodedLabels, $resourceUuid); $payload = [ - 'domains' => $publicUrl, + 'domains' => self::coolifyProxyUrl($publicUrl, $routePort), 'is_force_https_enabled' => true, 'force_domain_override' => true, ]; - $labels = self::gatewayRouteApplicationLabels($publicUrl, $resourceUuid, $port); + $labels = self::gatewayRouteApplicationLabels( + $publicUrl, + $resourceUuid, + $routePort, + self::gatewayRouteDefaultCertResolver($publicUrl) + ); if ($labels !== []) { $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( - self::decodeCoolifyLabels($existingLabels), + $decodedLabels, $labels ))); } @@ -1606,20 +2236,44 @@ class coolify_manager return $payload; } - private static function gatewayRouteServicePayload(string $publicUrl, string $app): array + private static function gatewayRouteServicePayload(string $publicUrl, string $app, ?int $port = null): array { return [ 'urls' => [ [ 'name' => trim($app) !== '' ? $app : 'api', - 'url' => $publicUrl, + 'url' => self::coolifyProxyUrl($publicUrl, $port), ], ], 'force_domain_override' => true, ]; } - private static function gatewayRouteApplicationLabels(string $publicUrl, string $resourceUuid, ?int $port = null): array + private static function coolifyProxyUrl(string $publicUrl, ?int $port): string + { + if ($port === null || $port <= 0) { + return $publicUrl; + } + + $parts = parse_url($publicUrl); + if (!is_array($parts) || trim((string)($parts['host'] ?? '')) === '' || isset($parts['port'])) { + return $publicUrl; + } + + $scheme = trim((string)($parts['scheme'] ?? 'https')) ?: 'https'; + $host = trim((string)$parts['host']); + $path = (string)($parts['path'] ?? ''); + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; + return "{$scheme}://{$host}:{$port}{$path}{$query}{$fragment}"; + } + + private static function gatewayRouteApplicationLabels( + string $publicUrl, + string $resourceUuid, + ?int $port = null, + ?string $certResolver = null + ): array { $resourceUuid = self::gatewayRouteLabelId($resourceUuid); if ($resourceUuid === '') { @@ -1639,7 +2293,8 @@ class coolify_manager $path = '/' . $path; } - $routePort = $port ?? self::firstInteger($parts['port'] ?? null) ?? 80; + $routePort = $port ?? self::firstInteger($parts['port'] ?? null); + $certResolver = trim((string)($certResolver ?? '')); $httpLabel = 'http-0-' . $resourceUuid; $httpsLabel = 'https-0-' . $resourceUuid; $labels = [ @@ -1651,8 +2306,10 @@ class coolify_manager 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 ($routePort !== null) { + $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"; @@ -1660,18 +2317,24 @@ class coolify_manager $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip"; } $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; - $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver=letsencrypt"; + if ($certResolver !== '') { + $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; + } $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}"; + if ($routePort !== null) { + $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 ($routePort !== null) { + $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"; @@ -1698,6 +2361,53 @@ class coolify_manager return array_values($merged); } + private static function coolifyLabelFirstServicePort(array $labels, string $resourceUuid): ?int + { + $resourceUuid = self::gatewayRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.services\.([^=]+)\.loadbalancer\.server\.port=(\d+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $port = (int)$matches[2]; + if ($port <= 0) { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $port; + } + $fallback ??= $port; + } + + return $fallback; + } + + private static function coolifyLabelCertResolver(array $labels, string $resourceUuid): ?string + { + $resourceUuid = self::gatewayRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.routers\.([^=]+)\.tls\.certresolver=([A-Za-z0-9_.-]+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $resolver = trim((string)$matches[2]); + if ($resolver === '') { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $resolver; + } + $fallback ??= $resolver; + } + + return $fallback; + } + + private static function gatewayRouteDefaultCertResolver(string $publicUrl): string + { + return 'letsencrypt'; + } + private static function decodeCoolifyLabels(mixed $labels): array { if (!is_scalar($labels)) { @@ -2568,11 +3278,20 @@ class coolify_manager } foreach (self::REQUIRED_LOAD_BALANCER_SERVICES as $requiredService) { - if (self::hasLoadBalancerService($actualServices, $requiredService)) { + $actualService = self::matchingLoadBalancerService($actualServices, $requiredService); + if ($actualService === null) { + $missingServices[] = $requiredService; + $actions[] = array_replace(['type' => 'add_service'], $requiredService); continue; } - $missingServices[] = $requiredService; - $actions[] = array_replace(['type' => 'add_service'], $requiredService); + + if (!self::loadBalancerServiceHealthCheckMatches($actualService, $requiredService)) { + $actions[] = array_replace([ + 'type' => 'update_service', + 'reason' => 'health_check_drift', + 'actual_health_check' => $actualService['health_check'] ?? null, + ], $requiredService); + } } $actions = $this->guardLastLoadBalancerTarget($actions, $actualTargetIps); @@ -2675,23 +3394,64 @@ class coolify_manager 'listen_port' => (int)($service['listen_port'] ?? 0), 'destination_port' => (int)($service['destination_port'] ?? 0), 'proxyprotocol' => (bool)($service['proxyprotocol'] ?? false), + 'health_check' => is_array($service['health_check'] ?? null) ? self::normalizeLoadBalancerHealthCheck($service['health_check']) : null, ]; } return $services; } - private static function hasLoadBalancerService(array $services, array $required): bool + private static function matchingLoadBalancerService(array $services, array $required): ?array { foreach ($services as $service) { if ((string)$service['protocol'] === (string)$required['protocol'] && (int)$service['listen_port'] === (int)$required['listen_port'] && (int)$service['destination_port'] === (int)$required['destination_port'] && empty($service['proxyprotocol'])) { - return true; + return $service; } } - return false; + return null; + } + + private static function loadBalancerServiceHealthCheckMatches(array $actual, array $required): bool + { + $requiredHealthCheck = is_array($required['health_check'] ?? null) + ? self::normalizeLoadBalancerHealthCheck($required['health_check']) + : null; + if ($requiredHealthCheck === null) { + return true; + } + + $actualHealthCheck = is_array($actual['health_check'] ?? null) + ? self::normalizeLoadBalancerHealthCheck($actual['health_check']) + : null; + + return $actualHealthCheck === $requiredHealthCheck; + } + + private static function normalizeLoadBalancerHealthCheck(array $healthCheck): array + { + $normalized = [ + 'protocol' => strtolower((string)($healthCheck['protocol'] ?? '')), + 'port' => (int)($healthCheck['port'] ?? 0), + 'interval' => (int)($healthCheck['interval'] ?? 0), + 'timeout' => (int)($healthCheck['timeout'] ?? 0), + 'retries' => (int)($healthCheck['retries'] ?? 0), + ]; + + if (is_array($healthCheck['http'] ?? null)) { + $http = $healthCheck['http']; + $normalized['http'] = [ + 'domain' => (string)($http['domain'] ?? ''), + 'path' => (string)($http['path'] ?? ''), + 'response' => (string)($http['response'] ?? ''), + 'status_codes' => array_values(array_map('strval', is_array($http['status_codes'] ?? null) ? $http['status_codes'] : [])), + 'tls' => (bool)($http['tls'] ?? false), + ]; + } + + return $normalized; } private function publicLoadBalancer(array $loadBalancer): array @@ -2742,6 +3502,16 @@ class coolify_manager } private function probeGatewayTarget(string $targetIp, string $publicHost): array + { + return $this->probeGatewayEndpoint($publicHost, $targetIp); + } + + private function probeGatewayPublicHost(string $publicHost): array + { + return $this->probeGatewayEndpoint($publicHost, null); + } + + private function probeGatewayEndpoint(string $publicHost, ?string $targetIp): array { $startedAt = microtime(true); $url = 'https://' . $publicHost . '/ping'; @@ -2755,7 +3525,9 @@ class coolify_manager curl_setopt($curl, CURLOPT_TIMEOUT, 5); curl_setopt($curl, CURLOPT_NOSIGNAL, true); curl_setopt($curl, CURLOPT_HTTPHEADER, ['Accept: application/json']); - curl_setopt($curl, CURLOPT_RESOLVE, [$publicHost . ':443:' . $targetIp]); + if ($targetIp !== null && $targetIp !== '') { + curl_setopt($curl, CURLOPT_RESOLVE, [$publicHost . ':443:' . $targetIp]); + } curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2); curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true); if (defined('CURLOPT_CERTINFO')) { @@ -2774,6 +3546,7 @@ class coolify_manager $trustedCertificate = $sslVerifyResult === 0; $letsencryptCertificate = (bool)($certificate['is_letsencrypt'] ?? false); + $ping = self::gatewayProbePingContract($raw); $probeError = $raw === false ? $error : null; if ($probeError === null && !$trustedCertificate) { $probeError = 'Gateway TLS certificate verification failed.'; @@ -2781,15 +3554,19 @@ class coolify_manager if ($probeError === null && !$letsencryptCertificate) { $probeError = "Gateway TLS certificate was not issued by Let's Encrypt."; } + if ($probeError === null && !($ping['ok'] ?? false)) { + $probeError = 'Gateway ping response did not match the expected API contract.'; + } return [ - 'ok' => $raw !== false && $status >= 200 && $status < 300 && $trustedCertificate && $letsencryptCertificate, + 'ok' => $raw !== false && $status >= 200 && $status < 300 && $trustedCertificate && $letsencryptCertificate && ($ping['ok'] ?? false), 'status_code' => $status ?: null, 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), 'host' => $publicHost, 'target_ip' => $targetIp, 'path' => '/ping', 'error' => $probeError, + 'ping' => $ping, 'tls' => [ 'verified' => $trustedCertificate, 'ssl_verify_result' => $sslVerifyResult, @@ -2800,6 +3577,25 @@ class coolify_manager ]; } + private static function gatewayProbePingContract(mixed $raw): array + { + if (!is_string($raw) || trim($raw) === '') { + return ['ok' => false, 'reason' => 'empty_response']; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return ['ok' => false, 'reason' => 'invalid_json']; + } + + $data = is_array($decoded['data'] ?? null) ? $decoded['data'] : []; + $message = strtolower(trim((string)($data['message'] ?? ''))); + return [ + 'ok' => ($decoded['success'] ?? false) === true && $message === 'pong', + 'message' => $data['message'] ?? null, + ]; + } + private static function gatewayProbeCertificate(mixed $certificateInfo): ?array { if (!is_array($certificateInfo) || !is_array($certificateInfo[0] ?? null)) { diff --git a/services/nginx/app/classes/hetzner_cloud_client.php b/services/nginx/app/classes/hetzner_cloud_client.php index 9698c2d8..cae49d60 100644 --- a/services/nginx/app/classes/hetzner_cloud_client.php +++ b/services/nginx/app/classes/hetzner_cloud_client.php @@ -57,14 +57,42 @@ class hetzner_cloud_client ]); } - public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort): array + public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array { - return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_service', [ + return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_service', self::servicePayload( + $protocol, + $listenPort, + $destinationPort, + $options + )); + } + + public function updateService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array + { + return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/update_service', self::servicePayload( + $protocol, + $listenPort, + $destinationPort, + $options + )); + } + + private static function servicePayload(string $protocol, int $listenPort, int $destinationPort, array $options): array + { + $payload = [ 'protocol' => strtolower($protocol), 'listen_port' => $listenPort, 'destination_port' => $destinationPort, 'proxyprotocol' => false, - ]); + ]; + + foreach (['health_check', 'http'] as $key) { + if (isset($options[$key]) && is_array($options[$key])) { + $payload[$key] = $options[$key]; + } + } + + return $payload; } private function request(string $method, string $path, ?array $payload = null): array diff --git a/services/nginx/app/classes/release_manager.php b/services/nginx/app/classes/release_manager.php index f5bee7f6..cf1b6d40 100644 --- a/services/nginx/app/classes/release_manager.php +++ b/services/nginx/app/classes/release_manager.php @@ -14,6 +14,7 @@ class release_manager private const STACK_DATA_KINDS = ['database', 'redis', 'minio']; private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod', 'beta']; private const DEFAULT_COOLIFY_APPLICATION_PORT = '80'; + private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api'; private const SUBJECT_TYPES = ['user', 'subuser', 'customer']; private const CAPTURE_LEVELS = ['metadata', 'full_redacted', 'full']; private const MODULE_KEYS = [ @@ -2703,7 +2704,7 @@ class release_manager 'urls' => [ [ 'name' => (string)($target['app'] ?? 'release'), - 'url' => $publicUrl, + 'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)), ], ], 'force_domain_override' => true, @@ -2770,7 +2771,7 @@ class release_manager } if ($publicUrl !== null) { - $payload['domains'] = $publicUrl; + $payload['domains'] = self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)); $payload['is_force_https_enabled'] = $this->toBool($context['coolify_enable_ssl'] ?? false); } @@ -2789,8 +2790,11 @@ class release_manager mixed $existingLabels = null ): array { + $decodedLabels = self::decodeCoolifyLabels($existingLabels); + $routePort = $this->releaseCoolifyProxyPort($target, $context) + ?? self::coolifyLabelFirstServicePort($decodedLabels, $resourceUuid); $payload = [ - 'domains' => $publicUrl, + 'domains' => self::coolifyProxyUrl($publicUrl, $routePort), 'is_force_https_enabled' => true, 'force_domain_override' => true, ]; @@ -2798,11 +2802,12 @@ class release_manager $labels = self::releaseCoolifyApplicationLabels( $publicUrl, $resourceUuid, - self::firstInteger($this->releaseCoolifyPortsExposes($target, $context)) ?? 80 + $routePort, + self::gatewayRouteDefaultCertResolver($publicUrl) ); if ($labels !== []) { $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( - self::decodeCoolifyLabels($existingLabels), + $decodedLabels, $labels ))); } @@ -2831,7 +2836,12 @@ 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 + private static function releaseCoolifyApplicationLabels( + string $publicUrl, + string $resourceUuid, + ?int $port = null, + ?string $certResolver = null + ): array { $resourceUuid = self::coolifyRouteLabelId($resourceUuid); if ($resourceUuid === '') { @@ -2851,7 +2861,8 @@ class release_manager $path = '/' . $path; } - $routePort = $port ?? self::firstInteger($parts['port'] ?? null) ?? 80; + $routePort = $port ?? self::firstInteger($parts['port'] ?? null); + $certResolver = trim((string)($certResolver ?? '')); $httpLabel = 'http-0-' . $resourceUuid; $httpsLabel = 'https-0-' . $resourceUuid; $labels = [ @@ -2863,8 +2874,10 @@ class release_manager 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 ($routePort !== null) { + $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"; @@ -2872,18 +2885,24 @@ class release_manager $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip"; } $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; - $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver=letsencrypt"; + if ($certResolver !== '') { + $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; + } $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}"; + if ($routePort !== null) { + $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 ($routePort !== null) { + $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"; @@ -2910,6 +2929,53 @@ class release_manager return array_values($merged); } + private static function coolifyLabelFirstServicePort(array $labels, string $resourceUuid): ?int + { + $resourceUuid = self::coolifyRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.services\.([^=]+)\.loadbalancer\.server\.port=(\d+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $port = (int)$matches[2]; + if ($port <= 0) { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $port; + } + $fallback ??= $port; + } + + return $fallback; + } + + private static function coolifyLabelCertResolver(array $labels, string $resourceUuid): ?string + { + $resourceUuid = self::coolifyRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.routers\.([^=]+)\.tls\.certresolver=([A-Za-z0-9_.-]+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $resolver = trim((string)$matches[2]); + if ($resolver === '') { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $resolver; + } + $fallback ??= $resolver; + } + + return $fallback; + } + + private static function gatewayRouteDefaultCertResolver(string $publicUrl): string + { + return 'letsencrypt'; + } + private static function decodeCoolifyLabels(mixed $labels): array { if (!is_scalar($labels)) { @@ -2970,6 +3036,25 @@ class release_manager return $integer > 0 ? $integer : null; } + private static function coolifyProxyUrl(string $publicUrl, ?int $port): string + { + if ($port === null || $port <= 0) { + return $publicUrl; + } + + $parts = parse_url($publicUrl); + if (!is_array($parts) || trim((string)($parts['host'] ?? '')) === '' || isset($parts['port'])) { + return $publicUrl; + } + + $scheme = trim((string)($parts['scheme'] ?? 'https')) ?: 'https'; + $host = trim((string)$parts['host']); + $path = (string)($parts['path'] ?? ''); + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; + return "{$scheme}://{$host}:{$port}{$path}{$query}{$fragment}"; + } + private function releaseCoolifyServicePayload(array $target, array $context, array $instance): array { $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); @@ -3016,7 +3101,7 @@ class release_manager $payload['urls'] = [ [ 'name' => (string)($target['app'] ?? 'release'), - 'url' => $publicUrl, + 'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)), ], ]; } @@ -3189,6 +3274,30 @@ class release_manager return self::DEFAULT_COOLIFY_APPLICATION_PORT; } + private function releaseCoolifyProxyPort(array $target, array $context): ?int + { + foreach ([ + 'coolify_ports_exposes', + 'ports_exposes', + 'coolify_exposed_port', + 'exposed_port', + 'coolify_port', + 'port', + ] as $key) { + $port = self::firstInteger($context[$key] ?? null); + if ($port !== null) { + return $port; + } + } + + $app = strtolower(trim((string)($target['app'] ?? ''))); + if ($app !== 'api') { + return null; + } + + return self::firstInteger($this->releaseCoolifyPortsExposes($target, $context)); + } + private function releaseCoolifyGitCommitSha(array $target, array $context): string { foreach ([ @@ -3217,10 +3326,16 @@ class release_manager private function releaseCoolifyApplicationDefaultFields(array $target, array $context): array { - if (strtolower(trim((string)($target['app'] ?? ''))) !== 'frontend') { - return []; + $app = strtolower(trim((string)($target['app'] ?? ''))); + $buildPack = $this->releaseCoolifyBuildPack($target, $context); + + if ($app === 'api' && $buildPack === 'dockerfile') { + return [ + 'dockerfile_location' => self::DEFAULT_COOLIFY_API_DOCKERFILE, + ]; } - if ($this->releaseCoolifyBuildPack($target, $context) !== 'static') { + + if ($app !== 'frontend' || $buildPack !== 'static') { return []; } @@ -3417,6 +3532,10 @@ class release_manager return $baseUrl; } + if ($this->toBool($context['gateway_route_autoprovision'] ?? false)) { + return $baseUrl; + } + $path = trim((string)($parts['path'] ?? ''), '/'); if ($path !== '') { return $baseUrl; diff --git a/services/nginx/app/composer.json b/services/nginx/app/composer.json index f152dd53..22faa24e 100644 --- a/services/nginx/app/composer.json +++ b/services/nginx/app/composer.json @@ -69,6 +69,10 @@ "modules/", "routes/", "statistics/" + ], + "exclude-from-classmap": [ + "modules/*/vendor/", + "modules/*/vendor/**" ] }, "config": { diff --git a/services/nginx/app/composer.lock b/services/nginx/app/composer.lock index a6697be9..e64c5ae9 100644 --- a/services/nginx/app/composer.lock +++ b/services/nginx/app/composer.lock @@ -7530,7 +7530,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -7539,6 +7539,6 @@ "ext-curl": "*", "ext-json": "*" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/services/nginx/app/modules/washcertificates/composer.json b/services/nginx/app/modules/washcertificates/composer.json index 628892b6..63add6e9 100644 --- a/services/nginx/app/modules/washcertificates/composer.json +++ b/services/nginx/app/modules/washcertificates/composer.json @@ -19,5 +19,8 @@ "setasign/fpdi": "^2.6", "setasign/fpdf": "^1.8", "ext-mysqli": "*" + }, + "config": { + "secure-http": false } } diff --git a/services/nginx/app/modules/washcertificates/composer.lock b/services/nginx/app/modules/washcertificates/composer.lock index 944fc255..8dcbfd89 100644 --- a/services/nginx/app/modules/washcertificates/composer.lock +++ b/services/nginx/app/modules/washcertificates/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5c2ab021deb58020ce7d1f7f06e14f19", + "content-hash": "d6a6015f0fa919d0d8fd2b111e901572", "packages": [ { "name": "dompdf/dompdf", @@ -1366,10 +1366,12 @@ "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform": { + "ext-mysqli": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 648ae5c3..557ebd08 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -11074,6 +11074,39 @@ paths: '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } + /superuser/coolify/load-balancer/api/deploy: + post: + tags: + - Superuser + summary: Deploy the latest Coolify API code for the public gateway host + operationId: deploySuperuserCoolifyGatewayApiCode + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + dry_run: + type: boolean + default: true + enforce: + type: boolean + default: false + deploy_routes: + type: boolean + default: true + responses: + '200': + description: Gateway API code deployment 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: diff --git a/services/nginx/app/routes/superuserCoolifyRoute.php b/services/nginx/app/routes/superuserCoolifyRoute.php index 9a6165c6..7cab8d70 100644 --- a/services/nginx/app/routes/superuserCoolifyRoute.php +++ b/services/nginx/app/routes/superuserCoolifyRoute.php @@ -73,6 +73,30 @@ class superuserCoolifyRoute 'superuser_coolify_manage' => 'Deploy the Coolify API application route for the public gateway host', ]); + $this->post('/superuser/coolify/load-balancer/api/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); + $deployRoutes = array_key_exists('deploy_routes', $parameters) + ? filter_var($parameters['deploy_routes'], FILTER_VALIDATE_BOOLEAN) + : true; + $result = (new coolify_manager())->deployGatewayApiCode($dryRun, $deployRoutes, $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 latest Coolify API code for the public gateway host', + ]); + $this->get('/superuser/coolify/gateways', function () { global $response; diff --git a/services/nginx/app/tests/Tooling/composer-entrypoint-autoload-recovery.sh b/services/nginx/app/tests/Tooling/composer-entrypoint-autoload-recovery.sh index a43bcb6f..c5860829 100644 --- a/services/nginx/app/tests/Tooling/composer-entrypoint-autoload-recovery.sh +++ b/services/nginx/app/tests/Tooling/composer-entrypoint-autoload-recovery.sh @@ -2,8 +2,12 @@ set -eu tmp_dir="$(mktemp -d)" +psr_tmp_dir="" cleanup() { rm -rf "$tmp_dir" + if [ -n "$psr_tmp_dir" ]; then + rm -rf "$psr_tmp_dir" + fi } trap cleanup EXIT HUP INT TERM @@ -40,10 +44,75 @@ AUTO_COMPOSER_INSTALL=true \ APP_DIR="$tmp_dir" \ MODULE_DIR="$tmp_dir/no-module" \ LOG_FILE="$tmp_dir/composer-install.log" \ +REDIS_CONFIG_HOST= \ /usr/local/bin/docker-entrypoint.sh \ php -r "require \$argv[1]; echo class_exists('FixtureClass') ? 'autoload-ok' . PHP_EOL : 'autoload-missing' . PHP_EOL;" \ "$tmp_dir/vendor/autoload.php" >/dev/null php -d display_errors=1 -r "require \$argv[1]; exit(class_exists('FixtureClass') ? 0 : 1);" "$tmp_dir/vendor/autoload.php" +psr_tmp_dir="$(mktemp -d)" +cat > "$psr_tmp_dir/composer.json" <<'JSON' +{ + "name": "truckwash/composer-entrypoint-psr-fixture", + "require": { + "psr/http-message": "^2.0" + } +} +JSON + +COMPOSER_ALLOW_SUPERUSER=1 composer install \ + --no-dev \ + --prefer-dist \ + --optimize-autoloader \ + --no-interaction \ + -d "$psr_tmp_dir" >/dev/null 2>&1 + +cat > "$psr_tmp_dir/corrupt-autoload.php" <<'PHP' + "__DIR__ . '/../..' . '/modules/washcertificates/vendor/psr/http-message/src/StreamInterface.php'", + "__DIR__ . '/..' . '/psr/http-message/src/UriInterface.php'" => "__DIR__ . '/../..' . '/modules/washcertificates/vendor/psr/http-message/src/UriInterface.php'", + "\$vendorDir . '/psr/http-message/src/StreamInterface.php'" => "\$vendorDir . '/../modules/washcertificates/vendor/psr/http-message/src/StreamInterface.php'", + "\$vendorDir . '/psr/http-message/src/UriInterface.php'" => "\$vendorDir . '/../modules/washcertificates/vendor/psr/http-message/src/UriInterface.php'", +]; + +foreach (['autoload_static.php', 'autoload_classmap.php'] as $file) { + $path = $dir . '/vendor/composer/' . $file; + $contents = file_get_contents($path); + if ($contents === false) { + fwrite(STDERR, "Unable to read $path\n"); + exit(1); + } + + $updated = str_replace(array_keys($replacements), array_values($replacements), $contents); + if ($updated === $contents) { + fwrite(STDERR, "Fixture did not corrupt $path\n"); + exit(1); + } + + file_put_contents($path, $updated); +} +PHP + +php "$psr_tmp_dir/corrupt-autoload.php" "$psr_tmp_dir" + +if php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') ? 0 : 1);" "$psr_tmp_dir/vendor/autoload.php" >/dev/null 2>&1; then + echo "fixture failed to corrupt psr/http-message autoload map" >&2 + exit 1 +fi + +AUTO_COMPOSER_INSTALL=true \ +APP_DIR="$psr_tmp_dir" \ +MODULE_DIR="$psr_tmp_dir/no-module" \ +LOG_FILE="$psr_tmp_dir/composer-install.log" \ +REDIS_CONFIG_HOST= \ +/usr/local/bin/docker-entrypoint.sh \ +php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') && interface_exists('Psr\\\\Http\\\\Message\\\\StreamInterface') ? 0 : 1);" \ +"$psr_tmp_dir/vendor/autoload.php" + +php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') && interface_exists('Psr\\\\Http\\\\Message\\\\StreamInterface') ? 0 : 1);" "$psr_tmp_dir/vendor/autoload.php" + echo "composer-entrypoint-autoload-recovery-ok" diff --git a/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php b/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php index d90aafd5..8b18ab56 100644 --- a/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php +++ b/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php @@ -6,6 +6,69 @@ app_require('classes/coolify_manager.php'); use classes\coolify_api_client; use classes\coolify_manager; +class CoolifyManagerHetznerTargetSetFake +{ + public array $targets; + + public function __construct(array $targets) + { + $this->targets = array_values($targets); + } + + public function getLoadBalancer(int|string $id): array + { + return [ + 'id' => $id, + 'targets' => array_map( + static fn(string $ip): array => ['type' => 'ip', 'ip' => ['ip' => $ip]], + $this->targets + ), + 'services' => [], + ]; + } + + public function addIpTarget(int|string $loadBalancerId, string $ip): array + { + if (!in_array($ip, $this->targets, true)) { + $this->targets[] = $ip; + } + return []; + } + + public function removeIpTarget(int|string $loadBalancerId, string $ip): array + { + $this->targets = array_values(array_filter($this->targets, static fn(string $target): bool => $target !== $ip)); + return []; + } + + public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array + { + return []; + } + + public function updateService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array + { + return []; + } +} + +function coolifyManagerTestLoadBalancerService(string $protocol, int $listenPort, int $destinationPort): array +{ + return [ + 'protocol' => $protocol, + 'listen_port' => $listenPort, + 'destination_port' => $destinationPort, + 'proxyprotocol' => false, + 'health_check' => [ + 'protocol' => 'tcp', + 'port' => $listenPort, + 'interval' => 15, + 'timeout' => 10, + 'retries' => 3, + ], + ]; +} + it('normalizes Coolify API base URLs to the v1 API root', function (): void { expect(coolify_api_client::normalizeBaseUrl('https://coolify.example.com'))->toBe('https://coolify.example.com/api/v1'); expect(coolify_api_client::normalizeBaseUrl('https://coolify.example.com/api/v1'))->toBe('https://coolify.example.com/api/v1'); @@ -133,7 +196,7 @@ it('plans Hetzner load balancer target and service drift without mutating state' ['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']], ], 'services' => [ - ['protocol' => 'http', 'listen_port' => 80, 'destination_port' => 80, 'proxyprotocol' => false], + coolifyManagerTestLoadBalancerService('http', 80, 80), ], ], [ ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true], @@ -146,13 +209,66 @@ it('plans Hetzner load balancer target and service drift without mutating state' ->and($plan['missing_targets'])->toContain('65.21.214.30') ->and($actionTypes)->toContain('add_target') ->and($actionTypes)->toContain('add_service') - ->and($plan['missing_services'])->toContain([ + ->and($plan['missing_services'][0])->toMatchArray([ 'protocol' => 'tcp', 'listen_port' => 443, 'destination_port' => 443, ]); }); +it('plans Hetzner load balancer service health check drift updates', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile'); + $method->setAccessible(true); + + $plan = $method->invoke($manager, [ + 'targets' => [ + ['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']], + ], + 'services' => [ + [ + 'protocol' => 'http', + 'listen_port' => 80, + 'destination_port' => 80, + 'proxyprotocol' => false, + 'health_check' => [ + 'protocol' => 'http', + 'port' => 80, + 'interval' => 15, + 'timeout' => 10, + 'retries' => 3, + 'http' => [ + 'domain' => '', + 'path' => '/', + 'response' => '', + 'status_codes' => ['2??', '3??'], + 'tls' => false, + ], + ], + ], + coolifyManagerTestLoadBalancerService('tcp', 443, 443), + ], + ], [ + ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true], + ]); + + expect($plan['actions'])->toHaveCount(1) + ->and($plan['actions'][0])->toMatchArray([ + 'type' => 'update_service', + 'reason' => 'health_check_drift', + 'protocol' => 'http', + 'listen_port' => 80, + 'destination_port' => 80, + 'health_check' => [ + 'protocol' => 'tcp', + 'port' => 80, + 'interval' => 15, + 'timeout' => 10, + 'retries' => 3, + ], + ]); +}); + it('does not plan removal of the last Hetzner load balancer target', function (): void { $manager = new coolify_manager(); $method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile'); @@ -163,8 +279,8 @@ it('does not plan removal of the last Hetzner load balancer target', function () ['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']], ], 'services' => [ - ['protocol' => 'http', 'listen_port' => 80, 'destination_port' => 80, 'proxyprotocol' => false], - ['protocol' => 'tcp', 'listen_port' => 443, 'destination_port' => 443, 'proxyprotocol' => false], + coolifyManagerTestLoadBalancerService('http', 80, 80), + coolifyManagerTestLoadBalancerService('tcp', 443, 443), ], ], [ ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => false], @@ -186,8 +302,8 @@ it('plans removal only for disabled or deleted Hetzner load balancer targets', f ['type' => 'ip', 'ip' => ['ip' => '65.21.214.30']], ], 'services' => [ - ['protocol' => 'http', 'listen_port' => 80, 'destination_port' => 80, 'proxyprotocol' => false], - ['protocol' => 'tcp', 'listen_port' => 443, 'destination_port' => 443, 'proxyprotocol' => false], + coolifyManagerTestLoadBalancerService('http', 80, 80), + coolifyManagerTestLoadBalancerService('tcp', 443, 443), ], ], [ ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true], @@ -222,6 +338,10 @@ it('builds gateway API auto-provision context for connected Coolify servers', fu 'channel_slug' => 'internal', 'deploy_context_json' => json_encode([ 'coolify_project_uuid' => 'project-internal', + 'coolify_base_directory' => 'services/nginx/app', + 'coolify_dockerfile_location' => 'services/php/Dockerfile', + 'coolify_ports_exposes' => '9000', + 'coolify_start_command' => 'php-fpm', 'coolify_destination_uuid' => 'source-destination', 'coolify_git_commit_sha' => 'source-commit', 'coolify_enable_ssl' => false, @@ -233,6 +353,10 @@ it('builds gateway API auto-provision context for connected Coolify servers', fu 'coolify_auto_create' => true, 'coolify_enable_ssl' => true, 'coolify_deploy_now' => true, + 'coolify_build_pack' => 'dockerfile', + 'coolify_dockerfile_location' => '/Dockerfile.coolify-api', + 'coolify_ports_exposes' => '80', + 'coolify_port' => '80', 'coolify_domain' => 'api-v2.truckwash.io', 'coolify_public_url' => 'https://api-v2.truckwash.io', 'coolify_server_uuid' => 'server-node1', @@ -245,6 +369,8 @@ it('builds gateway API auto-provision context for connected Coolify servers', fu 'gateway_route_target_ip' => '94.130.142.41', ]); expect($context)->not->toHaveKey('coolify_git_commit_sha'); + expect($context)->not->toHaveKey('coolify_base_directory'); + expect($context)->not->toHaveKey('coolify_start_command'); }); it('adds explicit Coolify application route labels for gateway API domains', function (): void { @@ -254,10 +380,12 @@ it('adds explicit Coolify application route labels for gateway API domains', fun $payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [ 'custom.keep=true', 'traefik.http.routers.https-0-api-app-uuid.entryPoints=old', + 'traefik.http.routers.https-0-api-app-uuid.tls.certresolver=dns-cloudflare', + 'traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=9090', ]))); $labels = explode("\n", base64_decode($payload['custom_labels'], true)); - expect($payload['domains'])->toBe('https://api-v2.truckwash.io') + expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080') ->and($payload['is_force_https_enabled'])->toBeTrue() ->and($payload['force_domain_override'])->toBeTrue() ->and($labels)->toContain('custom.keep=true') @@ -268,6 +396,81 @@ it('adds explicit Coolify application route labels for gateway API domains', fun ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080'); }); +it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'setLoadBalancerIpTargets'); + $method->setAccessible(true); + $client = new CoolifyManagerHetznerTargetSetFake([ + '94.130.142.41', + '65.21.214.30', + '23.88.23.183', + ]); + + $method->invoke($manager, $client, '6366569', ['65.21.214.30']); + $isolated = $client->targets; + sort($isolated); + + $method->invoke($manager, $client, '6366569', [ + '94.130.142.41', + '65.21.214.30', + '23.88.23.183', + ]); + $restored = $client->targets; + sort($restored); + + expect($isolated)->toBe(['65.21.214.30']) + ->and($restored)->toBe([ + '23.88.23.183', + '65.21.214.30', + '94.130.142.41', + ]); +}); + +it('requires gateway ping probes to return the API ping contract', function (): void { + $method = new ReflectionMethod(coolify_manager::class, 'gatewayProbePingContract'); + $method->setAccessible(true); + + expect($method->invoke(null, json_encode([ + 'success' => true, + 'data' => ['message' => 'pong'], + ])))->toMatchArray(['ok' => true, 'message' => 'pong']); + + expect($method->invoke(null, 'Fatal error'))->toMatchArray([ + 'ok' => false, + 'reason' => 'invalid_json', + ]); +}); + +it('returns structured errors for failed gateway certificate bootstrap and verification', function (): void { + $method = new ReflectionMethod(coolify_manager::class, 'gatewayRouteHealthErrors'); + $method->setAccessible(true); + + $errors = $method->invoke(null, [ + 'ok' => false, + 'reason' => 'load_balancer_enforce_required', + 'results' => [ + ['target_ip' => '94.130.142.41', 'ok' => false], + ['target_ip' => '65.21.214.30', 'ok' => true], + ['target_ip' => '23.88.23.183', 'ok' => false], + ], + ], [ + 'ok' => false, + 'failed_target_ips' => ['94.130.142.41', '23.88.23.183'], + 'results' => [], + ]); + + expect($errors)->toHaveCount(2) + ->and($errors[0])->toMatchArray([ + 'type' => 'certificate_bootstrap_failed', + 'reason' => 'load_balancer_enforce_required', + 'failed_target_ips' => ['94.130.142.41', '23.88.23.183'], + ]) + ->and($errors[1])->toMatchArray([ + 'type' => 'gateway_route_verification_failed', + 'failed_target_ips' => ['94.130.142.41', '23.88.23.183'], + ]); +}); + 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')); @@ -298,6 +501,7 @@ it('defines Coolify schema, route permissions, and replication integration hooks 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/load-balancer/api/deploy'); expect($route)->toContain('/superuser/coolify/gateways'); expect($route)->toContain('/superuser/coolify/gateways/{id}/test'); expect($route)->toContain('/superuser/coolify/instances/{id}/test'); @@ -364,6 +568,9 @@ it('defines Coolify schema, route permissions, and replication integration hooks expect($manager)->toContain('loadBalancerSummary'); expect($manager)->toContain('reconcileLoadBalancer'); expect($manager)->toContain('deployGatewayApplicationRoutes'); + expect($manager)->toContain('deployGatewayApiCode'); + expect($manager)->toContain('gateway_api_code_deploy'); + expect($manager)->toContain('deploy_gateway_route_after_code'); expect($manager)->toContain('loadBalancerReleaseApiTargets'); expect($manager)->toContain('provisionMissingGatewayApiTargets'); expect($manager)->toContain('provision_gateway_api_target'); @@ -371,6 +578,13 @@ it('defines Coolify schema, route permissions, and replication integration hooks expect($manager)->toContain('upsertDeploymentTarget'); expect($manager)->toContain('startDeployment'); expect($manager)->toContain('verifyGatewayRoutes'); + expect($manager)->toContain('bootstrapGatewayCertificates'); + expect($manager)->toContain('setLoadBalancerIpTargets'); + expect($manager)->toContain('probeGatewayPublicHost'); + expect($manager)->toContain('GATEWAY_CERT_BOOTSTRAP_ATTEMPTS'); + expect($manager)->toContain('certificate_bootstrap'); + expect($manager)->toContain('certificate_bootstrap_failed'); + expect($manager)->toContain('gateway_route_verification_failed'); 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'); @@ -378,6 +592,8 @@ it('defines Coolify schema, route permissions, and replication integration hooks 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)->toContain('gatewayProbePingContract'); + expect($manager)->toContain('Gateway ping response did not match the expected API contract.'); expect($manager)->not->toContain('CURLOPT_SSL_VERIFYHOST, 0'); expect($manager)->not->toContain('CURLOPT_SSL_VERIFYPEER, false'); expect($manager)->toContain('information_schema.tables'); @@ -388,6 +604,9 @@ it('defines Coolify schema, route permissions, and replication integration hooks expect($manager)->toContain('REQUIRED_LOAD_BALANCER_SERVICES'); expect($manager)->toContain('skip_remove_target'); + $composer = json_decode((string)file_get_contents(app_path('composer.json')), true); + expect($composer['autoload']['exclude-from-classmap'] ?? [])->toContain('modules/*/vendor/'); + $client = file_get_contents(app_path('classes/coolify_api_client.php')); expect($client)->toContain("request('GET', '/health', null, false)"); expect($client)->toContain('/github-apps'); @@ -413,6 +632,7 @@ it('defines Coolify schema, route permissions, and replication integration hooks expect($openapi)->toContain('operationId: getSuperuserCoolifyLoadBalancer'); expect($openapi)->toContain('operationId: reconcileSuperuserCoolifyLoadBalancer'); expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayRoutes'); + expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayApiCode'); 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 9f7dda86..e7fbce1d 100644 --- a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php +++ b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php @@ -200,6 +200,38 @@ it('creates Coolify GitHub App application payloads so pulls use the app token', expect($payload)->not->toHaveKey('docker_compose_raw'); }); +it('uses the self-contained Coolify API Dockerfile for API applications', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload'); + $payloadMethod->setAccessible(true); + + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'auto_deploy' => 1, + ], [ + 'coolify_service_name' => 'release-internal-api-node3-truckwash-io', + 'coolify_project_uuid' => 'project-internal', + 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', + 'coolify_deploy_now' => true, + 'coolify_public_url' => 'https://api-v2.truckwash.io', + 'coolify_enable_ssl' => true, + 'gateway_route_autoprovision' => true, + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-node3', + ]); + + expect($payload['build_pack'])->toBe('dockerfile'); + expect($payload['ports_exposes'])->toBe('80'); + expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api'); + expect($payload['domains'])->toBe('https://api-v2.truckwash.io:80'); + expect($payload)->not->toHaveKey('publish_directory'); + expect($payload)->not->toHaveKey('is_static'); +}); + it('builds explicit Coolify application route labels for release API targets', function (): void { $manager = new release_manager(); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload'); @@ -215,7 +247,7 @@ it('builds explicit Coolify application route labels for release API targets', f ], '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') + expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080') ->and($payload['is_force_https_enabled'])->toBeTrue() ->and($payload['force_domain_override'])->toBeTrue() ->and($labels)->toContain('custom.keep=true') diff --git a/services/nginx/app/tests/Unit/Tooling/ComposerEntrypointTest.php b/services/nginx/app/tests/Unit/Tooling/ComposerEntrypointTest.php new file mode 100644 index 00000000..3362bad2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Tooling/ComposerEntrypointTest.php @@ -0,0 +1,20 @@ +markTestSkipped('Docker entrypoint is only available inside the PHP container.'); + } + + $entrypoint = (string)file_get_contents($entrypointPath); + + expect($entrypoint)->toContain('http_message_sanity_ok') + ->and($entrypoint)->toContain('composer_lock_has_package "$dir" "psr/http-message"') + ->and($entrypoint)->toContain('UriInterface.php') + ->and($entrypoint)->toContain('StreamInterface.php') + ->and($entrypoint)->toContain('interface_exists("Psr\\\\Http\\\\Message\\\\UriInterface")') + ->and($entrypoint)->toContain('interface_exists("Psr\\\\Http\\\\Message\\\\StreamInterface")') + ->and($entrypoint)->toContain('if ! http_message_sanity_ok "$dir"; then'); +}); diff --git a/services/php/Dockerfile b/services/php/Dockerfile index 3034fb47..7498e5ed 100644 --- a/services/php/Dockerfile +++ b/services/php/Dockerfile @@ -73,7 +73,8 @@ WORKDIR /var/www/html # Copy and enable entrypoint that installs Composer deps on first run COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -RUN chmod +x /usr/local/bin/docker-entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \ + && chmod +x /usr/local/bin/docker-entrypoint.sh # Expose port 9000 EXPOSE 9000 diff --git a/services/php/docker-entrypoint.sh b/services/php/docker-entrypoint.sh index b9af64f1..77ba94e5 100644 --- a/services/php/docker-entrypoint.sh +++ b/services/php/docker-entrypoint.sh @@ -36,6 +36,10 @@ vendor_sanity_ok() { return 1 fi + if ! http_message_sanity_ok "$dir"; then + return 1 + fi + if [ -f "$aws_s3_api_file" ] && ! php -l "$aws_s3_api_file" >/dev/null 2>&1; then log "Vendor sanity check failed for $aws_s3_api_file" return 1 @@ -44,6 +48,45 @@ vendor_sanity_ok() { return 0 } +composer_lock_has_package() { + dir="$1" + package="$2" + + if [ ! -f "$dir/composer.lock" ]; then + return 1 + fi + + grep -q "\"name\": \"$package\"" "$dir/composer.lock" +} + +http_message_sanity_ok() { + dir="$1" + autoload_file="$dir/vendor/autoload.php" + uri_file="$dir/vendor/psr/http-message/src/UriInterface.php" + stream_file="$dir/vendor/psr/http-message/src/StreamInterface.php" + + if ! composer_lock_has_package "$dir" "psr/http-message"; then + return 0 + fi + + if [ ! -f "$uri_file" ]; then + log "Vendor sanity check failed: missing $uri_file" + return 1 + fi + + if [ ! -f "$stream_file" ]; then + log "Vendor sanity check failed: missing $stream_file" + return 1 + fi + + if ! php -d display_errors=1 -r 'require $argv[1]; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);' "$autoload_file" >/dev/null 2>&1; then + log "Vendor sanity check failed: psr/http-message interfaces do not autoload in $dir" + return 1 + fi + + return 0 +} + wait_for_redis() { db_target="${CONFIG_DB_TARGET:-live}" if [ "$db_target" = "debug" ]; then @@ -130,6 +173,19 @@ install_if_needed() { fi } +refresh_root_autoload() { + if [ -f "$APP_DIR/composer.json" ] && [ -f "$APP_DIR/vendor/autoload.php" ]; then + log "Refreshing root Composer autoload after module dependency checks ..." + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + if ! COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload \ + --optimize --no-interaction \ + -d "$APP_DIR" 2>&1 | tee -a "$LOG_FILE"; then + log "ERROR: composer dump-autoload failed in $APP_DIR. See $LOG_FILE" + exit 1 + fi + fi +} + if [ "$AUTO_COMPOSER_INSTALL" = "true" ]; then if ! wait_for_file "$APP_DIR/composer.json" 120; then log "WARNING: $APP_DIR/composer.json not found after waiting - skipping auto-install" @@ -139,6 +195,7 @@ if [ "$AUTO_COMPOSER_INSTALL" = "true" ]; then if [ -f "$MODULE_DIR/composer.json" ]; then with_install_lock install_if_needed "$MODULE_DIR" + with_install_lock refresh_root_autoload fi else log "AUTO_COMPOSER_INSTALL=false - skipping Composer auto-install"