mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
support master-domain routing for remote apps and database proxies
This commit is contained in:
parent
aaf0b94bce
commit
7fc8bc665f
12 changed files with 1987 additions and 78 deletions
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Actions\Database;
|
||||
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
|
|
@ -24,7 +25,7 @@ class StartDatabaseProxy
|
|||
{
|
||||
$databaseType = $database->database_type;
|
||||
$network = data_get($database, 'destination.network');
|
||||
$server = data_get($database, 'destination.server');
|
||||
$deploymentServer = data_get($database, 'destination.server');
|
||||
$containerName = data_get($database, 'uuid');
|
||||
$proxyContainerName = "{$database->uuid}-proxy";
|
||||
$isSSLEnabled = $database->enable_ssl ?? false;
|
||||
|
|
@ -32,7 +33,7 @@ class StartDatabaseProxy
|
|||
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
$databaseType = $database->databaseType();
|
||||
$network = $database->service->uuid;
|
||||
$server = data_get($database, 'service.destination.server');
|
||||
$deploymentServer = data_get($database, 'service.destination.server') ?? data_get($database, 'service.server');
|
||||
$containerName = "{$database->name}-{$database->service->uuid}";
|
||||
}
|
||||
$internalPort = match ($databaseType) {
|
||||
|
|
@ -50,10 +51,30 @@ class StartDatabaseProxy
|
|||
};
|
||||
}
|
||||
|
||||
$configuration_dir = database_proxy_dir($database->uuid);
|
||||
if (isDev()) {
|
||||
$configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
|
||||
if (! $deploymentServer instanceof Server) {
|
||||
throw new \RuntimeException('Cannot start database proxy: deployment server is missing.');
|
||||
}
|
||||
|
||||
$proxyServer = $deploymentServer;
|
||||
$upstreamTarget = "{$containerName}:{$internalPort}";
|
||||
$proxyNetwork = $network;
|
||||
|
||||
$edgeProxyServer = $this->resolveEdgeProxyServerForTeamId($this->resolveDatabaseTeamId($database));
|
||||
if ($edgeProxyServer instanceof Server && $edgeProxyServer->id !== $deploymentServer->id) {
|
||||
$remoteHost = $this->resolveRemoteHost($deploymentServer);
|
||||
if (! is_null($remoteHost)) {
|
||||
$proxyServer = $edgeProxyServer;
|
||||
$upstreamTarget = "{$remoteHost}:{$internalPort}";
|
||||
$proxyNetwork = null;
|
||||
} else {
|
||||
$this->logWarning(sprintf(
|
||||
'Database proxy for %s is falling back to deployment server because remote host for edge forwarding is missing.',
|
||||
$database->uuid
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$configuration_dir = $this->resolveConfigurationDirectory($database->uuid);
|
||||
$nginxconf = <<<EOF
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
|
@ -66,61 +87,68 @@ class StartDatabaseProxy
|
|||
stream {
|
||||
server {
|
||||
listen $database->public_port;
|
||||
proxy_pass $containerName:$internalPort;
|
||||
proxy_pass $upstreamTarget;
|
||||
}
|
||||
}
|
||||
EOF;
|
||||
$docker_compose = [
|
||||
'services' => [
|
||||
$proxyContainerName => [
|
||||
'image' => 'nginx:stable-alpine',
|
||||
'container_name' => $proxyContainerName,
|
||||
'restart' => RESTART_MODE,
|
||||
'ports' => [
|
||||
"$database->public_port:$database->public_port",
|
||||
],
|
||||
'networks' => [
|
||||
$network,
|
||||
],
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => "$configuration_dir/nginx.conf",
|
||||
'target' => '/etc/nginx/nginx.conf',
|
||||
],
|
||||
],
|
||||
'healthcheck' => [
|
||||
'test' => [
|
||||
'CMD-SHELL',
|
||||
'stat /etc/nginx/nginx.conf || exit 1',
|
||||
],
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 3,
|
||||
'start_period' => '1s',
|
||||
],
|
||||
$proxyServiceCompose = [
|
||||
'image' => 'nginx:stable-alpine',
|
||||
'container_name' => $proxyContainerName,
|
||||
'restart' => RESTART_MODE,
|
||||
'ports' => [
|
||||
"$database->public_port:$database->public_port",
|
||||
],
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => "$configuration_dir/nginx.conf",
|
||||
'target' => '/etc/nginx/nginx.conf',
|
||||
],
|
||||
],
|
||||
'networks' => [
|
||||
$network => [
|
||||
'external' => true,
|
||||
'name' => $network,
|
||||
'attachable' => true,
|
||||
'healthcheck' => [
|
||||
'test' => [
|
||||
'CMD-SHELL',
|
||||
'stat /etc/nginx/nginx.conf || exit 1',
|
||||
],
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 3,
|
||||
'start_period' => '1s',
|
||||
],
|
||||
];
|
||||
|
||||
$docker_compose = [
|
||||
'services' => [
|
||||
$proxyContainerName => $proxyServiceCompose,
|
||||
],
|
||||
];
|
||||
|
||||
if (filled($proxyNetwork)) {
|
||||
$docker_compose['services'][$proxyContainerName]['networks'] = [$proxyNetwork];
|
||||
$docker_compose['networks'] = [
|
||||
$proxyNetwork => [
|
||||
'external' => true,
|
||||
'name' => $proxyNetwork,
|
||||
'attachable' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2));
|
||||
$nginxconf_base64 = base64_encode($nginxconf);
|
||||
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
|
||||
$this->runRemoteCommands(["docker rm -f $proxyContainerName"], $deploymentServer, false);
|
||||
if ($proxyServer->id !== $deploymentServer->id) {
|
||||
$this->runRemoteCommands(["docker rm -f $proxyContainerName"], $proxyServer, false);
|
||||
}
|
||||
|
||||
try {
|
||||
instant_remote_process([
|
||||
$this->runRemoteCommands([
|
||||
"mkdir -p $configuration_dir",
|
||||
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
|
||||
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
|
||||
"docker compose --project-directory {$configuration_dir} pull",
|
||||
"docker compose --project-directory {$configuration_dir} up -d",
|
||||
], $server);
|
||||
], $proxyServer);
|
||||
} catch (\RuntimeException $e) {
|
||||
if ($this->isNonTransientError($e->getMessage())) {
|
||||
$database->update(['is_public' => false]);
|
||||
|
|
@ -131,7 +159,7 @@ class StartDatabaseProxy
|
|||
$team?->notify(
|
||||
new \App\Notifications\Container\ContainerRestarted(
|
||||
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
|
||||
$server,
|
||||
$proxyServer,
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -144,6 +172,124 @@ class StartDatabaseProxy
|
|||
}
|
||||
}
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
return instant_remote_process($commands, $server, $throwError);
|
||||
}
|
||||
|
||||
protected function resolveConfigurationDirectory(string $databaseUuid): string
|
||||
{
|
||||
$configurationDirectory = database_proxy_dir($databaseUuid);
|
||||
if (isDev()) {
|
||||
$configurationDirectory = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$databaseUuid.'/proxy';
|
||||
}
|
||||
|
||||
return $configurationDirectory;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
if (is_null($teamId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Server::query()
|
||||
->where('team_id', $teamId)
|
||||
->whereRelation('settings', 'is_master_domain_router_enabled', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveDatabaseTeamId(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database): ?int
|
||||
{
|
||||
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
$teamId = data_get($database, 'service.environment.project.team_id');
|
||||
if (! is_null($teamId)) {
|
||||
return (int) $teamId;
|
||||
}
|
||||
}
|
||||
|
||||
$teamId = data_get($database, 'environment.project.team_id');
|
||||
if (! is_null($teamId)) {
|
||||
return (int) $teamId;
|
||||
}
|
||||
|
||||
$teamId = data_get($database, 'team.id');
|
||||
if (! is_null($teamId)) {
|
||||
return (int) $teamId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveRemoteHost(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) {
|
||||
$normalizedHost = $this->normalizeRemoteHost((string) $candidate);
|
||||
if (! is_null($normalizedHost)) {
|
||||
return $normalizedHost;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeRemoteHost(string $rawHost): ?string
|
||||
{
|
||||
$host = trim($rawHost);
|
||||
if ($host === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($host, 'http://') || str_starts_with($host, '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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
protected function logWarning(string $message): void
|
||||
{
|
||||
if (app()->bound('log')) {
|
||||
app('log')->warning($message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
error_log($message);
|
||||
}
|
||||
|
||||
private function isNonTransientError(string $message): bool
|
||||
{
|
||||
$nonTransientPatterns = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Actions\Database;
|
|||
|
||||
use App\Events\DatabaseProxyStopped;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
|
|
@ -22,16 +23,69 @@ class StopDatabaseProxy
|
|||
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|ServiceDatabase|StandaloneDragonfly|StandaloneClickhouse $database)
|
||||
{
|
||||
$server = data_get($database, 'destination.server');
|
||||
$deploymentServer = data_get($database, 'destination.server');
|
||||
$uuid = $database->uuid;
|
||||
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
$server = data_get($database, 'service.server');
|
||||
$deploymentServer = data_get($database, 'service.destination.server') ?? data_get($database, 'service.server');
|
||||
}
|
||||
if (! $deploymentServer instanceof Server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runRemoteCommands(["docker rm -f {$uuid}-proxy"], $deploymentServer, false);
|
||||
$edgeProxyServer = $this->resolveEdgeProxyServerForTeamId($this->resolveDatabaseTeamId($database));
|
||||
if ($edgeProxyServer instanceof Server && $edgeProxyServer->id !== $deploymentServer->id) {
|
||||
$this->runRemoteCommands(["docker rm -f {$uuid}-proxy"], $edgeProxyServer, false);
|
||||
}
|
||||
instant_remote_process(["docker rm -f {$uuid}-proxy"], $server);
|
||||
|
||||
$database->save();
|
||||
|
||||
DatabaseProxyStopped::dispatch();
|
||||
$this->dispatchDatabaseProxyStoppedEvent();
|
||||
|
||||
}
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
return instant_remote_process($commands, $server, $throwError);
|
||||
}
|
||||
|
||||
protected function dispatchDatabaseProxyStoppedEvent(): void
|
||||
{
|
||||
DatabaseProxyStopped::dispatch();
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
if (is_null($teamId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Server::query()
|
||||
->where('team_id', $teamId)
|
||||
->whereRelation('settings', 'is_master_domain_router_enabled', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveDatabaseTeamId(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|ServiceDatabase|StandaloneDragonfly|StandaloneClickhouse $database): ?int
|
||||
{
|
||||
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
$teamId = data_get($database, 'service.environment.project.team_id');
|
||||
if (! is_null($teamId)) {
|
||||
return (int) $teamId;
|
||||
}
|
||||
}
|
||||
|
||||
$teamId = data_get($database, 'environment.project.team_id');
|
||||
if (! is_null($teamId)) {
|
||||
return (int) $teamId;
|
||||
}
|
||||
|
||||
$teamId = data_get($database, 'team.id');
|
||||
if (! is_null($teamId)) {
|
||||
return (int) $teamId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use App\Models\StandaloneDocker;
|
|||
use App\Models\SwarmDocker;
|
||||
use App\Notifications\Application\DeploymentFailed;
|
||||
use App\Notifications\Application\DeploymentSuccess;
|
||||
use App\Services\EdgeProxyRemoteRouteService;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\ExecuteRemoteCommand;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -508,6 +509,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
} catch (\Exception $e) {
|
||||
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
if ($this->pull_request_id === 0) {
|
||||
try {
|
||||
$edgeRoutingWarnings = app(EdgeProxyRemoteRouteService::class)
|
||||
->syncApplicationOnDeploymentServer($this->application, $this->mainServer);
|
||||
foreach ($edgeRoutingWarnings as $warning) {
|
||||
$this->application_deployment_queue->addLogEntry("Edge proxy routing warning: {$warning}", 'stderr');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('Failed to sync edge proxy route for application deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
$this->application_deployment_queue->addLogEntry(
|
||||
'Edge proxy routing warning: Failed to sync edge proxy route configuration. Check master domain server settings and edge proxy connectivity.',
|
||||
'stderr'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deploy_simple_dockerfile()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Actions\Database\StopDatabase;
|
|||
use App\Actions\Server\CleanupDocker;
|
||||
use App\Actions\Service\DeleteService;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Services\EdgeProxyRemoteRouteService;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Service;
|
||||
|
|
@ -102,6 +103,14 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
|||
} catch (\Throwable $e) {
|
||||
throw $e;
|
||||
} finally {
|
||||
if ($this->resource instanceof Application) {
|
||||
try {
|
||||
app(EdgeProxyRemoteRouteService::class)->deleteApplication($this->resource);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('Failed to delete edge proxy route file for application '.$this->resource->uuid.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->resource->forceDelete();
|
||||
if ($this->dockerCleanup) {
|
||||
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Livewire\Server;
|
|||
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Actions\Server\StopSentinel;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Server;
|
||||
|
|
@ -45,9 +46,17 @@ class Show extends Component
|
|||
|
||||
public bool $isBuildServer;
|
||||
|
||||
public bool $isMasterDomainRouterEnabled;
|
||||
|
||||
#[Locked]
|
||||
public bool $isBuildServerLocked = false;
|
||||
|
||||
#[Locked]
|
||||
public bool $isMasterDomainRouterLocked = false;
|
||||
|
||||
#[Locked]
|
||||
public ?string $masterDomainRouterLockMessage = null;
|
||||
|
||||
public bool $isMetricsEnabled;
|
||||
|
||||
public string $sentinelToken;
|
||||
|
|
@ -116,6 +125,7 @@ class Show extends Component
|
|||
'isSwarmManager' => 'required',
|
||||
'isSwarmWorker' => 'required',
|
||||
'isBuildServer' => 'required',
|
||||
'isMasterDomainRouterEnabled' => 'required',
|
||||
'isMetricsEnabled' => 'required',
|
||||
'sentinelToken' => 'required',
|
||||
'sentinelUpdatedAt' => 'nullable',
|
||||
|
|
@ -162,6 +172,7 @@ class Show extends Component
|
|||
if (! $this->server->isEmpty()) {
|
||||
$this->isBuildServerLocked = true;
|
||||
}
|
||||
$this->refreshMasterDomainRouterLockState();
|
||||
// Load saved Hetzner status and validation state
|
||||
$this->hetznerServerStatus = $this->server->hetzner_server_status;
|
||||
$this->isValidating = $this->server->is_validating ?? false;
|
||||
|
|
@ -213,6 +224,7 @@ class Show extends Component
|
|||
$this->server->settings->wildcard_domain = $this->wildcardDomain;
|
||||
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
|
||||
$this->server->settings->is_build_server = $this->isBuildServer;
|
||||
$this->server->settings->is_master_domain_router_enabled = $this->isMasterDomainRouterEnabled;
|
||||
$this->server->settings->is_metrics_enabled = $this->isMetricsEnabled;
|
||||
$this->server->settings->sentinel_token = $this->sentinelToken;
|
||||
$this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds;
|
||||
|
|
@ -243,6 +255,7 @@ class Show extends Component
|
|||
$this->isSwarmManager = $this->server->settings->is_swarm_manager;
|
||||
$this->isSwarmWorker = $this->server->settings->is_swarm_worker;
|
||||
$this->isBuildServer = $this->server->settings->is_build_server;
|
||||
$this->isMasterDomainRouterEnabled = $this->server->settings->is_master_domain_router_enabled;
|
||||
$this->isMetricsEnabled = $this->server->settings->is_metrics_enabled;
|
||||
$this->sentinelToken = $this->server->settings->sentinel_token;
|
||||
$this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds;
|
||||
|
|
@ -255,6 +268,8 @@ class Show extends Component
|
|||
$this->serverTimezone = $this->server->settings->server_timezone;
|
||||
$this->isValidating = $this->server->is_validating ?? false;
|
||||
}
|
||||
|
||||
$this->refreshMasterDomainRouterLockState();
|
||||
}
|
||||
|
||||
public function refresh()
|
||||
|
|
@ -353,6 +368,25 @@ class Show extends Component
|
|||
}
|
||||
}
|
||||
|
||||
public function updatedIsMasterDomainRouterEnabled($value)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->refreshMasterDomainRouterLockState();
|
||||
|
||||
if ($value === true && $this->isMasterDomainRouterLocked) {
|
||||
$this->isMasterDomainRouterEnabled = false;
|
||||
$this->dispatch('error', $this->masterDomainRouterLockMessage ?? 'Another server in this team is already selected as the master domain router.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->submit();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedIsSentinelEnabled($value)
|
||||
{
|
||||
try {
|
||||
|
|
@ -492,6 +526,34 @@ class Show extends Component
|
|||
}
|
||||
}
|
||||
|
||||
private function refreshMasterDomainRouterLockState(): void
|
||||
{
|
||||
$this->isMasterDomainRouterLocked = false;
|
||||
$this->masterDomainRouterLockMessage = null;
|
||||
|
||||
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->server->loadMissing('settings');
|
||||
if ($this->server->settings->is_master_domain_router_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$masterDomainServerExists = Server::query()
|
||||
->where('team_id', $this->server->team_id)
|
||||
->where('id', '!=', $this->server->id)
|
||||
->whereRelation('settings', 'is_master_domain_router_enabled', true)
|
||||
->exists();
|
||||
|
||||
if (! $masterDomainServerExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isMasterDomainRouterLocked = true;
|
||||
$this->masterDomainRouterLockMessage = 'Disabled because another server in this team is already selected as the master domain router. Disable it there first.';
|
||||
}
|
||||
|
||||
public function loadHetznerTokens(): void
|
||||
{
|
||||
$this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use OpenApi\Attributes as OA;
|
|||
'force_server_cleanup' => ['type' => 'boolean'],
|
||||
'is_build_server' => ['type' => 'boolean'],
|
||||
'is_cloudflare_tunnel' => ['type' => 'boolean'],
|
||||
'is_master_domain_router_enabled' => ['type' => 'boolean'],
|
||||
'is_jump_server' => ['type' => 'boolean'],
|
||||
'is_logdrain_axiom_enabled' => ['type' => 'boolean'],
|
||||
'is_logdrain_custom_enabled' => ['type' => 'boolean'],
|
||||
|
|
@ -62,6 +63,7 @@ class ServerSetting extends Model
|
|||
'is_reachable' => 'boolean',
|
||||
'is_usable' => 'boolean',
|
||||
'is_terminal_enabled' => 'boolean',
|
||||
'is_master_domain_router_enabled' => 'boolean',
|
||||
'disable_application_image_retention' => 'boolean',
|
||||
];
|
||||
|
||||
|
|
@ -90,6 +92,30 @@ class ServerSetting extends Model
|
|||
$settings->server->restartSentinel();
|
||||
}
|
||||
});
|
||||
static::saving(function ($setting) {
|
||||
$setting->ensureSingleMasterDomainRouterEnabled();
|
||||
});
|
||||
}
|
||||
|
||||
private function ensureSingleMasterDomainRouterEnabled(): void
|
||||
{
|
||||
if (! $this->is_master_domain_router_enabled || is_null($this->server_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$teamId = Server::query()
|
||||
->whereKey($this->server_id)
|
||||
->value('team_id');
|
||||
|
||||
if (is_null($teamId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::query()
|
||||
->where('server_id', '!=', $this->server_id)
|
||||
->where('is_master_domain_router_enabled', true)
|
||||
->whereHas('server', fn ($query) => $query->where('team_id', $teamId))
|
||||
->update(['is_master_domain_router_enabled' => false]);
|
||||
}
|
||||
|
||||
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
|
|
@ -14,11 +15,14 @@ use Symfony\Component\Yaml\Yaml;
|
|||
|
||||
class EdgeProxyRemoteRouteService
|
||||
{
|
||||
private const string ROUTE_FILE_PREFIX = 'service-remote-';
|
||||
private const string SERVICE_ROUTE_FILE_PREFIX = 'service-remote-';
|
||||
|
||||
private const string APPLICATION_ROUTE_FILE_PREFIX = 'application-remote-';
|
||||
|
||||
public function syncService(Service $service): array
|
||||
{
|
||||
$edgeProxyServer = $this->resolveEdgeProxyServer($service);
|
||||
$teamId = $this->extractServiceTeamId($service);
|
||||
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId);
|
||||
$deploymentServer = $this->resolveDeploymentServer($service);
|
||||
|
||||
if (! $deploymentServer instanceof Server) {
|
||||
|
|
@ -26,16 +30,6 @@ class EdgeProxyRemoteRouteService
|
|||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +73,7 @@ class EdgeProxyRemoteRouteService
|
|||
|
||||
$routes = [];
|
||||
$warnings = [];
|
||||
$networkOverlapWarning = $this->detectDockerNetworkOverlapWarning($service, $edgeProxyServer, $tunnelHost);
|
||||
$networkOverlapWarning = $this->detectDockerNetworkOverlapWarningForResource('service', $service->uuid, $edgeProxyServer, $tunnelHost);
|
||||
if (! is_null($networkOverlapWarning)) {
|
||||
$warnings[] = $networkOverlapWarning;
|
||||
}
|
||||
|
|
@ -165,9 +159,156 @@ class EdgeProxyRemoteRouteService
|
|||
return $warnings;
|
||||
}
|
||||
|
||||
public function syncApplication(Application $application): array
|
||||
{
|
||||
$teamId = $this->extractApplicationTeamId($application);
|
||||
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId);
|
||||
$deploymentServer = $this->resolveApplicationDeploymentServer($application);
|
||||
|
||||
if (! $deploymentServer instanceof Server || ! $edgeProxyServer instanceof Server) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer);
|
||||
}
|
||||
|
||||
public function syncApplicationOnDeploymentServer(Application $application, Server $deploymentServer): array
|
||||
{
|
||||
$teamId = $this->extractApplicationTeamId($application);
|
||||
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId);
|
||||
if (! $edgeProxyServer instanceof Server) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer);
|
||||
}
|
||||
|
||||
public function syncApplicationWithServers(Application $application, Server $edgeProxyServer, Server $deploymentServer): array
|
||||
{
|
||||
if ($edgeProxyServer->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($deploymentServer->id === $edgeProxyServer->id) {
|
||||
$this->deleteRouteFile($edgeProxyServer, $application->uuid, self::APPLICATION_ROUTE_FILE_PREFIX);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$domains = $this->getApplicationDomains($application);
|
||||
if ($domains->isEmpty()) {
|
||||
$this->deleteRouteFile($edgeProxyServer, $application->uuid, self::APPLICATION_ROUTE_FILE_PREFIX);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$tunnelHost = $this->resolveTunnelHost($deploymentServer);
|
||||
if (blank($tunnelHost)) {
|
||||
$warning = sprintf(
|
||||
'Edge proxy route skipped for application %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.',
|
||||
$application->uuid
|
||||
);
|
||||
|
||||
$this->logWarning($warning);
|
||||
$this->deleteRouteFile($edgeProxyServer, $application->uuid, self::APPLICATION_ROUTE_FILE_PREFIX);
|
||||
|
||||
return [$warning];
|
||||
}
|
||||
|
||||
$routes = [];
|
||||
$warnings = [];
|
||||
$networkOverlapWarning = $this->detectDockerNetworkOverlapWarningForResource('application', $application->uuid, $edgeProxyServer, $tunnelHost);
|
||||
if (! is_null($networkOverlapWarning)) {
|
||||
$warnings[] = $networkOverlapWarning;
|
||||
}
|
||||
|
||||
$compose = $this->parseApplicationCompose($application);
|
||||
$environmentMap = $this->applicationEnvironmentMap($application);
|
||||
|
||||
foreach ($domains as $domainData) {
|
||||
$domain = data_get($domainData, 'domain');
|
||||
$composeServiceName = data_get($domainData, 'service_name');
|
||||
|
||||
$unsupportedProtocol = $this->detectUnsupportedDomainProtocol($domain);
|
||||
if (! is_null($unsupportedProtocol)) {
|
||||
$warnings[] = sprintf(
|
||||
'Edge proxy route skipped for application %s (domain %s): protocol "%s" is not supported for edge remote routing. Only http:// and https:// domains are currently supported.',
|
||||
$application->uuid,
|
||||
$domain,
|
||||
$unsupportedProtocol
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = $this->parseDomainUrl($domain);
|
||||
if (! $url instanceof Url) {
|
||||
$warnings[] = sprintf(
|
||||
'Edge proxy route skipped for application %s (domain %s): domain format is invalid. Use a valid hostname/domain with optional scheme, port and path.',
|
||||
$application->uuid,
|
||||
$domain
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$requestedInternalPort = $url->getPort();
|
||||
$publishedPort = $this->resolvePublishedPortForApplication(
|
||||
$application,
|
||||
$requestedInternalPort,
|
||||
$composeServiceName,
|
||||
$compose,
|
||||
$environmentMap
|
||||
);
|
||||
|
||||
if (is_null($publishedPort)) {
|
||||
$warnings[] = sprintf(
|
||||
'Edge proxy route skipped for application %s (domain %s): published host port could not be resolved. Expose the application port in host mappings/compose ports and/or include an explicit port in the domain.',
|
||||
$application->uuid,
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($routes)) {
|
||||
$this->deleteRouteFile($edgeProxyServer, $application->uuid, self::APPLICATION_ROUTE_FILE_PREFIX);
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
$config = $this->generateTraefikConfig($application->uuid, $routes);
|
||||
try {
|
||||
$this->writeRouteFile($edgeProxyServer, $application->uuid, $config, self::APPLICATION_ROUTE_FILE_PREFIX);
|
||||
} catch (\Throwable $exception) {
|
||||
$warning = sprintf(
|
||||
'Edge proxy route partially applied for application %s: failed to write dynamic route configuration on edge proxy (%s).',
|
||||
$application->uuid,
|
||||
$exception->getMessage()
|
||||
);
|
||||
$this->logWarning($warning);
|
||||
$warnings[] = $warning;
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
public function deleteService(Service $service): void
|
||||
{
|
||||
$edgeProxyServer = $this->resolveEdgeProxyServer($service);
|
||||
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($this->extractServiceTeamId($service));
|
||||
if (! $edgeProxyServer instanceof Server || $edgeProxyServer->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -180,6 +321,21 @@ class EdgeProxyRemoteRouteService
|
|||
$this->deleteRouteFile($edgeProxyServer, $service->uuid);
|
||||
}
|
||||
|
||||
public function deleteApplication(Application $application): void
|
||||
{
|
||||
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($this->extractApplicationTeamId($application));
|
||||
if (! $edgeProxyServer instanceof Server || $edgeProxyServer->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteApplicationWithServer($application, $edgeProxyServer);
|
||||
}
|
||||
|
||||
public function deleteApplicationWithServer(Application $application, Server $edgeProxyServer): void
|
||||
{
|
||||
$this->deleteRouteFile($edgeProxyServer, $application->uuid, self::APPLICATION_ROUTE_FILE_PREFIX);
|
||||
}
|
||||
|
||||
public function generateTraefikConfig(string $serviceUuid, array $routes): array
|
||||
{
|
||||
$serviceKey = Str::slug($serviceUuid);
|
||||
|
|
@ -240,12 +396,22 @@ class EdgeProxyRemoteRouteService
|
|||
}
|
||||
|
||||
public function routeFilePath(Server $edgeProxyServer, string $serviceUuid): string
|
||||
{
|
||||
return $this->resourceRouteFilePath($edgeProxyServer, self::SERVICE_ROUTE_FILE_PREFIX, $serviceUuid);
|
||||
}
|
||||
|
||||
public function applicationRouteFilePath(Server $edgeProxyServer, string $applicationUuid): string
|
||||
{
|
||||
return $this->resourceRouteFilePath($edgeProxyServer, self::APPLICATION_ROUTE_FILE_PREFIX, $applicationUuid);
|
||||
}
|
||||
|
||||
private function resourceRouteFilePath(Server $edgeProxyServer, string $prefix, string $resourceUuid): string
|
||||
{
|
||||
return sprintf(
|
||||
'%s/%s%s.yaml',
|
||||
$this->routeDirectoryPath($edgeProxyServer),
|
||||
self::ROUTE_FILE_PREFIX,
|
||||
$serviceUuid
|
||||
$prefix,
|
||||
$resourceUuid
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -259,13 +425,13 @@ class EdgeProxyRemoteRouteService
|
|||
return rtrim($edgeProxyServer->proxyPath(), '/').'/dynamic';
|
||||
}
|
||||
|
||||
private function writeRouteFile(Server $edgeProxyServer, string $serviceUuid, array $config): void
|
||||
private function writeRouteFile(Server $edgeProxyServer, string $resourceUuid, array $config, string $filePrefix = self::SERVICE_ROUTE_FILE_PREFIX): 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);
|
||||
|
||||
$routeFilePath = $this->routeFilePath($edgeProxyServer, $serviceUuid);
|
||||
$routeFilePath = $this->resourceRouteFilePath($edgeProxyServer, $filePrefix, $resourceUuid);
|
||||
$temporaryRouteFilePath = $routeFilePath.'.tmp';
|
||||
|
||||
$escapedDirectory = escapeshellarg($this->routeDirectoryPath($edgeProxyServer));
|
||||
|
|
@ -279,10 +445,11 @@ class EdgeProxyRemoteRouteService
|
|||
]);
|
||||
}
|
||||
|
||||
private function deleteRouteFile(Server $edgeProxyServer, string $serviceUuid): void
|
||||
private function deleteRouteFile(Server $edgeProxyServer, string $resourceUuid, string $filePrefix = self::SERVICE_ROUTE_FILE_PREFIX): void
|
||||
{
|
||||
$escapedFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid));
|
||||
$escapedTemporaryFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid).'.tmp');
|
||||
$routeFilePath = $this->resourceRouteFilePath($edgeProxyServer, $filePrefix, $resourceUuid);
|
||||
$escapedFilePath = escapeshellarg($routeFilePath);
|
||||
$escapedTemporaryFilePath = escapeshellarg($routeFilePath.'.tmp');
|
||||
|
||||
$this->runRemoteCommands($edgeProxyServer, [
|
||||
"rm -f $escapedFilePath $escapedTemporaryFilePath",
|
||||
|
|
@ -300,16 +467,16 @@ class EdgeProxyRemoteRouteService
|
|||
return $rule;
|
||||
}
|
||||
|
||||
private function resolveEdgeProxyServer(Service $service): ?Server
|
||||
private function resolveEdgeProxyServerByTeamId(?int $teamId): ?Server
|
||||
{
|
||||
$teamId = $this->extractTeamId($service);
|
||||
if (is_null($teamId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Server::query()
|
||||
->where('team_id', $teamId)
|
||||
->where('id', 0)
|
||||
->whereRelation('settings', 'is_master_domain_router_enabled', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
|
|
@ -332,7 +499,31 @@ class EdgeProxyRemoteRouteService
|
|||
return null;
|
||||
}
|
||||
|
||||
private function extractTeamId(Service $service): ?int
|
||||
private function resolveApplicationDeploymentServer(Application $application): ?Server
|
||||
{
|
||||
$server = data_get($application, 'server');
|
||||
if ($server instanceof Server) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
$server = data_get($application, 'destination.server');
|
||||
if ($server instanceof Server) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
if ($application->exists) {
|
||||
$application->loadMissing('destination.server');
|
||||
|
||||
$server = data_get($application, 'destination.server');
|
||||
if ($server instanceof Server) {
|
||||
return $server;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractServiceTeamId(Service $service): ?int
|
||||
{
|
||||
$teamId = data_get($service, 'environment.project.team_id');
|
||||
if (! is_null($teamId)) {
|
||||
|
|
@ -350,6 +541,24 @@ class EdgeProxyRemoteRouteService
|
|||
return null;
|
||||
}
|
||||
|
||||
private function extractApplicationTeamId(Application $application): ?int
|
||||
{
|
||||
$teamId = data_get($application, 'environment.project.team_id');
|
||||
if (! is_null($teamId)) {
|
||||
return (int) $teamId;
|
||||
}
|
||||
|
||||
if ($application->exists) {
|
||||
$application->loadMissing('environment.project');
|
||||
$teamId = data_get($application, 'environment.project.team_id');
|
||||
if (! is_null($teamId)) {
|
||||
return (int) $teamId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getServiceApplicationsWithDomains(Service $service): Collection
|
||||
{
|
||||
$applications = collect([]);
|
||||
|
|
@ -365,6 +574,48 @@ class EdgeProxyRemoteRouteService
|
|||
->values();
|
||||
}
|
||||
|
||||
private function getApplicationDomains(Application $application): Collection
|
||||
{
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$domains = collect(json_decode((string) $application->docker_compose_domains, true));
|
||||
if ($domains->isEmpty()) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
return $domains
|
||||
->map(function (mixed $domainConfig, string $serviceName) {
|
||||
$domain = data_get($domainConfig, 'domain');
|
||||
if (is_string($domainConfig)) {
|
||||
$domain = $domainConfig;
|
||||
}
|
||||
|
||||
return [
|
||||
'service_name' => $serviceName,
|
||||
'domain' => (string) $domain,
|
||||
];
|
||||
})
|
||||
->flatMap(function (array $domainData) {
|
||||
return collect(explode(',', $domainData['domain']))
|
||||
->map(fn (string $domain) => trim($domain))
|
||||
->filter()
|
||||
->map(fn (string $domain) => [
|
||||
'service_name' => $domainData['service_name'],
|
||||
'domain' => $domain,
|
||||
]);
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
return collect(explode(',', (string) $application->fqdn))
|
||||
->map(fn (string $domain) => trim($domain))
|
||||
->filter()
|
||||
->map(fn (string $domain) => [
|
||||
'service_name' => null,
|
||||
'domain' => $domain,
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
private function resolveTunnelHost(Server $deploymentServer): ?string
|
||||
{
|
||||
$candidates = [
|
||||
|
|
@ -419,6 +670,82 @@ class EdgeProxyRemoteRouteService
|
|||
->all();
|
||||
}
|
||||
|
||||
private function parseApplicationCompose(Application $application): array
|
||||
{
|
||||
if ($application->build_pack !== 'dockercompose') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rawCompose = filled($application->docker_compose_raw)
|
||||
? $application->docker_compose_raw
|
||||
: $application->docker_compose;
|
||||
|
||||
if (blank($rawCompose)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$parsedCompose = Yaml::parse($rawCompose);
|
||||
|
||||
return is_array($parsedCompose) ? $parsedCompose : [];
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function applicationEnvironmentMap(Application $application): array
|
||||
{
|
||||
if ($application->relationLoaded('environment_variables')) {
|
||||
return $application->environment_variables
|
||||
->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value])
|
||||
->all();
|
||||
}
|
||||
|
||||
if (! $application->exists) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $application->environment_variables()
|
||||
->get()
|
||||
->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function resolvePublishedPortForApplication(
|
||||
Application $application,
|
||||
?int $requestedInternalPort,
|
||||
?string $composeServiceName,
|
||||
array $compose,
|
||||
array $environmentMap
|
||||
): ?int {
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$serviceName = $composeServiceName;
|
||||
if (blank($serviceName)) {
|
||||
$serviceName = $application->uuid;
|
||||
}
|
||||
|
||||
return $this->resolvePublishedPort($compose, $serviceName, $requestedInternalPort, $environmentMap);
|
||||
}
|
||||
|
||||
$portMappings = $this->parsePortMappings($application->ports_mappings_array, $environmentMap)
|
||||
->filter(fn (array $mapping) => ! is_null($mapping['published']))
|
||||
->values();
|
||||
|
||||
if ($portMappings->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fallbackInternalPort = null;
|
||||
if (is_null($requestedInternalPort)) {
|
||||
$mainPorts = $application->main_port();
|
||||
if (is_array($mainPorts) && count($mainPorts) === 1 && is_numeric($mainPorts[0])) {
|
||||
$fallbackInternalPort = (int) $mainPorts[0];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->selectPublishedPortFromMappings($portMappings, $requestedInternalPort, $fallbackInternalPort);
|
||||
}
|
||||
|
||||
private function detectUnsupportedDomainProtocol(string $domain): ?string
|
||||
{
|
||||
$trimmedDomain = trim($domain);
|
||||
|
|
@ -434,7 +761,7 @@ class EdgeProxyRemoteRouteService
|
|||
return $protocol;
|
||||
}
|
||||
|
||||
private function detectDockerNetworkOverlapWarning(Service $service, Server $edgeProxyServer, string $tunnelHost): ?string
|
||||
private function detectDockerNetworkOverlapWarningForResource(string $resourceType, string $resourceUuid, Server $edgeProxyServer, string $tunnelHost): ?string
|
||||
{
|
||||
// Only run this for persisted server models (normal runtime) to avoid noisy checks in synthetic test stubs.
|
||||
if (! $edgeProxyServer->exists) {
|
||||
|
|
@ -449,8 +776,9 @@ class EdgeProxyRemoteRouteService
|
|||
foreach ($this->resolveEdgeDockerSubnets($edgeProxyServer) as $subnet) {
|
||||
if ($this->ipv4InCidr($normalizedTunnelHost, $subnet)) {
|
||||
return sprintf(
|
||||
'Edge proxy route warning for service %s: remote host %s overlaps edge Docker network subnet %s. This can break VPN/WireGuard routing. Configure Docker default-address-pools to a non-overlapping range (for example base 172.20.0.0/16 with size 24) and recreate overlapping networks.',
|
||||
$service->uuid,
|
||||
'Edge proxy route warning for %s %s: remote host %s overlaps edge Docker network subnet %s. This can break VPN/WireGuard routing. Configure Docker default-address-pools to a non-overlapping range (for example base 172.20.0.0/16 with size 24) and recreate overlapping networks.',
|
||||
$resourceType,
|
||||
$resourceUuid,
|
||||
$normalizedTunnelHost,
|
||||
$subnet
|
||||
);
|
||||
|
|
@ -554,6 +882,11 @@ class EdgeProxyRemoteRouteService
|
|||
return null;
|
||||
}
|
||||
|
||||
return $this->selectPublishedPortFromMappings($portMappings, $requestedInternalPort);
|
||||
}
|
||||
|
||||
private function selectPublishedPortFromMappings(Collection $portMappings, ?int $requestedInternalPort, ?int $fallbackInternalPort = null): ?int
|
||||
{
|
||||
if (! is_null($requestedInternalPort)) {
|
||||
$matchingTarget = $portMappings->first(fn (array $mapping) => $mapping['target'] === $requestedInternalPort);
|
||||
if ($matchingTarget) {
|
||||
|
|
@ -566,6 +899,13 @@ class EdgeProxyRemoteRouteService
|
|||
}
|
||||
}
|
||||
|
||||
if (! is_null($fallbackInternalPort)) {
|
||||
$matchingTarget = $portMappings->first(fn (array $mapping) => $mapping['target'] === $fallbackInternalPort);
|
||||
if ($matchingTarget) {
|
||||
return $matchingTarget['published'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($portMappings->count() === 1) {
|
||||
return $portMappings->first()['published'];
|
||||
}
|
||||
|
|
@ -586,6 +926,18 @@ class EdgeProxyRemoteRouteService
|
|||
return is_array($ports) ? $ports : null;
|
||||
}
|
||||
|
||||
$normalizedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
foreach ($services as $composeServiceName => $serviceConfig) {
|
||||
$normalizedComposeServiceName = str((string) $composeServiceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
if ($normalizedComposeServiceName !== $normalizedServiceName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ports = data_get($serviceConfig, 'ports');
|
||||
|
||||
return is_array($ports) ? $ports : null;
|
||||
}
|
||||
|
||||
// Defensive fallback for templates where application name does not match compose key.
|
||||
$servicesWithPorts = collect($services)
|
||||
->filter(fn (mixed $serviceConfig) => is_array($serviceConfig) && is_array(data_get($serviceConfig, 'ports')) && ! empty(data_get($serviceConfig, 'ports')))
|
||||
|
|
@ -649,7 +1001,7 @@ class EdgeProxyRemoteRouteService
|
|||
}
|
||||
|
||||
if (str_contains($normalizedPortDefinition, ':')) {
|
||||
$segments = explode(':', $normalizedPortDefinition);
|
||||
$segments = $this->splitPortDefinitionSegments($normalizedPortDefinition);
|
||||
if (count($segments) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -669,6 +1021,37 @@ class EdgeProxyRemoteRouteService
|
|||
];
|
||||
}
|
||||
|
||||
private function splitPortDefinitionSegments(string $portDefinition): array
|
||||
{
|
||||
$segments = [];
|
||||
$currentSegment = '';
|
||||
$braceDepth = 0;
|
||||
$length = strlen($portDefinition);
|
||||
|
||||
for ($index = 0; $index < $length; $index++) {
|
||||
$character = $portDefinition[$index];
|
||||
|
||||
if ($character === '{') {
|
||||
$braceDepth++;
|
||||
} elseif ($character === '}' && $braceDepth > 0) {
|
||||
$braceDepth--;
|
||||
}
|
||||
|
||||
if ($character === ':' && $braceDepth === 0) {
|
||||
$segments[] = $currentSegment;
|
||||
$currentSegment = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentSegment .= $character;
|
||||
}
|
||||
|
||||
$segments[] = $currentSegment;
|
||||
|
||||
return $segments;
|
||||
}
|
||||
|
||||
private function resolvePortValue(mixed $rawPortValue, array $environmentMap): ?int
|
||||
{
|
||||
if (is_int($rawPortValue)) {
|
||||
|
|
@ -676,7 +1059,12 @@ class EdgeProxyRemoteRouteService
|
|||
}
|
||||
|
||||
$normalizedPortValue = trim((string) $rawPortValue);
|
||||
if ($normalizedPortValue === '' || str_contains($normalizedPortValue, '-')) {
|
||||
if ($normalizedPortValue === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ignore numeric port ranges (for example 8000-8010), which cannot be mapped deterministically.
|
||||
if (preg_match('/^\d+\s*-\s*\d+$/', $normalizedPortValue) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('server_settings', 'is_master_domain_router_enabled')) {
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->boolean('is_master_domain_router_enabled')->default(false)->after('is_cloudflare_tunnel');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('server_settings', 'is_master_domain_router_enabled')) {
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('is_master_domain_router_enabled');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -286,6 +286,20 @@
|
|||
</div>
|
||||
|
||||
@endif
|
||||
@if ($server->proxyType() === \App\Enums\ProxyTypes::TRAEFIK->value)
|
||||
<div class="w-96">
|
||||
@if ($isMasterDomainRouterLocked)
|
||||
<x-forms.checkbox disabled instantSave id="isMasterDomainRouterEnabled"
|
||||
label="Enable master domain routing?"
|
||||
helper="{{ $masterDomainRouterLockMessage }}" />
|
||||
@else
|
||||
<x-forms.checkbox canGate="update" :canResource="$server"
|
||||
id="isMasterDomainRouterEnabled" label="Enable master domain routing?"
|
||||
helper="Enable this on exactly one server when all domains point to this server and it should forward traffic to resources running on other servers (for example over VPN / WireGuard)."
|
||||
:disabled="$isValidating" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
68
tests/Feature/ServerMasterDomainRouterSettingTest.php
Normal file
68
tests/Feature/ServerMasterDomainRouterSettingTest.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Livewire\Server\Show;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('allows only one master domain router server per team', function () {
|
||||
$user = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
|
||||
$firstServer = Server::factory()->create(['team_id' => $team->id]);
|
||||
$secondServer = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
$firstServer->settings->update(['is_master_domain_router_enabled' => true]);
|
||||
|
||||
expect($firstServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue()
|
||||
->and($secondServer->settings->fresh()->is_master_domain_router_enabled)->toBeFalse();
|
||||
|
||||
$secondServer->settings->update(['is_master_domain_router_enabled' => true]);
|
||||
|
||||
expect($firstServer->settings->fresh()->is_master_domain_router_enabled)->toBeFalse()
|
||||
->and($secondServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not disable master domain router setting on servers from other teams', function () {
|
||||
$teamOneUser = User::factory()->create();
|
||||
$teamTwoUser = User::factory()->create();
|
||||
|
||||
$teamOneServer = Server::factory()->create(['team_id' => $teamOneUser->teams()->first()->id]);
|
||||
$teamTwoServer = Server::factory()->create(['team_id' => $teamTwoUser->teams()->first()->id]);
|
||||
|
||||
$teamOneServer->settings->update(['is_master_domain_router_enabled' => true]);
|
||||
$teamTwoServer->settings->update(['is_master_domain_router_enabled' => true]);
|
||||
|
||||
expect($teamOneServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue()
|
||||
->and($teamTwoServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
it('locks master domain router toggle when another server in the same team is already selected', function () {
|
||||
$user = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
$this->actingAs($user);
|
||||
refreshSession($team);
|
||||
|
||||
$masterServer = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
|
||||
]);
|
||||
$otherServer = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
|
||||
]);
|
||||
|
||||
$masterServer->settings->update(['is_master_domain_router_enabled' => true]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $otherServer->uuid])
|
||||
->assertSet('isMasterDomainRouterLocked', true)
|
||||
->assertSet('isMasterDomainRouterEnabled', false)
|
||||
->set('isMasterDomainRouterEnabled', true)
|
||||
->assertSet('isMasterDomainRouterEnabled', false);
|
||||
|
||||
expect($otherServer->settings->fresh()->is_master_domain_router_enabled)->toBeFalse();
|
||||
});
|
||||
696
tests/Unit/DatabaseProxyMasterRoutingTest.php
Normal file
696
tests/Unit/DatabaseProxyMasterRoutingTest.php
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
|
||||
it('runs standalone database proxy on the master domain router server for remote deployments', function () {
|
||||
$edgeServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$edgeServer->id = 1;
|
||||
|
||||
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 2;
|
||||
$deploymentServer->ip = '10.8.0.15';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$database = new StandalonePostgresql;
|
||||
$database->uuid = 'standalone-db-uuid';
|
||||
$database->name = 'standalone-db';
|
||||
$database->public_port = 15432;
|
||||
$database->setRelation('destination', (object) [
|
||||
'server' => $deploymentServer,
|
||||
'network' => 'standalone-network',
|
||||
]);
|
||||
$database->setRelation('environment', (object) [
|
||||
'project' => (object) ['team_id' => 123],
|
||||
]);
|
||||
|
||||
$action = new class($edgeServer) extends StartDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
public function __construct(private ?Server $edgeServer) {}
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
return $this->edgeServer;
|
||||
}
|
||||
|
||||
protected function resolveConfigurationDirectory(string $databaseUuid): string
|
||||
{
|
||||
return "/tmp/database-proxy/{$databaseUuid}";
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($database);
|
||||
|
||||
expect($action->calls)->toHaveCount(3)
|
||||
->and($action->calls[0]['server_id'])->toBe(2)
|
||||
->and($action->calls[1]['server_id'])->toBe(1)
|
||||
->and($action->calls[2]['server_id'])->toBe(1);
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches);
|
||||
$nginxConf = base64_decode($nginxMatches[1] ?? '');
|
||||
expect($nginxConf)->toContain('listen 15432;')
|
||||
->and($nginxConf)->toContain('proxy_pass 10.8.0.15:5432;');
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $action->calls[2]['commands'][2], $composeMatches);
|
||||
$dockerCompose = base64_decode($composeMatches[1] ?? '');
|
||||
expect($dockerCompose)->not->toContain('external: true')
|
||||
->and($dockerCompose)->not->toContain('standalone-network');
|
||||
});
|
||||
|
||||
it('keeps standalone database proxy on deployment server when no master domain router server is configured', function () {
|
||||
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 2;
|
||||
$deploymentServer->ip = '10.8.0.25';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$database = new StandalonePostgresql;
|
||||
$database->uuid = 'standalone-db-local-uuid';
|
||||
$database->name = 'standalone-db-local';
|
||||
$database->public_port = 15433;
|
||||
$database->setRelation('destination', (object) [
|
||||
'server' => $deploymentServer,
|
||||
'network' => 'standalone-network-local',
|
||||
]);
|
||||
$database->setRelation('environment', (object) [
|
||||
'project' => (object) ['team_id' => 456],
|
||||
]);
|
||||
|
||||
$action = new class extends StartDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveConfigurationDirectory(string $databaseUuid): string
|
||||
{
|
||||
return "/tmp/database-proxy/{$databaseUuid}";
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($database);
|
||||
|
||||
expect($action->calls)->toHaveCount(2)
|
||||
->and($action->calls[0]['server_id'])->toBe(2)
|
||||
->and($action->calls[1]['server_id'])->toBe(2);
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[1]['commands'][1], $nginxMatches);
|
||||
$nginxConf = base64_decode($nginxMatches[1] ?? '');
|
||||
expect($nginxConf)->toContain('proxy_pass standalone-db-local-uuid:5432;');
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $action->calls[1]['commands'][2], $composeMatches);
|
||||
$dockerCompose = base64_decode($composeMatches[1] ?? '');
|
||||
expect($dockerCompose)->toContain('standalone-network-local')
|
||||
->and($dockerCompose)->toContain('external: true');
|
||||
});
|
||||
|
||||
it('runs service database proxy on master domain router server for remote deployments', function () {
|
||||
$edgeServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$edgeServer->id = 11;
|
||||
|
||||
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 22;
|
||||
$deploymentServer->ip = '10.8.0.35';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$serviceDatabase = \Mockery::mock(ServiceDatabase::class)->makePartial();
|
||||
$serviceDatabase->uuid = 'service-db-uuid';
|
||||
$serviceDatabase->name = 'postgres';
|
||||
$serviceDatabase->public_port = 15434;
|
||||
$serviceDatabase->shouldReceive('getMorphClass')->andReturn(\App\Models\ServiceDatabase::class);
|
||||
$serviceDatabase->shouldReceive('databaseType')->andReturn('standalone-postgresql');
|
||||
$serviceDatabase->setRelation('service', (object) [
|
||||
'uuid' => 'service-uuid',
|
||||
'destination' => (object) ['server' => $deploymentServer],
|
||||
'environment' => (object) ['project' => (object) ['team_id' => 789]],
|
||||
]);
|
||||
|
||||
$action = new class($edgeServer) extends StartDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
public function __construct(private ?Server $edgeServer) {}
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
return $this->edgeServer;
|
||||
}
|
||||
|
||||
protected function resolveConfigurationDirectory(string $databaseUuid): string
|
||||
{
|
||||
return "/tmp/database-proxy/{$databaseUuid}";
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($serviceDatabase);
|
||||
|
||||
expect($action->calls)->toHaveCount(3)
|
||||
->and($action->calls[0]['server_id'])->toBe(22)
|
||||
->and($action->calls[1]['server_id'])->toBe(11)
|
||||
->and($action->calls[2]['server_id'])->toBe(11);
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches);
|
||||
$nginxConf = base64_decode($nginxMatches[1] ?? '');
|
||||
expect($nginxConf)->toContain('listen 15434;')
|
||||
->and($nginxConf)->toContain('proxy_pass 10.8.0.35:5432;')
|
||||
->and($nginxConf)->not->toContain('postgres-service-uuid');
|
||||
});
|
||||
|
||||
it('stops database proxy containers on deployment and master domain router servers', function () {
|
||||
$edgeServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$edgeServer->id = 101;
|
||||
|
||||
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 202;
|
||||
|
||||
$database = \Mockery::mock(StandalonePostgresql::class)->makePartial();
|
||||
$database->uuid = 'stopped-db-uuid';
|
||||
$database->shouldReceive('save')->once()->andReturnTrue();
|
||||
$database->setRelation('destination', (object) [
|
||||
'server' => $deploymentServer,
|
||||
]);
|
||||
$database->setRelation('environment', (object) [
|
||||
'project' => (object) ['team_id' => 555],
|
||||
]);
|
||||
|
||||
$action = new class($edgeServer) extends StopDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
public function __construct(private ?Server $edgeServer) {}
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
return $this->edgeServer;
|
||||
}
|
||||
|
||||
protected function dispatchDatabaseProxyStoppedEvent(): void
|
||||
{
|
||||
// No-op in isolated unit test environment.
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($database);
|
||||
|
||||
expect($action->calls)->toHaveCount(2)
|
||||
->and($action->calls[0]['server_id'])->toBe(202)
|
||||
->and($action->calls[1]['server_id'])->toBe(101)
|
||||
->and($action->calls[0]['commands'][0])->toBe('docker rm -f stopped-db-uuid-proxy')
|
||||
->and($action->calls[1]['commands'][0])->toBe('docker rm -f stopped-db-uuid-proxy');
|
||||
});
|
||||
|
||||
it('falls back to deployment server when master domain router exists but remote host is missing', function () {
|
||||
$edgeServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$edgeServer->id = 41;
|
||||
|
||||
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 42;
|
||||
$deploymentServer->ip = '';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$database = new StandalonePostgresql;
|
||||
$database->uuid = 'standalone-db-fallback-uuid';
|
||||
$database->name = 'standalone-db-fallback';
|
||||
$database->public_port = 15440;
|
||||
$database->setRelation('destination', (object) [
|
||||
'server' => $deploymentServer,
|
||||
'network' => 'fallback-network',
|
||||
]);
|
||||
$database->setRelation('environment', (object) [
|
||||
'project' => (object) ['team_id' => 321],
|
||||
]);
|
||||
|
||||
$action = new class($edgeServer) extends StartDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
public array $warnings = [];
|
||||
|
||||
public function __construct(private ?Server $edgeServer) {}
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
return $this->edgeServer;
|
||||
}
|
||||
|
||||
protected function resolveConfigurationDirectory(string $databaseUuid): string
|
||||
{
|
||||
return "/tmp/database-proxy/{$databaseUuid}";
|
||||
}
|
||||
|
||||
protected function logWarning(string $message): void
|
||||
{
|
||||
$this->warnings[] = $message;
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($database);
|
||||
|
||||
expect($action->calls)->toHaveCount(2)
|
||||
->and($action->calls[0]['server_id'])->toBe(42)
|
||||
->and($action->calls[1]['server_id'])->toBe(42)
|
||||
->and($action->warnings)->not->toBeEmpty()
|
||||
->and($action->warnings[0])->toContain('falling back to deployment server');
|
||||
});
|
||||
|
||||
it('keeps proxy on deployment server when master router server is the same server', function () {
|
||||
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 51;
|
||||
$deploymentServer->ip = '10.8.0.51';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$database = new StandalonePostgresql;
|
||||
$database->uuid = 'standalone-db-same-edge-uuid';
|
||||
$database->name = 'standalone-db-same-edge';
|
||||
$database->public_port = 15451;
|
||||
$database->setRelation('destination', (object) [
|
||||
'server' => $deploymentServer,
|
||||
'network' => 'same-edge-network',
|
||||
]);
|
||||
$database->setRelation('environment', (object) [
|
||||
'project' => (object) ['team_id' => 654],
|
||||
]);
|
||||
|
||||
$action = new class($deploymentServer) extends StartDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
public function __construct(private ?Server $edgeServer) {}
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
return $this->edgeServer;
|
||||
}
|
||||
|
||||
protected function resolveConfigurationDirectory(string $databaseUuid): string
|
||||
{
|
||||
return "/tmp/database-proxy/{$databaseUuid}";
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($database);
|
||||
|
||||
expect($action->calls)->toHaveCount(2)
|
||||
->and($action->calls[0]['server_id'])->toBe(51)
|
||||
->and($action->calls[1]['server_id'])->toBe(51);
|
||||
});
|
||||
|
||||
it('supports service database deployment server fallback from service.server when destination is missing', function () {
|
||||
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 61;
|
||||
$deploymentServer->ip = '10.8.0.61';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$serviceDatabase = \Mockery::mock(ServiceDatabase::class)->makePartial();
|
||||
$serviceDatabase->uuid = 'service-db-fallback-server-uuid';
|
||||
$serviceDatabase->name = 'postgres';
|
||||
$serviceDatabase->public_port = 15461;
|
||||
$serviceDatabase->shouldReceive('getMorphClass')->andReturn(\App\Models\ServiceDatabase::class);
|
||||
$serviceDatabase->shouldReceive('databaseType')->andReturn('standalone-postgresql');
|
||||
$serviceDatabase->setRelation('service', (object) [
|
||||
'uuid' => 'service-fallback-uuid',
|
||||
'server' => $deploymentServer,
|
||||
'environment' => (object) ['project' => (object) ['team_id' => 111]],
|
||||
]);
|
||||
|
||||
$action = new class extends StartDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveConfigurationDirectory(string $databaseUuid): string
|
||||
{
|
||||
return "/tmp/database-proxy/{$databaseUuid}";
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($serviceDatabase);
|
||||
|
||||
expect($action->calls)->toHaveCount(2)
|
||||
->and($action->calls[0]['server_id'])->toBe(61)
|
||||
->and($action->calls[1]['server_id'])->toBe(61);
|
||||
});
|
||||
|
||||
it('uses ssl internal redis port 6380 for remote database proxy upstream target', function () {
|
||||
$edgeServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$edgeServer->id = 71;
|
||||
|
||||
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 72;
|
||||
$deploymentServer->ip = '10.8.0.72';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$database = \Mockery::mock(\App\Models\StandaloneRedis::class)->makePartial();
|
||||
$database->uuid = 'redis-ssl-db-uuid';
|
||||
$database->name = 'redis-ssl-db';
|
||||
$database->public_port = 16379;
|
||||
$database->enable_ssl = true;
|
||||
$database->database_type = 'standalone-redis';
|
||||
$database->setRelation('destination', (object) [
|
||||
'server' => $deploymentServer,
|
||||
'network' => 'redis-network',
|
||||
]);
|
||||
$database->setRelation('environment', (object) [
|
||||
'project' => (object) ['team_id' => 222],
|
||||
]);
|
||||
|
||||
$action = new class($edgeServer) extends StartDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
public function __construct(private ?Server $edgeServer) {}
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
return $this->edgeServer;
|
||||
}
|
||||
|
||||
protected function resolveConfigurationDirectory(string $databaseUuid): string
|
||||
{
|
||||
return "/tmp/database-proxy/{$databaseUuid}";
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($database);
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches);
|
||||
$nginxConf = base64_decode($nginxMatches[1] ?? '');
|
||||
|
||||
expect($nginxConf)->toContain('listen 16379;')
|
||||
->and($nginxConf)->toContain('proxy_pass 10.8.0.72:6380;');
|
||||
});
|
||||
|
||||
it('disables public database proxy on non-transient startup errors', function () {
|
||||
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 81;
|
||||
$deploymentServer->ip = '10.8.0.81';
|
||||
$deploymentServer->proxy = ['type' => 'NONE'];
|
||||
|
||||
$database = \Mockery::mock(StandalonePostgresql::class)->makePartial();
|
||||
$database->uuid = 'standalone-db-error-uuid';
|
||||
$database->name = 'standalone-db-error';
|
||||
$database->public_port = 15481;
|
||||
$database->shouldReceive('update')->once()->with(['is_public' => false])->andReturnTrue();
|
||||
$database->setRelation('destination', (object) [
|
||||
'server' => $deploymentServer,
|
||||
'network' => 'error-network',
|
||||
]);
|
||||
$database->setRelation('environment', (object) [
|
||||
'project' => (object) ['team_id' => 333],
|
||||
]);
|
||||
|
||||
$action = new class extends StartDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
if (str_contains(implode("\n", $commands), 'up -d')) {
|
||||
throw new \RuntimeException('Bind for 0.0.0.0:15481 failed: port is already allocated');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveConfigurationDirectory(string $databaseUuid): string
|
||||
{
|
||||
return "/tmp/database-proxy/{$databaseUuid}";
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($database);
|
||||
|
||||
expect($action->calls)->toHaveCount(2)
|
||||
->and($action->calls[1]['server_id'])->toBe(81);
|
||||
});
|
||||
|
||||
it('does not try to stop database proxy when deployment server is missing', function () {
|
||||
$database = \Mockery::mock(StandalonePostgresql::class)->makePartial();
|
||||
$database->uuid = 'stop-missing-server-db-uuid';
|
||||
$database->setRelation('destination', (object) [
|
||||
'server' => null,
|
||||
]);
|
||||
|
||||
$action = new class extends StopDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function dispatchDatabaseProxyStoppedEvent(): void
|
||||
{
|
||||
// No-op in isolated unit test environment.
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($database);
|
||||
|
||||
expect($action->calls)->toBe([]);
|
||||
});
|
||||
|
||||
it('stops proxy only once when edge and deployment server are the same', function () {
|
||||
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 91;
|
||||
|
||||
$database = \Mockery::mock(StandalonePostgresql::class)->makePartial();
|
||||
$database->uuid = 'stop-same-edge-db-uuid';
|
||||
$database->shouldReceive('save')->once()->andReturnTrue();
|
||||
$database->setRelation('destination', (object) [
|
||||
'server' => $deploymentServer,
|
||||
]);
|
||||
$database->setRelation('environment', (object) [
|
||||
'project' => (object) ['team_id' => 444],
|
||||
]);
|
||||
|
||||
$action = new class($deploymentServer) extends StopDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
public function __construct(private ?Server $edgeServer) {}
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
return $this->edgeServer;
|
||||
}
|
||||
|
||||
protected function dispatchDatabaseProxyStoppedEvent(): void
|
||||
{
|
||||
// No-op in isolated unit test environment.
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($database);
|
||||
|
||||
expect($action->calls)->toHaveCount(1)
|
||||
->and($action->calls[0]['server_id'])->toBe(91)
|
||||
->and($action->calls[0]['commands'][0])->toBe('docker rm -f stop-same-edge-db-uuid-proxy');
|
||||
});
|
||||
|
||||
it('throws clear error when starting database proxy without a deployment server', function () {
|
||||
$database = new StandalonePostgresql;
|
||||
$database->uuid = 'missing-deployment-server-db-uuid';
|
||||
$database->name = 'missing-deployment-server-db';
|
||||
$database->public_port = 15500;
|
||||
$database->setRelation('destination', (object) [
|
||||
'server' => null,
|
||||
'network' => 'missing-network',
|
||||
]);
|
||||
|
||||
$action = new class extends StartDatabaseProxy
|
||||
{
|
||||
protected function resolveConfigurationDirectory(string $databaseUuid): string
|
||||
{
|
||||
return "/tmp/database-proxy/{$databaseUuid}";
|
||||
}
|
||||
};
|
||||
|
||||
expect(fn () => $action->handle($database))
|
||||
->toThrow(\RuntimeException::class, 'deployment server is missing');
|
||||
});
|
||||
|
||||
it('normalizes remote host with scheme and path before building database upstream target', function () {
|
||||
$edgeServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$edgeServer->id = 111;
|
||||
|
||||
$deploymentServer = \Mockery::mock(Server::class)->makePartial();
|
||||
$deploymentServer->id = 112;
|
||||
$deploymentServer->ip = '';
|
||||
$deploymentServer->proxy = ['type' => 'NONE', 'tunnel_host' => 'https://10.8.0.112:8443/path'];
|
||||
|
||||
$database = new StandalonePostgresql;
|
||||
$database->uuid = 'normalized-remote-host-db-uuid';
|
||||
$database->name = 'normalized-remote-host-db';
|
||||
$database->public_port = 15501;
|
||||
$database->setRelation('destination', (object) [
|
||||
'server' => $deploymentServer,
|
||||
'network' => 'normalized-network',
|
||||
]);
|
||||
$database->setRelation('environment', (object) [
|
||||
'project' => (object) ['team_id' => 987],
|
||||
]);
|
||||
|
||||
$action = new class($edgeServer) extends StartDatabaseProxy
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
public function __construct(private ?Server $edgeServer) {}
|
||||
|
||||
protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$this->calls[] = [
|
||||
'server_id' => $server->id,
|
||||
'commands' => $commands,
|
||||
'throw_error' => $throwError,
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server
|
||||
{
|
||||
return $this->edgeServer;
|
||||
}
|
||||
|
||||
protected function resolveConfigurationDirectory(string $databaseUuid): string
|
||||
{
|
||||
return "/tmp/database-proxy/{$databaseUuid}";
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle($database);
|
||||
|
||||
preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches);
|
||||
$nginxConf = base64_decode($nginxMatches[1] ?? '');
|
||||
|
||||
expect($nginxConf)->toContain('proxy_pass 10.8.0.112:5432;')
|
||||
->and($nginxConf)->not->toContain('8443');
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
|
|
@ -131,6 +132,166 @@ YAML;
|
|||
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('does not generate edge route file when published port cannot be resolved and returns actionable warning', function () {
|
||||
$manager = new class extends EdgeProxyRemoteRouteService
|
||||
{
|
||||
|
|
@ -676,3 +837,245 @@ YAML;
|
|||
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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue