From e1453f0e5a6ffad061ab1f0361e4e3dc1ed4f8e1 Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Fri, 27 Feb 2026 21:59:18 -0600 Subject: [PATCH] fix(proxy): route remote service domains via edge Traefik file provider --- app/Actions/Service/DeleteService.php | 10 + app/Actions/Service/StartService.php | 5 + app/Services/EdgeProxyRemoteRouteService.php | 554 ++++++++++++++++++ .../Unit/EdgeProxyRemoteRouteServiceTest.php | 207 +++++++ 4 files changed, 776 insertions(+) create mode 100644 app/Services/EdgeProxyRemoteRouteService.php create mode 100644 tests/Unit/EdgeProxyRemoteRouteServiceTest.php diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 8790901cd..31eea7692 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -4,6 +4,7 @@ namespace App\Actions\Service; use App\Actions\Server\CleanupDocker; use App\Models\Service; +use App\Services\EdgeProxyRemoteRouteService; use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; @@ -55,6 +56,15 @@ class DeleteService } catch (\Exception $e) { throw new \RuntimeException($e->getMessage()); } finally { + try { + app(EdgeProxyRemoteRouteService::class)->deleteService($service); + } catch (\Throwable $exception) { + Log::warning('Failed to delete edge proxy route file for service.', [ + 'service_uuid' => $service->uuid, + 'error' => $exception->getMessage(), + ]); + } + if ($deleteConfigurations) { $service->deleteConfigurations(); } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 6b5e1d4ac..699e476b6 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -3,6 +3,7 @@ namespace App\Actions\Service; use App\Models\Service; +use App\Services\EdgeProxyRemoteRouteService; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -15,6 +16,7 @@ class StartService public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { $service->parse(); + $edgeRoutingWarnings = app(EdgeProxyRemoteRouteService::class)->syncService($service); if ($stopBeforeStart) { StopService::run(service: $service, dockerCleanup: false); } @@ -23,6 +25,9 @@ class StartService $workdir = $service->workdir(); // $commands[] = "cd {$workdir}"; $commands[] = "echo 'Saved configuration files to {$workdir}.'"; + foreach ($edgeRoutingWarnings as $warning) { + $commands[] = 'echo '.escapeshellarg("Edge proxy routing warning: {$warning}"); + } // Ensure .env exists in the correct directory before docker compose tries to load it // This is defensive programming - saveComposeConfigs() already creates it, // but we guarantee it here in case of any edge cases or manual deployments diff --git a/app/Services/EdgeProxyRemoteRouteService.php b/app/Services/EdgeProxyRemoteRouteService.php new file mode 100644 index 000000000..3cdfefa2d --- /dev/null +++ b/app/Services/EdgeProxyRemoteRouteService.php @@ -0,0 +1,554 @@ +resolveEdgeProxyServer($service); + $deploymentServer = $this->resolveDeploymentServer($service); + + if (! $edgeProxyServer instanceof Server || ! $deploymentServer instanceof Server) { + return []; + } + + return $this->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); + } + + public function syncServiceWithServers(Service $service, Server $edgeProxyServer, Server $deploymentServer): array + { + if ($edgeProxyServer->proxyType() !== ProxyTypes::TRAEFIK->value) { + return []; + } + + if ($deploymentServer->id === $edgeProxyServer->id) { + $this->deleteRouteFile($edgeProxyServer, $service->uuid); + + return []; + } + + $applications = $this->getServiceApplicationsWithDomains($service); + if ($applications->isEmpty()) { + $this->deleteRouteFile($edgeProxyServer, $service->uuid); + + return []; + } + + $tunnelHost = $this->resolveTunnelHost($deploymentServer); + if (blank($tunnelHost)) { + $warning = sprintf( + 'Edge proxy route skipped for service %s: remote host is missing. Configure a tunnel host (proxy.wireguard_ip/proxy.wg_ip/proxy.tunnel_ip/proxy.tunnel_host) or set the server IP/domain.', + $service->uuid + ); + + $this->logWarning($warning); + $this->deleteRouteFile($edgeProxyServer, $service->uuid); + + return [$warning]; + } + + $compose = $this->parseServiceCompose($service); + $environmentMap = $this->serviceEnvironmentMap($service); + + $routes = []; + $warnings = []; + + foreach ($applications as $application) { + $domains = collect(explode(',', (string) $application->fqdn)) + ->map(fn (string $domain) => trim($domain)) + ->filter(); + + foreach ($domains as $domain) { + $url = $this->parseDomainUrl($domain); + if (! $url instanceof Url) { + continue; + } + + $requestedInternalPort = $url->getPort() ?? $application->getRequiredPort(); + $publishedPort = $this->resolvePublishedPort($compose, $application->name, $requestedInternalPort, $environmentMap); + + if (is_null($publishedPort)) { + $warnings[] = sprintf( + 'Edge proxy route skipped for service %s (%s, domain %s): published host port could not be resolved. Expose the container port in docker-compose "ports:" and/or include an explicit port in the domain.', + $service->uuid, + $application->name, + $domain + ); + + continue; + } + + $routes[] = [ + 'host' => $url->getHost(), + 'path' => $url->getPath(), + 'upstream_url' => sprintf('http://%s:%d', $tunnelHost, $publishedPort), + ]; + } + } + + if (! empty($warnings)) { + foreach ($warnings as $warning) { + $this->logWarning($warning); + } + + $this->deleteRouteFile($edgeProxyServer, $service->uuid); + + return $warnings; + } + + if (empty($routes)) { + $this->deleteRouteFile($edgeProxyServer, $service->uuid); + + return []; + } + + $config = $this->generateTraefikConfig($service->uuid, $routes); + $this->writeRouteFile($edgeProxyServer, $service->uuid, $config); + + return []; + } + + public function deleteService(Service $service): void + { + $edgeProxyServer = $this->resolveEdgeProxyServer($service); + if (! $edgeProxyServer instanceof Server || $edgeProxyServer->proxyType() !== ProxyTypes::TRAEFIK->value) { + return; + } + + $this->deleteServiceWithServer($service, $edgeProxyServer); + } + + public function deleteServiceWithServer(Service $service, Server $edgeProxyServer): void + { + $this->deleteRouteFile($edgeProxyServer, $service->uuid); + } + + public function generateTraefikConfig(string $serviceUuid, array $routes): array + { + $serviceKey = Str::slug($serviceUuid); + if ($serviceKey === '') { + $serviceKey = 'service'; + } + + $redirectMiddlewareName = "edge-{$serviceKey}-redirect-to-https"; + + $config = [ + 'http' => [ + 'middlewares' => [ + $redirectMiddlewareName => [ + 'redirectScheme' => [ + 'scheme' => 'https', + ], + ], + ], + 'routers' => [], + 'services' => [], + ], + ]; + + foreach ($routes as $index => $route) { + $suffix = $index + 1; + + $httpRouterName = "edge-{$serviceKey}-http-{$suffix}"; + $httpsRouterName = "edge-{$serviceKey}-https-{$suffix}"; + $serviceName = "edge-{$serviceKey}-svc-{$suffix}"; + $rule = $this->buildTraefikRule($route['host'], $route['path']); + + $config['http']['routers'][$httpRouterName] = [ + 'rule' => $rule, + 'entryPoints' => ['http'], + 'middlewares' => [$redirectMiddlewareName], + 'service' => $serviceName, + ]; + + $config['http']['routers'][$httpsRouterName] = [ + 'rule' => $rule, + 'entryPoints' => ['https'], + 'service' => $serviceName, + 'tls' => [ + 'certResolver' => 'letsencrypt', + ], + ]; + + $config['http']['services'][$serviceName] = [ + 'loadBalancer' => [ + 'servers' => [ + ['url' => $route['upstream_url']], + ], + ], + ]; + } + + return $config; + } + + public function routeFilePath(Server $edgeProxyServer, string $serviceUuid): string + { + return sprintf( + '%s/%s%s.yaml', + $this->routeDirectoryPath($edgeProxyServer), + self::ROUTE_FILE_PREFIX, + $serviceUuid + ); + } + + protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string + { + return instant_remote_process($commands, $server, $throwError); + } + + private function routeDirectoryPath(Server $edgeProxyServer): string + { + return rtrim($edgeProxyServer->proxyPath(), '/').'/dynamic'; + } + + private function writeRouteFile(Server $edgeProxyServer, string $serviceUuid, array $config): void + { + $yaml = Yaml::dump($config, 12, 2); + $banner = "# This file is generated by Coolify, do not edit it manually.\n\n"; + $payload = base64_encode($banner.$yaml); + + $escapedDirectory = escapeshellarg($this->routeDirectoryPath($edgeProxyServer)); + $escapedFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid)); + + $this->runRemoteCommands($edgeProxyServer, [ + "mkdir -p $escapedDirectory", + "echo '$payload' | base64 -d | tee $escapedFilePath > /dev/null", + ]); + } + + private function deleteRouteFile(Server $edgeProxyServer, string $serviceUuid): void + { + $escapedFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid)); + + $this->runRemoteCommands($edgeProxyServer, [ + "rm -f $escapedFilePath", + ], false); + } + + private function buildTraefikRule(string $host, ?string $path): string + { + $rule = sprintf('Host(`%s`)', $host); + + if (! is_null($path) && $path !== '' && $path !== '/') { + $rule .= sprintf(' && PathPrefix(`%s`)', $path); + } + + return $rule; + } + + private function resolveEdgeProxyServer(Service $service): ?Server + { + $teamId = $this->extractTeamId($service); + if (is_null($teamId)) { + return null; + } + + return Server::query() + ->where('team_id', $teamId) + ->where('id', 0) + ->first(); + } + + private function resolveDeploymentServer(Service $service): ?Server + { + $server = data_get($service, 'server'); + if ($server instanceof Server) { + return $server; + } + + $server = data_get($service, 'destination.server'); + if ($server instanceof Server) { + return $server; + } + + if ($service->exists && ! is_null($service->server_id)) { + return Server::query()->find($service->server_id); + } + + return null; + } + + private function extractTeamId(Service $service): ?int + { + $teamId = data_get($service, 'environment.project.team_id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + + if ($service->exists) { + $service->loadMissing('environment.project'); + $teamId = data_get($service, 'environment.project.team_id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + } + + return null; + } + + private function getServiceApplicationsWithDomains(Service $service): Collection + { + $applications = collect([]); + + if ($service->relationLoaded('applications')) { + $applications = $service->applications; + } elseif ($service->exists) { + $applications = $service->applications()->get(); + } + + return $applications + ->filter(fn (ServiceApplication $application) => filled($application->fqdn)) + ->values(); + } + + private function resolveTunnelHost(Server $deploymentServer): ?string + { + $candidates = [ + data_get($deploymentServer, 'proxy.wireguard_ip'), + data_get($deploymentServer, 'proxy.wg_ip'), + data_get($deploymentServer, 'proxy.tunnel_ip'), + data_get($deploymentServer, 'proxy.tunnel_host'), + data_get($deploymentServer, 'proxy.tunnel_domain'), + data_get($deploymentServer, 'ip'), + ]; + + foreach ($candidates as $candidate) { + $value = trim((string) $candidate); + if ($value !== '') { + return $value; + } + } + + return null; + } + + private function parseServiceCompose(Service $service): array + { + if (blank($service->docker_compose_raw)) { + return []; + } + + try { + $parsedCompose = Yaml::parse($service->docker_compose_raw); + + return is_array($parsedCompose) ? $parsedCompose : []; + } catch (\Throwable) { + return []; + } + } + + private function serviceEnvironmentMap(Service $service): array + { + if ($service->relationLoaded('environment_variables')) { + return $service->environment_variables + ->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value]) + ->all(); + } + + if (! $service->exists) { + return []; + } + + return $service->environment_variables() + ->get() + ->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value]) + ->all(); + } + + private function parseDomainUrl(string $domain): ?Url + { + $normalizedDomain = trim($domain); + if ($normalizedDomain === '') { + return null; + } + + if (! Str::startsWith($normalizedDomain, ['http://', 'https://'])) { + $normalizedDomain = 'https://'.$normalizedDomain; + } + + try { + $url = Url::fromString($normalizedDomain, ['http', 'https']); + if ($url->getHost() === '') { + return null; + } + + return $url; + } catch (\Throwable) { + return null; + } + } + + private function resolvePublishedPort(array $compose, string $serviceName, ?int $requestedInternalPort, array $environmentMap): ?int + { + $ports = data_get($compose, "services.$serviceName.ports", []); + if (! is_array($ports)) { + return null; + } + + $portMappings = $this->parsePortMappings($ports, $environmentMap) + ->filter(fn (array $mapping) => ! is_null($mapping['published'])) + ->values(); + + if ($portMappings->isEmpty()) { + return null; + } + + if (! is_null($requestedInternalPort)) { + $matchingTarget = $portMappings->first(fn (array $mapping) => $mapping['target'] === $requestedInternalPort); + if ($matchingTarget) { + return $matchingTarget['published']; + } + + $matchingPublished = $portMappings->first(fn (array $mapping) => $mapping['published'] === $requestedInternalPort); + if ($matchingPublished) { + return $matchingPublished['published']; + } + } + + if ($portMappings->count() === 1) { + return $portMappings->first()['published']; + } + + return null; + } + + private function parsePortMappings(array $ports, array $environmentMap): Collection + { + $mappings = collect(); + + foreach ($ports as $portDefinition) { + if (is_array($portDefinition)) { + $target = $this->resolvePortValue(data_get($portDefinition, 'target'), $environmentMap); + $published = $this->resolvePortValue(data_get($portDefinition, 'published'), $environmentMap); + + if (! is_null($target) || ! is_null($published)) { + $mappings->push([ + 'target' => $target, + 'published' => $published, + ]); + } + + continue; + } + + if (is_string($portDefinition) || is_int($portDefinition)) { + $mapping = $this->parsePortMappingFromString((string) $portDefinition, $environmentMap); + if (! is_null($mapping)) { + $mappings->push($mapping); + } + } + } + + return $mappings; + } + + private function parsePortMappingFromString(string $portDefinition, array $environmentMap): ?array + { + $normalizedPortDefinition = trim($portDefinition); + if ($normalizedPortDefinition === '') { + return null; + } + + $normalizedPortDefinition = preg_replace('/\/(tcp|udp)$/i', '', $normalizedPortDefinition) ?? $normalizedPortDefinition; + + if (str_contains($normalizedPortDefinition, ':')) { + $segments = explode(':', $normalizedPortDefinition); + if (count($segments) < 2) { + return null; + } + + $containerPort = $this->resolvePortValue(array_pop($segments), $environmentMap); + $hostPort = $this->resolvePortValue(array_pop($segments), $environmentMap); + + return [ + 'target' => $containerPort, + 'published' => $hostPort, + ]; + } + + return [ + 'target' => $this->resolvePortValue($normalizedPortDefinition, $environmentMap), + 'published' => null, + ]; + } + + private function resolvePortValue(mixed $rawPortValue, array $environmentMap): ?int + { + if (is_int($rawPortValue)) { + return $rawPortValue; + } + + $normalizedPortValue = trim((string) $rawPortValue); + if ($normalizedPortValue === '' || str_contains($normalizedPortValue, '-')) { + return null; + } + + if (preg_match('/^\d+$/', $normalizedPortValue)) { + return (int) $normalizedPortValue; + } + + if ( + preg_match( + '/^\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?])([^}]*))?\}$/', + $normalizedPortValue, + $matches + ) + ) { + $environmentKey = $matches[1]; + $defaultPort = trim((string) ($matches[3] ?? '')); + + $resolvedEnvironmentPort = $environmentMap[$environmentKey] ?? null; + if (! is_null($resolvedEnvironmentPort) && is_numeric(trim((string) $resolvedEnvironmentPort))) { + return (int) trim((string) $resolvedEnvironmentPort); + } + + if ($defaultPort !== '' && is_numeric($defaultPort)) { + return (int) $defaultPort; + } + + return null; + } + + if (preg_match('/^\$([A-Za-z_][A-Za-z0-9_]*)$/', $normalizedPortValue, $matches)) { + $environmentKey = $matches[1]; + $resolvedEnvironmentPort = $environmentMap[$environmentKey] ?? null; + if (! is_null($resolvedEnvironmentPort) && is_numeric(trim((string) $resolvedEnvironmentPort))) { + return (int) trim((string) $resolvedEnvironmentPort); + } + + return null; + } + + if (array_key_exists($normalizedPortValue, $environmentMap) && is_numeric(trim((string) $environmentMap[$normalizedPortValue]))) { + return (int) trim((string) $environmentMap[$normalizedPortValue]); + } + + return null; + } + + private function logWarning(string $message): void + { + $container = Container::getInstance(); + if ($container instanceof Container && $container->bound('log')) { + $container->make('log')->warning($message); + + return; + } + + error_log($message); + } +} diff --git a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php new file mode 100644 index 000000000..a514ec4b8 --- /dev/null +++ b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php @@ -0,0 +1,207 @@ +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('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'; + $firstWriteCommands = implode("\n", $manager->calls[0]['commands']); + + expect($firstWriteCommands)->toContain($expectedPath) + ->and($firstWriteCommands)->toContain('tee'); + + 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('tee'); + + 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'"); +}); + +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('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'); +});