Files
api/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php
T

324 lines
16 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;
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');
$method->setAccessible(true);
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');
$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],
],
], [
['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'])->toContain([
'protocol' => 'tcp',
'listen_port' => 443,
'destination_port' => 443,
]);
});
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');
$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],
['protocol' => 'tcp', 'listen_port' => 443, 'destination_port' => 443, 'proxyprotocol' => false],
],
], [
['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');
$method->setAccessible(true);
$plan = $method->invoke($manager, [
'targets' => [
['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']],
['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],
],
], [
['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('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('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/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('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('target_already_defined');
expect($manager)->toContain('REQUIRED_LOAD_BALANCER_SERVICES');
expect($manager)->toContain('skip_remove_target');
$client = file_get_contents(app_path('classes/coolify_api_client.php'));
expect($client)->toContain("request('GET', '/health', null, false)");
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: 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');
});