Add API-only Traefik CORS middleware labels while preserving configured origins and the existing rollout/load-balancer behavior.
762 lines
35 KiB
PHP
762 lines
35 KiB
PHP
<?php
|
|
|
|
app_require('classes/coolify_api_client.php');
|
|
app_require('classes/coolify_manager.php');
|
|
|
|
use classes\coolify_api_client;
|
|
use classes\coolify_manager;
|
|
use classes\cors_policy;
|
|
|
|
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');
|
|
expect(coolify_api_client::normalizeBaseUrl(' https://coolify.example.com/ '))->toBe('https://coolify.example.com/api/v1');
|
|
});
|
|
|
|
it('parses generated env files for Coolify service env bulk updates', function (): void {
|
|
$env = implode("\n", [
|
|
'# generated',
|
|
'MARIADB_ROOT_PASSWORD=root-secret',
|
|
'MARIADB_PASSWORD=app-secret',
|
|
'',
|
|
'REDIS_PRIMARY_USERNAME=',
|
|
]);
|
|
|
|
expect(coolify_manager::parseEnvFile($env))->toBe([
|
|
'MARIADB_ROOT_PASSWORD' => 'root-secret',
|
|
'MARIADB_PASSWORD' => 'app-secret',
|
|
'REDIS_PRIMARY_USERNAME' => '',
|
|
]);
|
|
});
|
|
|
|
it('prefers public Coolify server hosts over Docker-local addresses', function (): void {
|
|
expect(coolify_manager::publicServerHostFromCoolifyServer([
|
|
'ip' => 'host.docker.internal',
|
|
'public_ip' => '94.130.142.41',
|
|
'name' => 'node3.truckwash.io',
|
|
]))->toBe('94.130.142.41');
|
|
|
|
expect(coolify_manager::publicServerHostFromCoolifyServer([
|
|
'ip' => 'host.docker.internal',
|
|
'name' => 'node3.truckwash.io',
|
|
]))->toBe('node3.truckwash.io');
|
|
|
|
expect(coolify_manager::publicServerHostFromCoolifyServer([
|
|
'ip' => 'host.docker.internal',
|
|
'name' => 'node3.truckwash.io',
|
|
], null, false))->toBeNull();
|
|
|
|
expect(coolify_manager::publicServerHostFromCoolifyServer([
|
|
'ip' => '10.0.0.10',
|
|
'name' => 'Production Server',
|
|
]))->toBe('10.0.0.10');
|
|
|
|
expect(coolify_manager::publicServerHostFromCoolifyServer([
|
|
'ip' => 'host.docker.internal',
|
|
'name' => 'Production Server',
|
|
]))->toBeNull();
|
|
|
|
expect(coolify_manager::publicDnsServerNameFromCoolifyServer([
|
|
'ip' => 'host.docker.internal',
|
|
'name' => 'node3.truckwash.io',
|
|
]))->toBe('node3.truckwash.io');
|
|
|
|
expect(coolify_manager::publicDnsServerNameFromCoolifyServer([
|
|
'ip' => 'host.docker.internal',
|
|
'name' => 'Production Server',
|
|
]))->toBeNull();
|
|
});
|
|
|
|
it('blocks planned downtime operations against active replication primaries', function (): void {
|
|
expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'deploy'))->toBeTrue();
|
|
expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'restart'))->toBeTrue();
|
|
expect(coolify_manager::blocksPrimaryMutation(['role' => 'replica'], 'restart'))->toBeFalse();
|
|
expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'failover'))->toBeFalse();
|
|
});
|
|
|
|
it('allows failed Coolify replica targets to be removed after the service disappears', function (): void {
|
|
expect(coolify_manager::targetAllowsReplicaRemoval([
|
|
'role' => 'replica',
|
|
'deployment_status' => 'reconcile_failed',
|
|
'last_reconcile_status' => 'reconcile_failed',
|
|
]))->toBeTrue();
|
|
|
|
expect(coolify_manager::targetAllowsReplicaRemoval([
|
|
'role' => 'replica',
|
|
'deployment_status' => 'deploying',
|
|
'last_reconcile_json' => json_encode(['message' => 'Coolify API request failed: HTTP 404']),
|
|
]))->toBeTrue();
|
|
|
|
expect(coolify_manager::targetAllowsReplicaRemoval([
|
|
'role' => 'replica',
|
|
'deployment_status' => 'provisioned',
|
|
'last_reconcile_status' => 'ok',
|
|
]))->toBeFalse();
|
|
|
|
expect(coolify_manager::targetAllowsReplicaRemoval([
|
|
'role' => 'primary',
|
|
'deployment_status' => 'reconcile_failed',
|
|
]))->toBeFalse();
|
|
});
|
|
|
|
it('retries Coolify maintenance while linked replication provisioning is still incomplete', function (): void {
|
|
$method = new ReflectionMethod(coolify_manager::class, 'replicationHostStillNeedsProvisioning');
|
|
|
|
expect($method->invoke(null, [
|
|
'role' => 'replica',
|
|
'status' => 'provisioning',
|
|
'last_status_json' => json_encode([
|
|
'status' => 'provisioning',
|
|
'replication_percent' => 99.9,
|
|
'blockers' => ['MinIO replica has not caught up.'],
|
|
]),
|
|
]))->toBeTrue();
|
|
|
|
expect($method->invoke(null, [
|
|
'role' => 'replica',
|
|
'status' => 'ok',
|
|
'last_status_json' => json_encode([
|
|
'status' => 'ok',
|
|
'replication_percent' => 100,
|
|
'blockers' => [],
|
|
]),
|
|
]))->toBeFalse();
|
|
});
|
|
|
|
it('plans Hetzner load balancer target and service drift without mutating state', function (): void {
|
|
$manager = new coolify_manager();
|
|
$method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile');
|
|
|
|
$plan = $method->invoke($manager, [
|
|
'targets' => [
|
|
['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']],
|
|
],
|
|
'services' => [
|
|
coolifyManagerTestLoadBalancerService('http', 80, 80),
|
|
],
|
|
], [
|
|
['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true],
|
|
['hostname' => 'node2.truckwash.io', 'target_ip' => '65.21.214.30', 'enabled' => true],
|
|
]);
|
|
|
|
$actionTypes = array_map(static fn(array $action): string => (string)$action['type'], $plan['actions']);
|
|
|
|
expect($plan['has_drift'])->toBeTrue()
|
|
->and($plan['missing_targets'])->toContain('65.21.214.30')
|
|
->and($actionTypes)->toContain('add_target')
|
|
->and($actionTypes)->toContain('add_service')
|
|
->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');
|
|
|
|
$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');
|
|
|
|
$plan = $method->invoke($manager, [
|
|
'targets' => [
|
|
['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']],
|
|
],
|
|
'services' => [
|
|
coolifyManagerTestLoadBalancerService('http', 80, 80),
|
|
coolifyManagerTestLoadBalancerService('tcp', 443, 443),
|
|
],
|
|
], [
|
|
['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => false],
|
|
]);
|
|
|
|
expect($plan['actions'][0])
|
|
->toHaveKey('type', 'skip_remove_target')
|
|
->toHaveKey('reason', 'last_reachable_target_guard');
|
|
});
|
|
|
|
it('plans removal only for disabled or deleted Hetzner load balancer targets', function (): void {
|
|
$manager = new coolify_manager();
|
|
$method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile');
|
|
|
|
$plan = $method->invoke($manager, [
|
|
'targets' => [
|
|
['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']],
|
|
['type' => 'ip', 'ip' => ['ip' => '65.21.214.30']],
|
|
],
|
|
'services' => [
|
|
coolifyManagerTestLoadBalancerService('http', 80, 80),
|
|
coolifyManagerTestLoadBalancerService('tcp', 443, 443),
|
|
],
|
|
], [
|
|
['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true],
|
|
['hostname' => 'node2.truckwash.io', 'target_ip' => '65.21.214.30', 'enabled' => true, 'deleted_at' => '2026-05-19 10:00:00'],
|
|
]);
|
|
|
|
expect($plan['actions'])
|
|
->toHaveCount(1)
|
|
->and($plan['actions'][0])
|
|
->toHaveKey('type', 'remove_target')
|
|
->toHaveKey('target_ip', '65.21.214.30');
|
|
});
|
|
|
|
it('builds gateway API auto-provision context for connected Coolify servers', function (): void {
|
|
$manager = new coolify_manager();
|
|
$contextMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteProvisionDeployContext');
|
|
$ipMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteServerPublicIp');
|
|
|
|
$server = [
|
|
'uuid' => 'server-node1',
|
|
'name' => 'node1.truckwash.io',
|
|
'public_ip' => '94.130.142.41',
|
|
'settings' => ['is_reachable' => true, 'is_usable' => true],
|
|
];
|
|
|
|
expect($ipMethod->invoke(null, $server))->toBe('94.130.142.41');
|
|
|
|
$context = $contextMethod->invoke($manager, [
|
|
'id' => 42,
|
|
'channel_slug' => 'internal',
|
|
'deploy_context_json' => json_encode([
|
|
'coolify_project_uuid' => 'project-internal',
|
|
'coolify_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,
|
|
]),
|
|
], $server, '94.130.142.41', 'api-v2.truckwash.io', 'https://api-v2.truckwash.io');
|
|
|
|
expect($context)->toMatchArray([
|
|
'coolify_project_uuid' => 'project-internal',
|
|
'coolify_auto_create' => true,
|
|
'coolify_enable_ssl' => true,
|
|
'coolify_deploy_now' => true,
|
|
'coolify_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',
|
|
'server_uuid' => 'server-node1',
|
|
'coolify_destination_uuid' => '',
|
|
'destination_uuid' => '',
|
|
'coolify_service_name' => 'release-internal-api-node1-truckwash-io',
|
|
'gateway_route_autoprovision' => true,
|
|
'gateway_route_source_target_id' => 42,
|
|
'gateway_route_target_ip' => '94.130.142.41',
|
|
]);
|
|
expect($context)->not->toHaveKey('coolify_git_commit_sha');
|
|
expect($context)->not->toHaveKey('coolify_base_directory');
|
|
expect($context)->not->toHaveKey('coolify_start_command');
|
|
});
|
|
|
|
it('builds gateway frontend auto-provision context with the release Dockerfile', function (): void {
|
|
$manager = new coolify_manager();
|
|
$contextMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteProvisionDeployContext');
|
|
|
|
$server = [
|
|
'uuid' => 'server-node3',
|
|
'name' => 'node3.truckwash.io',
|
|
'public_ip' => '23.88.23.183',
|
|
'settings' => ['is_reachable' => true, 'is_usable' => true],
|
|
];
|
|
|
|
$context = $contextMethod->invoke($manager, [
|
|
'id' => 43,
|
|
'channel_slug' => 'internal',
|
|
'app' => 'frontend',
|
|
'deploy_context_json' => json_encode([
|
|
'coolify_project_uuid' => 'project-internal',
|
|
'coolify_build_pack' => 'static',
|
|
'coolify_install_command' => 'npm ci',
|
|
'coolify_build_command' => 'npm run build',
|
|
'coolify_publish_directory' => 'dist',
|
|
'coolify_is_static' => true,
|
|
'coolify_is_spa' => true,
|
|
]),
|
|
], $server, '23.88.23.183', 'api-v2.truckwash.io', 'https://api-v2.truckwash.io/internal/frontend');
|
|
|
|
expect($context)->toMatchArray([
|
|
'coolify_project_uuid' => 'project-internal',
|
|
'coolify_build_pack' => 'dockerfile',
|
|
'coolify_dockerfile_location' => '/Dockerfile.coolify-frontend',
|
|
'coolify_ports_exposes' => '80',
|
|
'coolify_port' => '80',
|
|
'coolify_public_url' => 'https://api-v2.truckwash.io/internal/frontend',
|
|
'coolify_server_uuid' => 'server-node3',
|
|
'coolify_service_name' => 'release-internal-frontend-node3-truckwash-io',
|
|
'gateway_route_autoprovision' => true,
|
|
'gateway_route_source_target_id' => 43,
|
|
'gateway_route_target_ip' => '23.88.23.183',
|
|
]);
|
|
expect($context)->not->toHaveKey('coolify_install_command');
|
|
expect($context)->not->toHaveKey('coolify_publish_directory');
|
|
expect($context)->not->toHaveKey('coolify_is_static');
|
|
expect($context)->not->toHaveKey('coolify_is_spa');
|
|
});
|
|
|
|
it('adds explicit Coolify application route labels for gateway API domains', function (): void {
|
|
$payloadMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteApplicationPayload');
|
|
$publicUrlMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteTargetPublicUrl');
|
|
|
|
$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.middlewares=legacy',
|
|
'traefik.http.routers.https-0-api-app-uuid.tls.certresolver=dns-cloudflare',
|
|
'traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=9090',
|
|
])), 'api', 'https://partner.example.test/app');
|
|
$labels = explode("\n", base64_decode($payload['custom_labels'], true));
|
|
|
|
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')
|
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
|
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.entryPoints=https')
|
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,gzip')
|
|
->and($labels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolallowcredentials=true')
|
|
->and($labels)->toContain(
|
|
'traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlist='
|
|
. implode(',', cors_policy::allowedOrigins('https://partner.example.test/app'))
|
|
)
|
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
|
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
|
|
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
|
|
|
|
expect($publicUrlMethod->invoke(null, 'api-v2.truckwash.io', [
|
|
'channel_slug' => 'stable',
|
|
'channel_default_channel' => 1,
|
|
'app' => 'api',
|
|
]))->toBe('https://api-v2.truckwash.io');
|
|
expect($publicUrlMethod->invoke(null, 'api-v2.truckwash.io', [
|
|
'channel_slug' => 'internal',
|
|
'channel_default_channel' => 0,
|
|
'app' => 'api',
|
|
]))->toBe('https://api-v2.truckwash.io/internal/api');
|
|
expect($publicUrlMethod->invoke(null, 'api-v2.truckwash.io', [
|
|
'channel_slug' => 'internal',
|
|
'channel_default_channel' => 0,
|
|
'app' => 'frontend',
|
|
]))->toBe('https://api-v2.truckwash.io/internal/frontend');
|
|
|
|
$pathPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/api', 'api-app-uuid', 8080, '', 'api', '*');
|
|
$pathLabels = explode("\n", base64_decode($pathPayload['custom_labels'], true));
|
|
|
|
expect($pathPayload['domains'])->toBe('https://api-v2.truckwash.io:8080/internal/api')
|
|
->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)')
|
|
->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-stripprefix.stripprefix.prefixes=/internal/api')
|
|
->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlistregex=^(https?://[^/]+|capacitor://[^/]+)$')
|
|
->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,https-0-api-app-uuid-stripprefix,gzip');
|
|
|
|
$frontendPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', 80, '', 'frontend');
|
|
$frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true));
|
|
|
|
expect($frontendPayload['domains'])->toBe('https://api-v2.truckwash.io:80/internal/frontend')
|
|
->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/frontend`)')
|
|
->and($frontendLabels)->toContain('traefik.http.middlewares.https-0-frontend-app-uuid-stripprefix.stripprefix.prefixes=/internal/frontend')
|
|
->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip');
|
|
expect(implode("\n", $frontendLabels))->not->toContain('-cors');
|
|
});
|
|
|
|
it('resolves gateway CORS from deployment context with the same precedence as runtime env', function (): void {
|
|
$method = new ReflectionMethod(coolify_manager::class, 'gatewayRouteTargetCorsConfig');
|
|
$config = $method->invoke(null, [
|
|
'deploy_context_json' => json_encode([
|
|
'coolify_env' => [
|
|
'CORS' => 'https://array.example.test/app',
|
|
],
|
|
'coolify_env_file' => "CORS=https://file.example.test/path\nOTHER=value",
|
|
'env' => "CORS=*\n",
|
|
]),
|
|
]);
|
|
|
|
expect($config)->toBe('*');
|
|
|
|
$requiredConfig = $method->invoke(null, [
|
|
'deploy_context_json' => json_encode([
|
|
'runtime_env' => [
|
|
'CORS' => 'https://partner.example.test/app',
|
|
],
|
|
]),
|
|
]);
|
|
expect(explode(',', $requiredConfig))
|
|
->toContain('https://partner.example.test')
|
|
->toContain('https://truckwash.io');
|
|
});
|
|
|
|
it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void {
|
|
$manager = new coolify_manager();
|
|
$method = new ReflectionMethod(coolify_manager::class, 'setLoadBalancerIpTargets');
|
|
$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');
|
|
|
|
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('normalizes gateway probe paths for release gateway health checks', function (): void {
|
|
$method = new ReflectionMethod(coolify_manager::class, 'normalizeGatewayProbePath');
|
|
|
|
expect($method->invoke(null, 'internal/api/ping'))->toBe('/internal/api/ping')
|
|
->and($method->invoke(null, '//internal//api//ping//'))->toBe('/internal/api/ping')
|
|
->and($method->invoke(null, 'https://api-v2.truckwash.io/internal/api/ping'))->toBe('/internal/api/ping')
|
|
->and($method->invoke(null, ''))->toBe('');
|
|
});
|
|
|
|
it('returns structured errors for failed gateway certificate bootstrap and verification', function (): void {
|
|
$method = new ReflectionMethod(coolify_manager::class, 'gatewayRouteHealthErrors');
|
|
|
|
$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'));
|
|
$route = file_get_contents(app_path('routes/superuserCoolifyRoute.php'));
|
|
$replication = file_get_contents(app_path('classes/replication_manager.php'));
|
|
$status = file_get_contents(app_path('classes/superuser_system_status_service.php'));
|
|
$cron = file_get_contents(app_path('cron/Cron.php'));
|
|
$openapi = file_get_contents(app_path('openapi.yaml'));
|
|
$coolifyConfig = file_get_contents(app_path('modules/coolify/coolify_c.php'));
|
|
$tokenConfig = file_get_contents(app_path('modules/coolify/config/coolify_hetzner_cloud_api_token_c.php'));
|
|
|
|
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_instances');
|
|
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_targets');
|
|
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_operations');
|
|
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_audit_logs');
|
|
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_instance_gateways');
|
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'lb_automation_enabled'");
|
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'lb_automation_mode'");
|
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'hetzner_load_balancer_id'");
|
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token'");
|
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io'");
|
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path'");
|
|
expect($schema)->toContain('94.130.142.41');
|
|
expect($schema)->toContain('65.21.214.30');
|
|
expect($schema)->toContain('23.88.23.183');
|
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'enabled'");
|
|
|
|
expect($route)->toContain('/superuser/coolify');
|
|
expect($route)->toContain('/superuser/coolify/load-balancer');
|
|
expect($route)->toContain('/superuser/coolify/load-balancer/reconcile');
|
|
expect($route)->toContain('/superuser/coolify/load-balancer/routes/deploy');
|
|
expect($route)->toContain('/superuser/coolify/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');
|
|
expect($route)->toContain('/superuser/coolify/instances/{id}/placement');
|
|
expect($route)->toContain('/superuser/coolify/targets/{id}/reconcile');
|
|
expect($route)->toContain('/superuser/coolify/targets/{id}/deploy');
|
|
expect($route)->toContain('/superuser/coolify/targets/{id}/restart');
|
|
expect($route)->toContain('/superuser/coolify/targets/{id}/failover');
|
|
expect($route)->toContain("requirePermission('superuser_coolify_view')");
|
|
expect($route)->toContain("requirePermission('superuser_coolify_manage')");
|
|
expect($route)->toContain("requirePermission('superuser_coolify_reconcile')");
|
|
expect($route)->toContain("requirePermission('superuser_coolify_failover')");
|
|
|
|
expect($manager)->toContain("Coolify-managed targets must be deployed as replicas first");
|
|
expect($manager)->toContain('ensureFailoverEnabled($kind)');
|
|
expect($manager)->toContain("deployment_provider' => 'coolify'");
|
|
expect($manager)->toContain('discoverInstancePlacement');
|
|
expect($manager)->toContain('applyCoolifyDeploymentDefaults($input, $instance)');
|
|
expect($manager)->toContain('resolveCoolifyServerHost');
|
|
expect($manager)->toContain('publicServerHostFromCoolifyServer');
|
|
expect($manager)->toContain('applyCoolifyPortDefaults');
|
|
expect($manager)->toContain('syncReplicationHostPortsForTarget');
|
|
expect($manager)->toContain('usedPublicPortsForCoolifyServer');
|
|
expect($manager)->toContain('nextAvailablePublicPorts');
|
|
expect($manager)->toContain('syncReplicationHostEndpointForTarget');
|
|
expect($manager)->toContain('knownPublicHostForCoolifyServer');
|
|
expect($manager)->toContain('publicDnsServerNameFromCoolifyServer');
|
|
expect($manager)->toContain('resolvedPublicDnsServerHostFromCoolifyServer');
|
|
expect($manager)->toContain('recordCreatedResource');
|
|
expect($manager)->toContain("'start_requested'");
|
|
expect($manager)->toContain('primaryCredentials');
|
|
expect($manager)->toContain('primary_admin_password');
|
|
expect($manager)->toContain('replication_transfer_limit');
|
|
expect($manager)->toContain('startOrRestartService');
|
|
expect($manager)->toContain('already running');
|
|
expect($manager)->toContain('restart_requested');
|
|
expect($manager)->toContain('isolated_stack');
|
|
expect($manager)->toContain('skip_replication_provisioning');
|
|
expect($manager)->toContain('targetSkipsReplicationProvisioning');
|
|
expect($manager)->toContain('targetComposeRole');
|
|
expect($manager)->toContain('production_data_attached');
|
|
expect($manager)->toContain('deferredProvisionResult');
|
|
expect($manager)->toContain('isTransientProvisionBlock');
|
|
expect($manager)->toContain('provision_deferred');
|
|
expect($manager)->toContain('shouldRetryProvisioning');
|
|
expect($manager)->toContain('shouldRetryProvisioning($target, $host)');
|
|
expect($manager)->toContain('hasRunningReplicationProvisionOperation');
|
|
expect($manager)->toContain('replicationHostStillNeedsProvisioning');
|
|
expect($manager)->toContain('syncDeploymentStateForReplicationHost');
|
|
expect($manager)->toContain('syncLabelForReplicationHost');
|
|
expect($manager)->toContain('syncTargetsForReplicationHost');
|
|
expect($manager)->toContain('targetAllowsReplicaRemoval');
|
|
expect($manager)->toContain('markTargetsRemovedForReplicationHost');
|
|
expect($manager)->toContain('replicationHostIsReady');
|
|
expect($manager)->toContain("'provisioned'");
|
|
expect($manager)->not->toContain('is_container_label_escape_enabled');
|
|
expect($manager)->not->toContain("\$payload['type'] = 'docker-compose';");
|
|
expect($manager)->toContain('encodedDockerCompose');
|
|
expect($manager)->toContain('base64_encode');
|
|
expect($manager)->toContain('if (!$update)');
|
|
expect($manager)->toContain("'project_uuid' => \$target['project_uuid']");
|
|
expect($manager)->toContain('/api/v1/services');
|
|
expect($manager)->toContain('/envs/bulk');
|
|
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('loadBalancerReleaseGatewayTargets');
|
|
expect($manager)->toContain('provisionMissingGatewayRouteTargets');
|
|
expect($manager)->toContain('provision_gateway_');
|
|
expect($manager)->toContain('gateway_route_autoprovision');
|
|
expect($manager)->toContain('upsertDeploymentTarget');
|
|
expect($manager)->toContain('startDeployment');
|
|
expect($manager)->toContain("'commit_mode' => \$sourceCommitSha === '' ? 'latest' : 'specific'");
|
|
expect($manager)->toContain("\$deploymentInput['commit_sha'] = \$sourceCommitSha;");
|
|
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');
|
|
expect($manager)->toContain('CURLOPT_SSL_VERIFYPEER, true');
|
|
expect($manager)->toContain('CURLOPT_CERTINFO');
|
|
expect($manager)->toContain('CURLINFO_SSL_VERIFYRESULT');
|
|
expect($manager)->toContain("Gateway TLS certificate was not issued by Let's Encrypt.");
|
|
expect($manager)->toContain('gatewayProbePath');
|
|
expect($manager)->toContain('public_gateway_probe_path');
|
|
expect($manager)->toContain('loadBalancerReleaseApiTargets');
|
|
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');
|
|
expect($manager)->not->toContain('SHOW TABLES LIKE ?');
|
|
expect($manager)->toContain('gatewayRouteApplicationPayload');
|
|
expect($manager)->toContain('gateway_application_routes_deployed');
|
|
expect($manager)->toContain('target_already_defined');
|
|
expect($manager)->toContain('REQUIRED_LOAD_BALANCER_SERVICES');
|
|
expect($manager)->toContain('skip_remove_target');
|
|
|
|
$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');
|
|
expect($client)->toContain('/applications/private-github-app');
|
|
expect($client)->toContain("request('GET', '/applications/' . rawurlencode(\$uuid))");
|
|
expect($client)->toContain("'/deploy?uuid=' . rawurlencode(\$uuid)");
|
|
expect($client)->toContain('/applications/\' . rawurlencode($uuid) . \'/restart');
|
|
expect($client)->toContain('CURL_HTTP_VERSION_1_1');
|
|
expect($client)->toContain('validationErrorSummary');
|
|
|
|
expect($replication)->toContain('deployment_provider');
|
|
expect($replication)->toContain('coolify_manager::deploymentMetadataForReplicationHost');
|
|
expect($replication)->toContain('coolify_manager::syncDeploymentStateForReplicationHost');
|
|
expect($replication)->toContain('databaseEngineKnown');
|
|
expect($status)->toContain("'key' => 'coolify'");
|
|
expect($status)->toContain('probeCoolifyModule');
|
|
expect($cron)->toContain('CoolifyAvailabilityMonitorCron');
|
|
expect($cron)->toContain('CoolifyLoadBalancerReconcileCron');
|
|
expect($coolifyConfig)->toContain('[redacted]');
|
|
expect($coolifyConfig)->toContain('secret_set');
|
|
expect($tokenConfig)->toContain('replication_secret_box::encrypt');
|
|
expect($openapi)->toContain('/superuser/coolify:');
|
|
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');
|
|
expect($openapi)->toContain('operationId: createSuperuserCoolifyTarget');
|
|
expect($openapi)->toContain('SuperuserCoolifyTarget');
|
|
expect($openapi)->toContain('SuperuserCoolifyLoadBalancer');
|
|
expect($openapi)->toContain('SuperuserCoolifyGateway');
|
|
});
|