makePartial(); $edgeServer->id = 1; $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 2; $deploymentServer->ip = '10.8.0.15'; $deploymentServer->proxy = ['type' => 'NONE']; $database = new StandalonePostgresql; $database->uuid = 'standalone-db-uuid'; $database->name = 'standalone-db'; $database->public_port = 15432; $database->setRelation('destination', (object) [ 'server' => $deploymentServer, 'network' => 'standalone-network', ]); $database->setRelation('environment', (object) [ 'project' => (object) ['team_id' => 123], ]); $action = new class($edgeServer) extends StartDatabaseProxy { public array $calls = []; public function __construct(private ?Server $edgeServer) {} protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return $this->edgeServer; } protected function resolveConfigurationDirectory(string $databaseUuid): string { return "/tmp/database-proxy/{$databaseUuid}"; } }; $action->handle($database); expect($action->calls)->toHaveCount(3) ->and($action->calls[0]['server_id'])->toBe(2) ->and($action->calls[1]['server_id'])->toBe(1) ->and($action->calls[2]['server_id'])->toBe(1); preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches); $nginxConf = base64_decode($nginxMatches[1] ?? ''); expect($nginxConf)->toContain('listen 15432;') ->and($nginxConf)->toContain('proxy_pass 10.8.0.15:5432;'); preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $action->calls[2]['commands'][2], $composeMatches); $dockerCompose = base64_decode($composeMatches[1] ?? ''); expect($dockerCompose)->not->toContain('external: true') ->and($dockerCompose)->not->toContain('standalone-network'); }); it('keeps configurable database proxy timeout when routing through the master domain router server', function () { $edgeServer = \Mockery::mock(Server::class)->makePartial(); $edgeServer->id = 3; $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 4; $deploymentServer->ip = '10.8.0.44'; $deploymentServer->proxy = ['type' => 'NONE']; $database = new StandalonePostgresql; $database->uuid = 'standalone-db-timeout-uuid'; $database->name = 'standalone-db-timeout'; $database->public_port = 15444; $database->public_port_timeout = 7200; $database->setRelation('destination', (object) [ 'server' => $deploymentServer, 'network' => 'standalone-timeout-network', ]); $database->setRelation('environment', (object) [ 'project' => (object) ['team_id' => 124], ]); $action = new class($edgeServer) extends StartDatabaseProxy { public array $calls = []; public function __construct(private ?Server $edgeServer) {} protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return $this->edgeServer; } protected function resolveConfigurationDirectory(string $databaseUuid): string { return "/tmp/database-proxy/{$databaseUuid}"; } }; $action->handle($database); preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches); $nginxConf = base64_decode($nginxMatches[1] ?? ''); expect($nginxConf)->toContain('proxy_pass 10.8.0.44:5432;') ->and($nginxConf)->toContain('proxy_timeout 7200s;'); }); it('keeps standalone database proxy on deployment server when no master domain router server is configured', function () { $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 2; $deploymentServer->ip = '10.8.0.25'; $deploymentServer->proxy = ['type' => 'NONE']; $database = new StandalonePostgresql; $database->uuid = 'standalone-db-local-uuid'; $database->name = 'standalone-db-local'; $database->public_port = 15433; $database->setRelation('destination', (object) [ 'server' => $deploymentServer, 'network' => 'standalone-network-local', ]); $database->setRelation('environment', (object) [ 'project' => (object) ['team_id' => 456], ]); $action = new class extends StartDatabaseProxy { public array $calls = []; protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return null; } protected function resolveConfigurationDirectory(string $databaseUuid): string { return "/tmp/database-proxy/{$databaseUuid}"; } }; $action->handle($database); expect($action->calls)->toHaveCount(2) ->and($action->calls[0]['server_id'])->toBe(2) ->and($action->calls[1]['server_id'])->toBe(2); preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[1]['commands'][1], $nginxMatches); $nginxConf = base64_decode($nginxMatches[1] ?? ''); expect($nginxConf)->toContain('proxy_pass standalone-db-local-uuid:5432;'); preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $action->calls[1]['commands'][2], $composeMatches); $dockerCompose = base64_decode($composeMatches[1] ?? ''); expect($dockerCompose)->toContain('standalone-network-local') ->and($dockerCompose)->toContain('external: true'); }); it('runs service database proxy on master domain router server for remote deployments', function () { $edgeServer = \Mockery::mock(Server::class)->makePartial(); $edgeServer->id = 11; $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 22; $deploymentServer->ip = '10.8.0.35'; $deploymentServer->proxy = ['type' => 'NONE']; $serviceDatabase = \Mockery::mock(ServiceDatabase::class)->makePartial(); $serviceDatabase->uuid = 'service-db-uuid'; $serviceDatabase->name = 'postgres'; $serviceDatabase->public_port = 15434; $serviceDatabase->shouldReceive('getMorphClass')->andReturn(\App\Models\ServiceDatabase::class); $serviceDatabase->shouldReceive('databaseType')->andReturn('standalone-postgresql'); $serviceDatabase->setRelation('service', (object) [ 'uuid' => 'service-uuid', 'destination' => (object) ['server' => $deploymentServer], 'environment' => (object) ['project' => (object) ['team_id' => 789]], ]); $action = new class($edgeServer) extends StartDatabaseProxy { public array $calls = []; public function __construct(private ?Server $edgeServer) {} protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return $this->edgeServer; } protected function resolveConfigurationDirectory(string $databaseUuid): string { return "/tmp/database-proxy/{$databaseUuid}"; } }; $action->handle($serviceDatabase); expect($action->calls)->toHaveCount(3) ->and($action->calls[0]['server_id'])->toBe(22) ->and($action->calls[1]['server_id'])->toBe(11) ->and($action->calls[2]['server_id'])->toBe(11); preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches); $nginxConf = base64_decode($nginxMatches[1] ?? ''); expect($nginxConf)->toContain('listen 15434;') ->and($nginxConf)->toContain('proxy_pass 10.8.0.35:5432;') ->and($nginxConf)->not->toContain('postgres-service-uuid'); }); it('stops database proxy containers on deployment and master domain router servers', function () { $edgeServer = \Mockery::mock(Server::class)->makePartial(); $edgeServer->id = 101; $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 202; $database = \Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->uuid = 'stopped-db-uuid'; $database->shouldReceive('save')->once()->andReturnTrue(); $database->setRelation('destination', (object) [ 'server' => $deploymentServer, ]); $database->setRelation('environment', (object) [ 'project' => (object) ['team_id' => 555], ]); $action = new class($edgeServer) extends StopDatabaseProxy { public array $calls = []; public function __construct(private ?Server $edgeServer) {} protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return $this->edgeServer; } protected function dispatchDatabaseProxyStoppedEvent(): void { // No-op in isolated unit test environment. } }; $action->handle($database); expect($action->calls)->toHaveCount(2) ->and($action->calls[0]['server_id'])->toBe(202) ->and($action->calls[1]['server_id'])->toBe(101) ->and($action->calls[0]['commands'][0])->toBe('docker rm -f stopped-db-uuid-proxy') ->and($action->calls[1]['commands'][0])->toBe('docker rm -f stopped-db-uuid-proxy'); }); it('falls back to deployment server when master domain router exists but remote host is missing', function () { $edgeServer = \Mockery::mock(Server::class)->makePartial(); $edgeServer->id = 41; $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 42; $deploymentServer->ip = ''; $deploymentServer->proxy = ['type' => 'NONE']; $database = new StandalonePostgresql; $database->uuid = 'standalone-db-fallback-uuid'; $database->name = 'standalone-db-fallback'; $database->public_port = 15440; $database->setRelation('destination', (object) [ 'server' => $deploymentServer, 'network' => 'fallback-network', ]); $database->setRelation('environment', (object) [ 'project' => (object) ['team_id' => 321], ]); $action = new class($edgeServer) extends StartDatabaseProxy { public array $calls = []; public array $warnings = []; public function __construct(private ?Server $edgeServer) {} protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return $this->edgeServer; } protected function resolveConfigurationDirectory(string $databaseUuid): string { return "/tmp/database-proxy/{$databaseUuid}"; } protected function logWarning(string $message): void { $this->warnings[] = $message; } }; $action->handle($database); expect($action->calls)->toHaveCount(2) ->and($action->calls[0]['server_id'])->toBe(42) ->and($action->calls[1]['server_id'])->toBe(42) ->and($action->warnings)->not->toBeEmpty() ->and($action->warnings[0])->toContain('falling back to deployment server'); }); it('keeps proxy on deployment server when master router server is the same server', function () { $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 51; $deploymentServer->ip = '10.8.0.51'; $deploymentServer->proxy = ['type' => 'NONE']; $database = new StandalonePostgresql; $database->uuid = 'standalone-db-same-edge-uuid'; $database->name = 'standalone-db-same-edge'; $database->public_port = 15451; $database->setRelation('destination', (object) [ 'server' => $deploymentServer, 'network' => 'same-edge-network', ]); $database->setRelation('environment', (object) [ 'project' => (object) ['team_id' => 654], ]); $action = new class($deploymentServer) extends StartDatabaseProxy { public array $calls = []; public function __construct(private ?Server $edgeServer) {} protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return $this->edgeServer; } protected function resolveConfigurationDirectory(string $databaseUuid): string { return "/tmp/database-proxy/{$databaseUuid}"; } }; $action->handle($database); expect($action->calls)->toHaveCount(2) ->and($action->calls[0]['server_id'])->toBe(51) ->and($action->calls[1]['server_id'])->toBe(51); }); it('supports service database deployment server fallback from service.server when destination is missing', function () { $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 61; $deploymentServer->ip = '10.8.0.61'; $deploymentServer->proxy = ['type' => 'NONE']; $serviceDatabase = \Mockery::mock(ServiceDatabase::class)->makePartial(); $serviceDatabase->uuid = 'service-db-fallback-server-uuid'; $serviceDatabase->name = 'postgres'; $serviceDatabase->public_port = 15461; $serviceDatabase->shouldReceive('getMorphClass')->andReturn(\App\Models\ServiceDatabase::class); $serviceDatabase->shouldReceive('databaseType')->andReturn('standalone-postgresql'); $serviceDatabase->setRelation('service', (object) [ 'uuid' => 'service-fallback-uuid', 'server' => $deploymentServer, 'environment' => (object) ['project' => (object) ['team_id' => 111]], ]); $action = new class extends StartDatabaseProxy { public array $calls = []; protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return null; } protected function resolveConfigurationDirectory(string $databaseUuid): string { return "/tmp/database-proxy/{$databaseUuid}"; } }; $action->handle($serviceDatabase); expect($action->calls)->toHaveCount(2) ->and($action->calls[0]['server_id'])->toBe(61) ->and($action->calls[1]['server_id'])->toBe(61); }); it('uses the dev host configuration path only for the bind mount source', function () { $action = new class extends StartDatabaseProxy { public function configurationDirectory(string $databaseUuid): string { return $this->resolveConfigurationDirectory($databaseUuid); } public function hostConfigurationDirectory(string $databaseUuid): string { return $this->resolveHostConfigurationDirectory($databaseUuid); } protected function isDevelopmentEnvironment(): bool { return true; } }; expect($action->configurationDirectory('dev-db-uuid')) ->toBe('/data/coolify/databases/dev-db-uuid/proxy') ->and($action->hostConfigurationDirectory('dev-db-uuid')) ->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/dev-db-uuid/proxy'); }); it('uses ssl internal redis port 6380 for remote database proxy upstream target', function () { $edgeServer = \Mockery::mock(Server::class)->makePartial(); $edgeServer->id = 71; $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 72; $deploymentServer->ip = '10.8.0.72'; $deploymentServer->proxy = ['type' => 'NONE']; $database = \Mockery::mock(\App\Models\StandaloneRedis::class)->makePartial(); $database->uuid = 'redis-ssl-db-uuid'; $database->name = 'redis-ssl-db'; $database->public_port = 16379; $database->enable_ssl = true; $database->database_type = 'standalone-redis'; $database->setRelation('destination', (object) [ 'server' => $deploymentServer, 'network' => 'redis-network', ]); $database->setRelation('environment', (object) [ 'project' => (object) ['team_id' => 222], ]); $action = new class($edgeServer) extends StartDatabaseProxy { public array $calls = []; public function __construct(private ?Server $edgeServer) {} protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return $this->edgeServer; } protected function resolveConfigurationDirectory(string $databaseUuid): string { return "/tmp/database-proxy/{$databaseUuid}"; } }; $action->handle($database); preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches); $nginxConf = base64_decode($nginxMatches[1] ?? ''); expect($nginxConf)->toContain('listen 16379;') ->and($nginxConf)->toContain('proxy_pass 10.8.0.72:6380;'); }); it('disables public database proxy on non-transient startup errors', function () { $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 81; $deploymentServer->ip = '10.8.0.81'; $deploymentServer->proxy = ['type' => 'NONE']; $database = \Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->uuid = 'standalone-db-error-uuid'; $database->name = 'standalone-db-error'; $database->public_port = 15481; $database->shouldReceive('update')->once()->with(['is_public' => false])->andReturnTrue(); $database->setRelation('destination', (object) [ 'server' => $deploymentServer, 'network' => 'error-network', ]); $database->setRelation('environment', (object) [ 'project' => (object) ['team_id' => 333], ]); $action = new class extends StartDatabaseProxy { public array $calls = []; protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; if (str_contains(implode("\n", $commands), 'up -d')) { throw new \RuntimeException('Bind for 0.0.0.0:15481 failed: port is already allocated'); } return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return null; } protected function resolveConfigurationDirectory(string $databaseUuid): string { return "/tmp/database-proxy/{$databaseUuid}"; } }; $action->handle($database); expect($action->calls)->toHaveCount(2) ->and($action->calls[1]['server_id'])->toBe(81); }); it('does not try to stop database proxy when deployment server is missing', function () { $database = \Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->uuid = 'stop-missing-server-db-uuid'; $database->setRelation('destination', (object) [ 'server' => null, ]); $action = new class extends StopDatabaseProxy { public array $calls = []; protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function dispatchDatabaseProxyStoppedEvent(): void { // No-op in isolated unit test environment. } }; $action->handle($database); expect($action->calls)->toBe([]); }); it('stops proxy only once when edge and deployment server are the same', function () { $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 91; $database = \Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->uuid = 'stop-same-edge-db-uuid'; $database->shouldReceive('save')->once()->andReturnTrue(); $database->setRelation('destination', (object) [ 'server' => $deploymentServer, ]); $database->setRelation('environment', (object) [ 'project' => (object) ['team_id' => 444], ]); $action = new class($deploymentServer) extends StopDatabaseProxy { public array $calls = []; public function __construct(private ?Server $edgeServer) {} protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return $this->edgeServer; } protected function dispatchDatabaseProxyStoppedEvent(): void { // No-op in isolated unit test environment. } }; $action->handle($database); expect($action->calls)->toHaveCount(1) ->and($action->calls[0]['server_id'])->toBe(91) ->and($action->calls[0]['commands'][0])->toBe('docker rm -f stop-same-edge-db-uuid-proxy'); }); it('skips starting database proxy when deployment server is missing', function () { $database = new StandalonePostgresql; $database->uuid = 'missing-deployment-server-db-uuid'; $database->name = 'missing-deployment-server-db'; $database->public_port = 15500; $database->setRelation('destination', (object) [ 'server' => null, 'network' => 'missing-network', ]); $action = new class extends StartDatabaseProxy { public array $warnings = []; protected function resolveConfigurationDirectory(string $databaseUuid): string { return "/tmp/database-proxy/{$databaseUuid}"; } protected function logWarning(string $message): void { $this->warnings[] = $message; } }; $action->handle($database); expect($action->warnings)->toHaveCount(1) ->and($action->warnings[0])->toContain('deployment server is missing'); }); it('normalizes remote host with scheme and path before building database upstream target', function () { $edgeServer = \Mockery::mock(Server::class)->makePartial(); $edgeServer->id = 111; $deploymentServer = \Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 112; $deploymentServer->ip = ''; $deploymentServer->proxy = ['type' => 'NONE', 'tunnel_host' => 'https://10.8.0.112:8443/path']; $database = new StandalonePostgresql; $database->uuid = 'normalized-remote-host-db-uuid'; $database->name = 'normalized-remote-host-db'; $database->public_port = 15501; $database->setRelation('destination', (object) [ 'server' => $deploymentServer, 'network' => 'normalized-network', ]); $database->setRelation('environment', (object) [ 'project' => (object) ['team_id' => 987], ]); $action = new class($edgeServer) extends StartDatabaseProxy { public array $calls = []; public function __construct(private ?Server $edgeServer) {} protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server { return $this->edgeServer; } protected function resolveConfigurationDirectory(string $databaseUuid): string { return "/tmp/database-proxy/{$databaseUuid}"; } }; $action->handle($database); preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches); $nginxConf = base64_decode($nginxMatches[1] ?? ''); expect($nginxConf)->toContain('proxy_pass 10.8.0.112:5432;') ->and($nginxConf)->not->toContain('8443'); });