feature realiability and added coverage. Fail softly

This commit is contained in:
Iisyourdad 2026-02-28 00:42:35 -06:00
parent c6e561afc8
commit 35a5a77526
3 changed files with 183 additions and 6 deletions

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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');
});