diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 699e476b6..5e1f2aee5 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -4,6 +4,7 @@ namespace App\Actions\Service; use App\Models\Service; use App\Services\EdgeProxyRemoteRouteService; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -16,7 +17,16 @@ class StartService public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { $service->parse(); - $edgeRoutingWarnings = app(EdgeProxyRemoteRouteService::class)->syncService($service); + $edgeRoutingWarnings = []; + try { + $edgeRoutingWarnings = app(EdgeProxyRemoteRouteService::class)->syncService($service); + } catch (\Throwable $exception) { + Log::warning('Failed to sync edge proxy route for service start.', [ + 'service_uuid' => $service->uuid, + 'error' => $exception->getMessage(), + ]); + $edgeRoutingWarnings[] = 'Failed to sync edge proxy route configuration. Check edge proxy connectivity and server settings.'; + } if ($stopBeforeStart) { StopService::run(service: $service, dockerCleanup: false); } diff --git a/app/Services/EdgeProxyRemoteRouteService.php b/app/Services/EdgeProxyRemoteRouteService.php index fac4c615e..f4eebeb38 100644 --- a/app/Services/EdgeProxyRemoteRouteService.php +++ b/app/Services/EdgeProxyRemoteRouteService.php @@ -21,7 +21,21 @@ class EdgeProxyRemoteRouteService $edgeProxyServer = $this->resolveEdgeProxyServer($service); $deploymentServer = $this->resolveDeploymentServer($service); - if (! $edgeProxyServer instanceof Server || ! $deploymentServer instanceof Server) { + if (! $deploymentServer instanceof Server) { + return []; + } + + if (! $edgeProxyServer instanceof Server) { + if ($deploymentServer->id !== 0) { + $warning = sprintf( + 'Edge proxy route skipped for service %s: edge proxy server (id=0) was not found for the current team.', + $service->uuid + ); + $this->logWarning($warning); + + return [$warning]; + } + return []; } @@ -112,7 +126,17 @@ class EdgeProxyRemoteRouteService } $config = $this->generateTraefikConfig($service->uuid, $routes); - $this->writeRouteFile($edgeProxyServer, $service->uuid, $config); + try { + $this->writeRouteFile($edgeProxyServer, $service->uuid, $config); + } catch (\Throwable $exception) { + $warning = sprintf( + 'Edge proxy route partially applied for service %s: failed to write dynamic route configuration on edge proxy (%s).', + $service->uuid, + $exception->getMessage() + ); + $this->logWarning($warning); + $warnings[] = $warning; + } return $warnings; } @@ -323,9 +347,9 @@ class EdgeProxyRemoteRouteService ]; foreach ($candidates as $candidate) { - $value = trim((string) $candidate); - if ($value !== '') { - return $value; + $normalizedHost = $this->normalizeRemoteHost((string) $candidate); + if (! is_null($normalizedHost)) { + return $normalizedHost; } } @@ -547,4 +571,42 @@ class EdgeProxyRemoteRouteService error_log($message); } + + private function normalizeRemoteHost(string $rawHost): ?string + { + $host = trim($rawHost); + if ($host === '') { + return null; + } + + // Allow values like https://10.8.0.15:8080/path and extract only host. + if (Str::startsWith($host, ['http://', 'https://'])) { + $parsedHost = parse_url($host, PHP_URL_HOST); + $host = is_string($parsedHost) ? $parsedHost : ''; + } elseif (str_contains($host, '/')) { + $parsedHost = parse_url('http://'.$host, PHP_URL_HOST); + $host = is_string($parsedHost) ? $parsedHost : ''; + } + + $host = trim($host, '[]'); + if ($host === '') { + return null; + } + + // Drop accidental host:port values so published compose port remains authoritative. + if (str_contains($host, ':') && ! filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $parsedHost = parse_url('http://'.$host, PHP_URL_HOST); + $host = is_string($parsedHost) ? $parsedHost : ''; + } + + if ($host === '') { + return null; + } + + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return '['.$host.']'; + } + + return $host; + } } diff --git a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php index 41ca15daf..2e70b7faf 100644 --- a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php +++ b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php @@ -267,3 +267,108 @@ YAML; ->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'); +});