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.
This commit is contained in:
@@ -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, '<b>Fatal error</b>'))->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');
|
||||
|
||||
Reference in New Issue
Block a user