bound('log') ? $container->make('log') : null; $container->instance('log', new NullLogger); }); afterEach(function () use (&$originalLogger) { if (is_null($originalLogger)) { return; } Container::getInstance()->instance('log', $originalLogger); }); it('generates edge traefik config for a remote domain route', function () { $service = new EdgeProxyRemoteRouteService; $config = $service->generateTraefikConfig('service-uuid', [[ 'host' => 'demo.example.com', 'path' => '/', 'upstream_url' => 'http://10.8.0.15:9010', ]]); expect(data_get($config, 'http.middlewares.edge-service-uuid-redirect-to-https.redirectScheme.scheme'))->toBe('https') ->and(data_get($config, 'http.routers.edge-service-uuid-http-1.rule'))->toBe('Host(`demo.example.com`)') ->and(data_get($config, 'http.routers.edge-service-uuid-http-1.entryPoints'))->toBe(['http']) ->and(data_get($config, 'http.routers.edge-service-uuid-http-1.middlewares'))->toBe(['edge-service-uuid-redirect-to-https']) ->and(data_get($config, 'http.routers.edge-service-uuid-https-1.entryPoints'))->toBe(['https']) ->and(data_get($config, 'http.routers.edge-service-uuid-https-1.tls.certResolver'))->toBe('letsencrypt') ->and(data_get($config, 'http.services.edge-service-uuid-svc-1.loadBalancer.servers.0.url'))->toBe('http://10.8.0.15:9010'); }); it('uses configured traefik entrypoints and cert resolver for remote routes', function () { $container = Container::getInstance(); $hadOriginalConfig = $container->bound('config'); $originalConfig = $hadOriginalConfig ? $container->make('config') : null; $container->instance('config', new Repository([ 'constants' => [ 'coolify' => [ 'proxy' => [ 'traefik' => [ 'entrypoints' => [ 'http' => 'web', 'https' => 'websecure', ], 'cert_resolver' => 'myresolver', ], ], ], ], ])); try { $service = new EdgeProxyRemoteRouteService; $config = $service->generateTraefikConfig('service-uuid', [[ 'host' => 'demo.example.com', 'path' => '/', 'upstream_url' => 'http://10.8.0.15:9010', ]]); expect(data_get($config, 'http.routers.edge-service-uuid-http-1.entryPoints'))->toBe(['web']) ->and(data_get($config, 'http.routers.edge-service-uuid-https-1.entryPoints'))->toBe(['websecure']) ->and(data_get($config, 'http.routers.edge-service-uuid-https-1.tls.certResolver'))->toBe('myresolver'); } finally { if ($hadOriginalConfig && ! is_null($originalConfig)) { $container->instance('config', $originalConfig); } else { unset($container['config']); } } }); it('returns warning when syncing service route without a master domain router', function () { $manager = new class extends EdgeProxyRemoteRouteService { protected function resolveEdgeProxyServerByTeamId(?int $teamId): ?Server { return null; } }; $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 10; $service = new Service; $service->uuid = 'service-no-master-router'; $service->setRelation('server', $deploymentServer); $service->setRelation('environment', (object) [ 'project' => (object) [ 'team_id' => 42, ], ]); $warnings = $manager->syncService($service); expect($warnings)->toHaveCount(1) ->and($warnings[0])->toContain('no master domain router is configured for team 42'); }); it('returns warning when syncing application route without a master domain router', function () { $manager = new class extends EdgeProxyRemoteRouteService { protected function resolveEdgeProxyServerByTeamId(?int $teamId): ?Server { return null; } }; $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 11; $application = new Application; $application->uuid = 'application-no-master-router'; $application->setRelation('environment', (object) [ 'project' => (object) [ 'team_id' => 52, ], ]); $warnings = $manager->syncApplicationOnDeploymentServer($application, $deploymentServer); expect($warnings)->toHaveCount(1) ->and($warnings[0])->toContain('no master domain router is configured for team 52'); }); it('creates, updates, and deletes a stable edge route file per service uuid', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 10; $deploymentServer->ip = '10.8.0.15'; $deploymentServer->proxy = ['type' => 'NONE']; $service = new Service; $service->uuid = 'service-test-uuid'; $service->docker_compose_raw = <<<'YAML' services: app: ports: - "9010:3000" YAML; $application = new ServiceApplication; $application->name = 'app'; $application->fqdn = 'https://demo.example.com:3000'; $service->setRelation('applications', collect([$application])); $application->setRelation('service', $service); $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]) ->and($manager->calls)->toHaveCount(1); $expectedPath = '/tmp/proxy/dynamic/service-remote-service-test-uuid.yaml'; $expectedTempPath = '/tmp/proxy/dynamic/service-remote-service-test-uuid.yaml.tmp'; $firstWriteCommands = implode("\n", $manager->calls[0]['commands']); expect($firstWriteCommands)->toContain($expectedPath) ->and($firstWriteCommands)->toContain($expectedTempPath) ->and($firstWriteCommands)->toContain('tee') ->and($firstWriteCommands)->toContain('mv'); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $firstPayloadMatches); $firstPayload = base64_decode($firstPayloadMatches[1]); expect($firstPayload)->toContain('http://10.8.0.15:9010'); $service->docker_compose_raw = <<<'YAML' services: app: ports: - "9020:3000" YAML; $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]) ->and($manager->calls)->toHaveCount(2); $secondWriteCommands = implode("\n", $manager->calls[1]['commands']); expect($secondWriteCommands)->toContain($expectedPath) ->and($secondWriteCommands)->toContain($expectedTempPath) ->and($secondWriteCommands)->toContain('tee') ->and($secondWriteCommands)->toContain('mv'); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[1]['commands'][1], $secondPayloadMatches); $secondPayload = base64_decode($secondPayloadMatches[1]); expect($secondPayload)->toContain('http://10.8.0.15:9020'); $manager->deleteServiceWithServer($service, $edgeProxyServer); expect($manager->calls)->toHaveCount(3); $deleteCommands = implode("\n", $manager->calls[2]['commands']); expect($deleteCommands)->toContain("rm -f '$expectedPath' '$expectedTempPath'"); }); it('creates, updates, and deletes a stable edge route file per application uuid', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'server_id' => $server->id, 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 30; $deploymentServer->ip = '10.8.0.30'; $deploymentServer->proxy = ['type' => 'NONE']; $application = new Application; $application->uuid = 'application-test-uuid'; $application->build_pack = 'nixpacks'; $application->fqdn = 'https://app.example.com:3000'; $application->ports_mappings = '9010:3000'; $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]) ->and($manager->calls)->toHaveCount(1); $expectedPath = '/tmp/proxy/dynamic/application-remote-application-test-uuid.yaml'; $expectedTempPath = '/tmp/proxy/dynamic/application-remote-application-test-uuid.yaml.tmp'; $firstWriteCommands = implode("\n", $manager->calls[0]['commands']); expect($firstWriteCommands)->toContain($expectedPath) ->and($firstWriteCommands)->toContain($expectedTempPath) ->and($firstWriteCommands)->toContain('tee') ->and($firstWriteCommands)->toContain('mv'); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $firstPayloadMatches); $firstPayload = base64_decode($firstPayloadMatches[1]); expect($firstPayload)->toContain('http://10.8.0.30:9010'); $application->ports_mappings = '9020:3000'; $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]) ->and($manager->calls)->toHaveCount(2); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[1]['commands'][1], $secondPayloadMatches); $secondPayload = base64_decode($secondPayloadMatches[1]); expect($secondPayload)->toContain('http://10.8.0.30:9020'); $manager->deleteApplicationWithServer($application, $edgeProxyServer); expect($manager->calls)->toHaveCount(3); $deleteCommands = implode("\n", $manager->calls[2]['commands']); expect($deleteCommands)->toContain("rm -f '$expectedPath' '$expectedTempPath'"); }); it('creates edge route for docker compose application domains using compose service ports', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 31; $deploymentServer->ip = '10.8.0.31'; $deploymentServer->proxy = ['type' => 'NONE']; $application = new Application; $application->uuid = 'application-compose-route'; $application->build_pack = 'dockercompose'; $application->docker_compose_domains = json_encode([ 'web' => ['domain' => 'https://compose-app.example.com:3000'], ]); $application->docker_compose_raw = <<<'YAML' services: web: ports: - "9030:3000" YAML; $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]) ->and($manager->calls)->toHaveCount(1); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('Host(`compose-app.example.com`)') ->and($payload)->toContain('http://10.8.0.31:9030'); }); it('returns actionable warning and does not write route file when application published host port is missing', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 32; $deploymentServer->ip = '10.8.0.32'; $deploymentServer->proxy = ['type' => 'NONE']; $application = new Application; $application->uuid = 'application-missing-port'; $application->build_pack = 'nixpacks'; $application->fqdn = 'https://missing-port.example.com:3000'; $application->ports_mappings = null; $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); expect($warnings)->not->toBeEmpty() ->and($warnings[0])->toContain('published host port could not be resolved') ->and(implode("\n", $manager->calls[0]['commands']))->toContain('/tmp/proxy/dynamic/application-remote-application-missing-port.yaml') ->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee'); }); it('keeps valid application edge routes when one domain port cannot be resolved and returns warning only for invalid domain', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 33; $deploymentServer->ip = '10.8.0.33'; $deploymentServer->proxy = ['type' => 'NONE']; $application = new Application; $application->uuid = 'application-partial-routes'; $application->build_pack = 'dockercompose'; $application->docker_compose_domains = json_encode([ 'web' => ['domain' => 'https://good-app.example.com:3000,https://bad-app.example.com:9999'], ]); $application->docker_compose_raw = <<<'YAML' services: web: ports: - "9060:3000" - "9070:4000" YAML; $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); expect($warnings)->not->toBeEmpty() ->and($warnings[0])->toContain('published host port could not be resolved') ->and($manager->calls)->toHaveCount(1); $writeCommands = implode("\n", $manager->calls[0]['commands']); expect($writeCommands)->toContain('/tmp/proxy/dynamic/application-remote-application-partial-routes.yaml') ->and($writeCommands)->toContain('tee') ->and($writeCommands)->not->toContain('rm -f'); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('Host(`good-app.example.com`)') ->and($payload)->not->toContain('Host(`bad-app.example.com`)') ->and($payload)->toContain('http://10.8.0.33:9060'); }); it('does not generate edge route file when published port cannot be resolved and returns actionable warning', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 11; $deploymentServer->ip = '10.8.0.16'; $deploymentServer->proxy = ['type' => 'NONE']; $service = new Service; $service->uuid = 'service-without-port'; $service->docker_compose_raw = <<<'YAML' services: app: {} YAML; $application = new ServiceApplication; $application->name = 'app'; $application->fqdn = 'https://broken.example.com:3000'; $service->setRelation('applications', collect([$application])); $application->setRelation('service', $service); $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->not->toBeEmpty() ->and($warnings[0])->toContain('published host port could not be resolved') ->and(implode("\n", $manager->calls[0]['commands']))->toContain('/tmp/proxy/dynamic/service-remote-service-without-port.yaml') ->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee'); }); it('keeps valid edge routes when one domain port cannot be resolved and returns warning only for invalid domain', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 13; $deploymentServer->ip = '10.8.0.18'; $deploymentServer->proxy = ['type' => 'NONE']; $service = new Service; $service->uuid = 'service-partial-routes'; $service->docker_compose_raw = <<<'YAML' services: app: ports: - "9010:3000" - "9020:4000" YAML; $application = new ServiceApplication; $application->name = 'app'; $application->fqdn = 'https://good.example.com:3000,https://bad.example.com:9999'; $service->setRelation('applications', collect([$application])); $application->setRelation('service', $service); $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->not->toBeEmpty() ->and($warnings[0])->toContain('published host port could not be resolved') ->and($manager->calls)->toHaveCount(1); $writeCommands = implode("\n", $manager->calls[0]['commands']); expect($writeCommands)->toContain('/tmp/proxy/dynamic/service-remote-service-partial-routes.yaml') ->and($writeCommands)->toContain('tee') ->and($writeCommands)->not->toContain('rm -f'); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('Host(`good.example.com`)') ->and($payload)->not->toContain('Host(`bad.example.com`)') ->and($payload)->toContain('http://10.8.0.18:9010'); }); it('does not generate edge route file when remote host is missing and returns actionable warning', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 12; $deploymentServer->ip = ''; $deploymentServer->proxy = ['type' => 'NONE']; $service = new Service; $service->uuid = 'service-without-tunnel-host'; $service->docker_compose_raw = <<<'YAML' services: app: ports: - "9010:3000" YAML; $application = new ServiceApplication; $application->name = 'app'; $application->fqdn = 'https://missing-tunnel.example.com:3000'; $service->setRelation('applications', collect([$application])); $application->setRelation('service', $service); $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->not->toBeEmpty() ->and($warnings[0])->toContain('remote host is missing') ->and(implode("\n", $manager->calls[0]['commands']))->toContain('/tmp/proxy/dynamic/service-remote-service-without-tunnel-host.yaml') ->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee'); }); it('returns warning instead of throwing when edge route file write fails', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; if (str_contains(implode("\n", $commands), 'tee')) { throw new RuntimeException('edge ssh unavailable'); } return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 14; $deploymentServer->ip = '10.8.0.19'; $deploymentServer->proxy = ['type' => 'NONE']; $service = new Service; $service->uuid = 'service-write-failure'; $service->docker_compose_raw = <<<'YAML' services: app: ports: - "9010:3000" YAML; $application = new ServiceApplication; $application->name = 'app'; $application->fqdn = 'https://write-failure.example.com:3000'; $service->setRelation('applications', collect([$application])); $application->setRelation('service', $service); $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->not->toBeEmpty() ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'failed to write dynamic route configuration'))) ->and($manager->calls)->toHaveCount(1); }); it('normalizes remote tunnel host values before generating upstream url', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 15; $deploymentServer->ip = ''; $deploymentServer->proxy = ['type' => 'NONE', 'tunnel_host' => 'https://10.8.0.20:9443/path']; $service = new Service; $service->uuid = 'service-normalized-host'; $service->docker_compose_raw = <<<'YAML' services: app: ports: - "9010:3000" YAML; $application = new ServiceApplication; $application->name = 'app'; $application->fqdn = 'https://normalized.example.com:3000'; $service->setRelation('applications', collect([$application])); $application->setRelation('service', $service); $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('http://10.8.0.20:9010') ->and($payload)->not->toContain('9443'); }); it('resolves published port when application name differs but compose has a single service with ports', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 16; $deploymentServer->ip = '10.8.0.21'; $deploymentServer->proxy = ['type' => 'NONE']; $service = new Service; $service->uuid = 'service-single-compose-fallback'; $service->docker_compose_raw = <<<'YAML' services: app: ports: - "9030:3000" YAML; $application = new ServiceApplication; $application->name = 'web'; $application->fqdn = 'https://single-fallback.example.com:3000'; $service->setRelation('applications', collect([$application])); $application->setRelation('service', $service); $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('http://10.8.0.21:9030'); }); it('ignores udp published ports when resolving upstream for edge routes', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 17; $deploymentServer->ip = '10.8.0.22'; $deploymentServer->proxy = ['type' => 'NONE']; $service = new Service; $service->uuid = 'service-udp-filtering'; $service->docker_compose_raw = <<<'YAML' services: app: ports: - "9010:3000/udp" - "9020:3000/tcp" YAML; $application = new ServiceApplication; $application->name = 'app'; $application->fqdn = 'https://udp-filtering.example.com:3000'; $service->setRelation('applications', collect([$application])); $application->setRelation('service', $service); $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('http://10.8.0.22:9020') ->and($payload)->not->toContain('http://10.8.0.22:9010'); }); it('returns warning for invalid domains while keeping valid remote edge routes', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 18; $deploymentServer->ip = '10.8.0.23'; $deploymentServer->proxy = ['type' => 'NONE']; $service = new Service; $service->uuid = 'service-invalid-domain-warning'; $service->docker_compose_raw = <<<'YAML' services: app: ports: - "9040:3000" YAML; $application = new ServiceApplication; $application->name = 'app'; $application->fqdn = 'https://valid.example.com:3000,https://:3000'; $service->setRelation('applications', collect([$application])); $application->setRelation('service', $service); $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->not->toBeEmpty() ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'domain format is invalid'))) ->and($manager->calls)->toHaveCount(1); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('Host(`valid.example.com`)') ->and($payload)->toContain('http://10.8.0.23:9040') ->and($payload)->not->toContain('https://:3000'); }); it('returns warning for unsupported domain protocols while keeping valid http routes', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 19; $deploymentServer->ip = '10.8.0.24'; $deploymentServer->proxy = ['type' => 'NONE']; $service = new Service; $service->uuid = 'service-unsupported-protocol-warning'; $service->docker_compose_raw = <<<'YAML' services: app: ports: - "9050:3000" - "25565:25565" YAML; $application = new ServiceApplication; $application->name = 'app'; $application->fqdn = 'https://valid-http.example.com:3000,tcp://minecraft.example.com:25565'; $service->setRelation('applications', collect([$application])); $application->setRelation('service', $service); $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->not->toBeEmpty() ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'protocol "tcp" is not supported for edge remote routing'))) ->and($manager->calls)->toHaveCount(1); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('Host(`valid-http.example.com`)') ->and($payload)->toContain('http://10.8.0.24:9050') ->and($payload)->not->toContain('minecraft.example.com'); }); it('returns warning when remote tunnel ip overlaps with an edge docker subnet', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; if (str_contains(implode("\n", $commands), 'docker network inspect')) { return "10.8.0.0/24\n172.18.0.0/16\n"; } return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->exists = true; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 20; $deploymentServer->ip = '10.8.0.40'; $deploymentServer->proxy = ['type' => 'NONE']; $service = new Service; $service->uuid = 'service-overlap-warning'; $service->docker_compose_raw = <<<'YAML' services: app: ports: - "9060:3000" YAML; $application = new ServiceApplication; $application->name = 'app'; $application->fqdn = 'https://overlap.example.com:3000'; $service->setRelation('applications', collect([$application])); $application->setRelation('service', $service); $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); expect($warnings)->not->toBeEmpty() ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'overlaps edge Docker network subnet 10.8.0.0/24'))); $writeCall = collect($manager->calls)->first( fn (array $call) => str_contains(implode("\n", $call['commands']), 'base64 -d | tee') ); expect($writeCall)->not->toBeNull(); preg_match("/echo '([^']+)' \\| base64 -d/", $writeCall['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('Host(`overlap.example.com`)') ->and($payload)->toContain('http://10.8.0.40:9060'); }); it('generates path prefix rule when route contains a non-root path', function () { $service = new EdgeProxyRemoteRouteService; $config = $service->generateTraefikConfig('service-path-uuid', [[ 'host' => 'api.example.com', 'path' => '/v1', 'upstream_url' => 'http://10.8.0.41:9070', ]]); expect(data_get($config, 'http.routers.edge-service-path-uuid-http-1.rule')) ->toBe('Host(`api.example.com`) && PathPrefix(`/v1`)') ->and(data_get($config, 'http.routers.edge-service-path-uuid-https-1.rule')) ->toBe('Host(`api.example.com`) && PathPrefix(`/v1`)'); }); it('deletes application edge route when deployment server is the same as edge proxy server', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 33; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 33; $deploymentServer->ip = '10.8.0.33'; $deploymentServer->proxy = ['type' => 'NONE']; $application = new Application; $application->uuid = 'application-edge-self'; $application->build_pack = 'nixpacks'; $application->fqdn = 'https://self.example.com:3000'; $application->ports_mappings = '9071:3000'; $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]) ->and($manager->calls)->toHaveCount(1) ->and(implode("\n", $manager->calls[0]['commands']))->toContain("/tmp/proxy/dynamic/application-remote-application-edge-self.yaml") ->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee'); }); it('normalizes ipv6 tunnel host for application upstream urls', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 34; $deploymentServer->ip = '2001:db8::1'; $deploymentServer->proxy = ['type' => 'NONE']; $application = new Application; $application->uuid = 'application-ipv6-host'; $application->build_pack = 'nixpacks'; $application->fqdn = 'https://ipv6.example.com:3000'; $application->ports_mappings = '9072:3000'; $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('http://[2001:db8::1]:9072'); }); it('resolves docker compose application ports when domain service uses dashed name and compose uses underscore', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 35; $deploymentServer->ip = '10.8.0.35'; $deploymentServer->proxy = ['type' => 'NONE']; $application = new Application; $application->uuid = 'application-compose-normalized-service'; $application->build_pack = 'dockercompose'; $application->docker_compose_domains = json_encode([ 'my-app' => ['domain' => 'https://normalized-service.example.com:3000'], ]); $application->docker_compose_raw = <<<'YAML' services: my_app: ports: - "9073:3000" YAML; $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('Host(`normalized-service.example.com`)') ->and($payload)->toContain('http://10.8.0.35:9073'); }); it('resolves compose application published port from environment variables', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 36; $deploymentServer->ip = '10.8.0.36'; $deploymentServer->proxy = ['type' => 'NONE']; $application = new Application; $application->uuid = 'application-compose-env-port'; $application->build_pack = 'dockercompose'; $application->docker_compose_domains = json_encode([ 'web' => ['domain' => 'https://compose-env.example.com:3000'], ]); $application->docker_compose_raw = <<<'YAML' services: web: ports: - "${APP_HOST_PORT:-9080}:3000" YAML; $application->setRelation('environment_variables', collect([ (object) ['key' => 'APP_HOST_PORT', 'value' => '9091'], ])); $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); expect($warnings)->toBe([]); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('http://10.8.0.36:9091'); }); it('returns warning for unsupported application domain protocol while preserving valid http route', function () { $manager = new class extends EdgeProxyRemoteRouteService { public array $calls = []; protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string { $this->calls[] = [ 'commands' => $commands, 'throw_error' => $throwError, ]; return null; } }; $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); $edgeProxyServer->id = 0; $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); $deploymentServer = Mockery::mock(Server::class)->makePartial(); $deploymentServer->id = 37; $deploymentServer->ip = '10.8.0.37'; $deploymentServer->proxy = ['type' => 'NONE']; $application = new Application; $application->uuid = 'application-unsupported-protocol'; $application->build_pack = 'nixpacks'; $application->fqdn = 'https://valid-app.example.com:3000,tcp://minecraft.example.com:25565'; $application->ports_mappings = '9074:3000,25565:25565'; $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); expect($warnings)->not->toBeEmpty() ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'protocol "tcp" is not supported for edge remote routing'))) ->and($manager->calls)->toHaveCount(1); preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); $payload = base64_decode($payloadMatches[1]); expect($payload)->toContain('Host(`valid-app.example.com`)') ->and($payload)->toContain('http://10.8.0.37:9074') ->and($payload)->not->toContain('minecraft.example.com'); });