mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
fix(proxy): route remote service domains via edge Traefik file provider
This commit is contained in:
parent
6b2a669cb9
commit
e1453f0e5a
4 changed files with 776 additions and 0 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
554
app/Services/EdgeProxyRemoteRouteService.php
Normal file
554
app/Services/EdgeProxyRemoteRouteService.php
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class EdgeProxyRemoteRouteService
|
||||
{
|
||||
private const string ROUTE_FILE_PREFIX = 'service-remote-';
|
||||
|
||||
public function syncService(Service $service): array
|
||||
{
|
||||
$edgeProxyServer = $this->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);
|
||||
}
|
||||
}
|
||||
207
tests/Unit/EdgeProxyRemoteRouteServiceTest.php
Normal file
207
tests/Unit/EdgeProxyRemoteRouteServiceTest.php
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Services\EdgeProxyRemoteRouteService;
|
||||
|
||||
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('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');
|
||||
});
|
||||
Loading…
Reference in a new issue