Integrate Coolify API client and module for managing Coolify services, enhancing automation and deployment processes.
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
<?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');
|
||||
});
|
||||
Reference in New Issue
Block a user