diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 1061394e61..1f71465a1a 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -56,10 +56,7 @@ class StartDatabaseProxy } $configuration_dir = database_proxy_dir($database->uuid); - $host_configuration_dir = $configuration_dir; - if (isDev()) { - $host_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; - } + $host_configuration_dir = devHostDockerPath($server, $configuration_dir); $timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout); $nginxconf = <<proxy->get('last_saved_proxy_configuration'); } + + if (! empty(trim($proxy_configuration ?? '')) && replaceDevHostDockerProxyPaths($server)) { + $proxy_configuration = $server->proxy->get('last_saved_proxy_configuration'); + } } // Generate default configuration as last resort diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index 8ee6c6f242..5caf492ccf 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -2,7 +2,6 @@ namespace App\Actions\Proxy; -use App\Enums\ProxyTypes; use App\Events\ProxyStatusChanged; use App\Events\ProxyStatusChangedUI; use App\Models\Server; @@ -51,11 +50,6 @@ class StartProxy "echo 'Successfully started coolify-proxy.'", ]); } else { - if (isDev()) { - if ($proxyType === ProxyTypes::CADDY->value) { - $proxy_path = '/data/coolify/proxy/caddy'; - } - } $caddyfile = 'import /dynamic/*.caddy'; $commands = $commands->merge([ "mkdir -p $proxy_path/dynamic", diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 504ffa7af4..8c9230c954 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -10,11 +10,12 @@ class StartSentinel { use AsAction; + /** + * Sentinel and the proxy both mount this host path, and Sentinel reads the access log at the same path. + */ public static function trafficLogDirectory(Server $server): string { - return isDev() - ? devCoolifyDataPath().'/proxy' - : rtrim($server->proxyPath(), '/'); + return devHostDockerPath($server, rtrim($server->proxyPath(), '/')); } /** @@ -88,7 +89,7 @@ class StartSentinel $token = $server->settings->ensureValidSentinelToken(); $endpoint = $server->settings->ensureSentinelUrl(); $debug = data_get($server, 'settings.is_sentinel_debug_enabled'); - $mountDir = '/data/coolify/sentinel'; + $mountDir = devHostDockerPath($server, base_configuration_dir().'/sentinel'); $image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version; $environments = [ 'TOKEN' => $token, @@ -108,7 +109,6 @@ class StartSentinel if ($customImage && ! empty($customImage)) { $image = $customImage; } - $mountDir = devCoolifyDataPath().'/sentinel'; } $dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments)); $dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels)); diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index c6eaaf84e0..2cafb8558b 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -801,6 +801,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return instant_remote_process(['du -b '.escapeshellarg($this->backup_location).' | cut -f1'], $this->server, false, false, null, disableMultiplexing: true); } + /** + * Host path of the backup file for the upload container. It differs from backup_location only for the + * development testing-host server (see devHostDockerPath()). + */ + private function backupMountSource(): string + { + return devHostDockerPath($this->server, $this->backup_location); + } + private function upload_to_s3(): void { if (is_null($this->s3)) { @@ -835,16 +844,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true); } - if (isDev()) { - if ($this->database->name === 'coolify-db') { - $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file; - } else { - $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file; - } - } else { - $backup_location_from = $this->backup_location; - } - $mount = escapeshellarg($backup_location_from.':'.$this->backup_location.':ro'); + $mount = escapeshellarg($this->backupMountSource().':'.$this->backup_location.':ro'); $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v {$mount} {$fullImageName}"; // Escape S3 credentials to prevent command injection diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index 6f0c535db3..7df13e775c 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -4,7 +4,6 @@ namespace App\Jobs; use App\Actions\Proxy\GetProxyConfiguration; use App\Actions\Proxy\SaveProxyConfiguration; -use App\Enums\ProxyTypes; use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Services\ProxyDashboardCacheService; @@ -87,7 +86,6 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue */ private function buildRestartCommands(string $configuration): array { - $proxyType = $this->server->proxyType(); $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; $proxy_path = $this->server->proxyPath(); $stopTimeout = 30; @@ -136,9 +134,6 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue "echo 'Successfully started coolify-proxy.'", ]); } else { - if (isDev() && $proxyType === ProxyTypes::CADDY->value) { - $proxy_path = '/data/coolify/proxy/caddy'; - } $caddyfile = 'import /dynamic/*.caddy'; $commands = $commands->merge([ "echo 'Starting proxy...'", diff --git a/app/Models/Server.php b/app/Models/Server.php index d5368a6d7f..109ed038c7 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -136,6 +136,11 @@ class Server extends BaseModel */ public const MINIMUM_CURRENT_CADDY_PROXY_VERSION = [2, 9]; + /** + * Address of the development `testing-host` server (docker-compose.dev*.yml, ServerSeeder). + */ + public const DEV_TESTING_HOST_IP = 'coolify-testing-host'; + public static $batch_counter = 0; /** @@ -594,11 +599,6 @@ class Server extends BaseModel $proxy_type = $this->proxyType(); $redirect_enabled = $this->proxy->redirect_enabled ?? true; $redirect_url = $this->proxy->redirect_url; - if (isDev()) { - if ($proxy_type === ProxyTypes::CADDY->value) { - $dynamic_conf_path = '/data/coolify/proxy/caddy/dynamic'; - } - } if ($proxy_type === ProxyTypes::TRAEFIK->value) { $default_redirect_file = "$dynamic_conf_path/default_redirect_503.yaml"; } elseif ($proxy_type === ProxyTypes::CADDY->value) { @@ -956,6 +956,20 @@ $siteAddress { return $this->ip === 'host.docker.internal' || $this->id === 0; } + /** + * True only in development for the `testing-host` server. That container runs containers on the + * host Docker daemon (/var/run/docker.sock), but its /data/coolify is a Docker named volume. The host + * daemon must therefore mount the volume's host path instead of /data/coolify (see devHostDockerPath()). + * + * Dev KVM VMs and all other servers have their own Docker daemon and their own /data/coolify. + * A `host.docker.internal` server writes to the real host /data/coolify, so it also needs no change. + */ + public function sharesDevHostDocker(): bool + { + // The saving hook can leave a Stringable in `ip`, so compare the string value. + return isDev() && (string) $this->ip === self::DEV_TESTING_HOST_IP; + } + /** * Usable dedicated (build-only) servers of a team. Servers with the combined role * host deployments, so they are never picked as build servers. diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index e363b1c862..8a203a3d0d 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1111,13 +1111,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resource_type' => get_class($originalResource), ] ); - if (isDev()) { - if ((int) $resource->compose_parsing_version >= 4) { - $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid); - } else { - $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid); - } - } + // The file storage keeps the path that Coolify writes; the Docker daemon may need another one. + $source = $source->replace($mainDirectory, devHostDockerPath($server, $mainDirectory->value())); $volume = "$source:$target"; if (isset($parsed['mode']) && $parsed['mode']) { $volume .= ':'.$parsed['mode']->value(); @@ -2465,13 +2460,8 @@ function serviceParser(Service $resource): Collection 'resource_type' => get_class($originalResource), ] ); - if (isDev()) { - if ((int) $resource->compose_parsing_version >= 4) { - $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/services/'.$uuid); - } else { - $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid); - } - } + // The file storage keeps the path that Coolify writes; the Docker daemon may need another one. + $source = $source->replace($mainDirectory, devHostDockerPath($server, $mainDirectory->value())); $volume = "$source:$target"; if (isset($parsed['mode']) && $parsed['mode']) { $volume .= ':'.$parsed['mode']->value(); diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index ea45cd2c58..fed5c27544 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -164,8 +164,8 @@ function applyTrafficAnalyticsToProxyConfigArray(Server $server, array $config): data_set($config, 'services.traefik.command', applyTraefikAccessLogCommands($server, $commands, $enabled)); unset($config['services']['traefik-logrotate']); - if ($enabled && ! $server->isSwarm() && ! isDev()) { - $proxyPath = $server->proxyPath(); + if ($enabled && ! $server->isSwarm()) { + $proxyPath = devHostDockerPath($server, $server->proxyPath()); $config['services']['traefik-logrotate'] = [ 'container_name' => 'coolify-proxy-logrotate', 'image' => 'alpine:3.24', @@ -534,6 +534,47 @@ function removeLegacyTraefikDashboardExposure(Server $server): bool return true; } +/** + * Development only. Older dev builds wrote the dev data volume path (/var/lib/docker/volumes/_coolify_data/_data) + * into the proxy configuration of every server. Replace it with the path for this server (devHostDockerPath()): + * /data/coolify on a dev KVM VM, the configured volume path on the testing-host server. The next proxy restart applies it. + */ +function replaceDevHostDockerProxyPaths(Server $server): bool +{ + if (! app()->bound('config') || ! isDev()) { + return false; + } + $configuration = $server->proxy->get('last_saved_proxy_configuration'); + if (! is_string($configuration) || blank($configuration)) { + return false; + } + + $configuredVolume = preg_quote(basename(dirname(devDockerVolumeDataPath('constants.coolify.dev_data_volume', 'coolify_dev_coolify_data'))), '#'); + $pattern = "#/var/lib/docker/volumes/(?:[A-Za-z0-9][A-Za-z0-9_.-]*_coolify_data|{$configuredVolume})/_data(?=[/:'\"\\s]|$)#m"; + $fixed = preg_replace($pattern, devHostDockerPath($server, base_configuration_dir()), $configuration); + if (! is_string($fixed) || $fixed === $configuration) { + return false; + } + + try { + // The Caddy /traffic mount and the Traefik log rotation mount depend on the proxy type, so rebuild them. + $fixed = applyTrafficAnalyticsToProxyConfiguration($server, $fixed); + } catch (Throwable) { + // Keep the plain path replacement for a configuration that is not a YAML mapping. + } + + if (blank($server->proxy->get('last_applied_settings'))) { + $server->proxy->last_applied_settings = md5(base64_encode($configuration)); + } + $server->proxy->last_saved_proxy_configuration = $fixed; + $server->proxy->last_saved_settings = md5(base64_encode($fixed)); + $server->save(); + + Log::info('Replaced the development data volume path in the proxy configuration', ['server_id' => $server->id]); + + return true; +} + function generateDefaultProxyConfiguration(Server $server, array $custom_commands = []) { Log::info('Generating default proxy configuration', [ @@ -633,11 +674,10 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command $config['services']['traefik']['command'][] = '--api.insecure=true'; $config['services']['traefik']['command'][] = '--log.level=debug'; $config['services']['traefik']['command'][] = '--accesslog.bufferingsize=100'; - $config['services']['traefik']['volumes'][] = devCoolifyDataPath().'/proxy/:/traefik'; } else { $config['services']['traefik']['command'][] = '--api.insecure=false'; - $config['services']['traefik']['volumes'][] = "{$proxy_path}:/traefik"; } + $config['services']['traefik']['volumes'][] = devHostDockerPath($server, $proxy_path).':/traefik'; if ($server->isSwarm()) { data_forget($config, 'services.traefik.container_name'); data_forget($config, 'services.traefik.restart'); @@ -692,9 +732,9 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command ], 'volumes' => [ '/var/run/docker.sock:/var/run/docker.sock:ro', - "{$proxy_path}/dynamic:/dynamic", - "{$proxy_path}/config:/config", - "{$proxy_path}/data:/data", + devHostDockerPath($server, "{$proxy_path}/dynamic").':/dynamic', + devHostDockerPath($server, "{$proxy_path}/config").':/config', + devHostDockerPath($server, "{$proxy_path}/data").':/data', ], ], ], diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e8b69a53b6..affdc1faaf 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -971,14 +971,44 @@ function isDev(): bool } /** - * Host path of the Coolify data volume in development. The proxy and Sentinel on one server both mount - * paths below it, so they must use this value. An invalid volume name falls back to the legacy name. + * Path that the Docker daemon of $server must use as a bind mount source for $path, a path that Coolify + * writes through SSH (below base_configuration_dir()). + * + * Only the development `testing-host` server needs a different path (Server::sharesDevHostDocker()): + * it writes to Docker named volumes, but it starts containers on the host Docker daemon. The returned + * paths match its mounts in docker-compose.dev*.yml: + * - /data/coolify/backups/... -> /var/lib/docker/volumes//_data/... + * - /data/coolify/... -> /var/lib/docker/volumes//_data/... + * + * For all other servers (production, dev KVM VMs, remote servers) the function returns $path unchanged. */ -function devCoolifyDataPath(): string +function devHostDockerPath(?Server $server, string $path): string { - $volume = (string) config('constants.coolify.dev_data_volume'); + if (! $server?->sharesDevHostDocker()) { + return $path; + } + + $mounts = [ + backup_dir() => devDockerVolumeDataPath('constants.coolify.dev_backups_volume', 'coolify_dev_backups_data'), + base_configuration_dir() => devDockerVolumeDataPath('constants.coolify.dev_data_volume', 'coolify_dev_coolify_data'), + ]; + foreach ($mounts as $containerPath => $hostPath) { + if ($path === $containerPath || str_starts_with($path, $containerPath.'/')) { + return $hostPath.substr($path, strlen($containerPath)); + } + } + + return $path; +} + +/** + * Host path of a development Docker volume. An invalid volume name falls back to the legacy name. + */ +function devDockerVolumeDataPath(string $configKey, string $fallbackVolume): string +{ + $volume = (string) config($configKey); if (preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]*$/', $volume) !== 1) { - $volume = 'coolify_dev_coolify_data'; + $volume = $fallbackVolume; } return "/var/lib/docker/volumes/{$volume}/_data"; diff --git a/config/constants.php b/config/constants.php index 0d63ef113e..83b8b22301 100644 --- a/config/constants.php +++ b/config/constants.php @@ -8,8 +8,10 @@ return [ 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), - // Development only: Docker volume that holds /data/coolify; the proxy and Sentinel share traffic logs below it. + // Development only: Docker volumes that the testing-host server mounts at /data/coolify and /data/coolify/backups. + // devHostDockerPath() uses them only for that server. The defaults are the legacy docker-compose.dev.yml names. 'dev_data_volume' => env('DEV_COOLIFY_DATA_VOLUME', 'coolify_dev_coolify_data'), + 'dev_backups_volume' => env('DEV_COOLIFY_BACKUPS_VOLUME', 'coolify_dev_backups_data'), 'registry_url' => env('REGISTRY_URL', 'ghcr.io'), 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), diff --git a/docker-compose.dev-multi.yml b/docker-compose.dev-multi.yml index 57cc18498b..153e1c9a1b 100644 --- a/docker-compose.dev-multi.yml +++ b/docker-compose.dev-multi.yml @@ -60,6 +60,10 @@ services: DEVELOPMENT_QEMU_SLOT: "${DEVELOPMENT_QEMU_SLOT:-}" DEVELOPMENT_QEMU_DOCKER_NETWORK: "${COMPOSE_PROJECT_NAME:-coolify-dev}" DEVELOPMENT_QEMU_COOLIFY_CONTAINER: "${COMPOSE_PROJECT_NAME:-coolify-dev}" + # Host paths of the volumes that testing-host mounts at /data/coolify and /data/coolify/backups. + # Coolify uses them only for the testing-host server (it shares the host Docker daemon), not for KVM servers. + DEV_COOLIFY_DATA_VOLUME: "${COMPOSE_PROJECT_NAME:-coolify-dev}_coolify_data" + DEV_COOLIFY_BACKUPS_VOLUME: "${COMPOSE_PROJECT_NAME:-coolify-dev}_backups_data" healthcheck: test: curl -sf http://127.0.0.1:8080/api/health || exit 1 interval: 5s diff --git a/tests/Feature/ApplicationPreviewComposeCleanupTest.php b/tests/Feature/ApplicationPreviewComposeCleanupTest.php index 7fec3852f6..c055631165 100644 --- a/tests/Feature/ApplicationPreviewComposeCleanupTest.php +++ b/tests/Feature/ApplicationPreviewComposeCleanupTest.php @@ -47,6 +47,7 @@ networks: YAML; beforeEach(function () { + Server::flushIdentityMap(); InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); config(['constants.ssh.mux_enabled' => false]); Queue::fake(); diff --git a/tests/Feature/DevHostDockerPathTest.php b/tests/Feature/DevHostDockerPathTest.php new file mode 100644 index 0000000000..ef647f024d --- /dev/null +++ b/tests/Feature/DevHostDockerPathTest.php @@ -0,0 +1,272 @@ + 0]); + config([ + 'app.maintenance.store' => 'array', + 'constants.ssh.mux_enabled' => false, + 'constants.coolify.dev_data_volume' => 'coolify-dev-feature_coolify_data', + 'constants.coolify.dev_backups_volume' => 'coolify-dev-feature_backups_data', + ]); + + $this->commands = []; + Process::fake(function ($process) { + $this->commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + + return Process::result(output: ''); + }); + + $this->team = Team::factory()->create(); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +/** + * Environment and server kinds: + * - kvm: development, a dev KVM VM with its own Docker daemon and its own /data/coolify. + * - testing-host: development, the testing-host container that uses the host Docker daemon. + * - production: production, with the testing-host address (the address alone must not change paths). + * + * @return array + */ +function devPathKinds(): array +{ + return [ + 'dev KVM server' => ['kvm', '/data/coolify'], + 'dev testing-host server' => ['testing-host', DEV_PATH_DATA], + 'production' => ['production', '/data/coolify'], + ]; +} + +function devPathServer(string $kind, ?string $proxyType = null, bool $analytics = false): Server +{ + config()->set('app.env', $kind === 'production' ? 'production' : 'local'); + + $server = Server::factory()->create([ + 'team_id' => test()->team->id, + 'private_key_id' => test()->privateKey->id, + 'ip' => $kind === 'kvm' ? '10.221.1.10' : 'coolify-testing-host', + ]); + if ($proxyType) { + $server->proxy->set('type', $proxyType); + $server->save(); + } + $server->settings->is_traffic_analytics_enabled = $analytics; + $server->settings->sentinel_custom_url = 'https://coolify.example.com'; + $server->settings->save(); + + return $server->fresh(); +} + +function devPathDestination(Server $server): StandaloneDocker +{ + return StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); +} + +function devPathAllCommands(): string +{ + return implode("\n", test()->commands); +} + +it('detects only the development testing-host server as a host Docker server', function (string $kind, string $base) { + $server = devPathServer($kind); + + expect($server->sharesDevHostDocker())->toBe($kind === 'testing-host') + ->and(devHostDockerPath($server, '/data/coolify'))->toBe($base); +})->with(devPathKinds()); + +it('maps Coolify data and backup paths to the dev volumes on the testing-host server', function () { + $server = devPathServer('testing-host'); + + expect(devHostDockerPath($server, '/data/coolify/applications/abc/data'))->toBe(DEV_PATH_DATA.'/applications/abc/data') + ->and(devHostDockerPath($server, '/data/coolify/proxy/'))->toBe(DEV_PATH_DATA.'/proxy/') + ->and(devHostDockerPath($server, '/data/coolify/backups/databases/x/file.dmp'))->toBe(DEV_PATH_BACKUPS.'/databases/x/file.dmp') + ->and(devHostDockerPath($server, '/data/coolify-other/x'))->toBe('/data/coolify-other/x') + ->and(devHostDockerPath($server, '/etc/localtime'))->toBe('/etc/localtime') + ->and(devHostDockerPath(null, '/data/coolify/proxy'))->toBe('/data/coolify/proxy'); +}); + +it('mounts application compose bind and content volumes from the server data path', function (string $kind, string $base) { + $server = devPathServer($kind); + $destination = devPathDestination($server); + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => DEV_PATH_COMPOSE, + ]); + + $volumes = applicationParser($application)->get('services')->get('app')['volumes']; + $directory = "{$base}/applications/{$application->uuid}"; + + expect(collect($volumes)->all())->toContain("{$directory}/data:/app/data", "{$directory}/config/app.conf:/etc/app.conf") + // Coolify writes the files through SSH to the normal path. + ->and($application->fileStorages()->pluck('fs_path')->all()) + ->toContain("/data/coolify/applications/{$application->uuid}/config/app.conf"); +})->with(devPathKinds()); + +it('mounts service compose bind and content volumes from the server data path', function (string $kind, string $base) { + $server = devPathServer($kind); + $destination = devPathDestination($server); + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => DEV_PATH_COMPOSE, + ]); + ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]); + + $volumes = serviceParser($service->fresh())->get('services')->get('app')['volumes']; + $directory = "{$base}/services/{$service->uuid}"; + + expect(collect($volumes)->all())->toContain("{$directory}/data:/app/data", "{$directory}/config/app.conf:/etc/app.conf"); +})->with(devPathKinds()); + +it('mounts the Traefik proxy and log rotation directory from the server data path', function (string $kind, string $base) { + $server = devPathServer($kind, 'TRAEFIK', analytics: true); + + $config = Yaml::parse(generateDefaultProxyConfiguration($server)); + + expect($config['services']['traefik']['volumes'])->toContain("{$base}/proxy/:/traefik") + ->and($config['services']['traefik-logrotate']['volumes'])->toBe(["{$base}/proxy/:/traefik"]); +})->with(devPathKinds()); + +it('mounts the Caddy proxy and traffic directories from the server data path', function (string $kind, string $base) { + $server = devPathServer($kind, 'CADDY', analytics: true); + + $volumes = Yaml::parse(generateDefaultProxyConfiguration($server))['services']['caddy']['volumes']; + + expect($volumes)->toContain( + "{$base}/proxy/caddy/dynamic:/dynamic", + "{$base}/proxy/caddy/config:/config", + "{$base}/proxy/caddy/data:/data", + "{$base}/proxy/caddy:/traffic", + )->and(StartSentinel::trafficLogDirectory($server))->toBe("{$base}/proxy/caddy"); +})->with(devPathKinds()); + +it('mounts the Sentinel database and traffic log directory from the server data path', function (string $kind, string $base) { + $server = devPathServer($kind, 'TRAEFIK', analytics: true); + + StartSentinel::run($server, latestVersion: '1.0.1'); + + $directory = "{$base}/proxy"; + $script = collect($this->commands)->first(fn (string $command): bool => str_contains($command, 'docker run -d')); + + expect(StartSentinel::trafficLogDirectory($server))->toBe($directory) + ->and($script)->toContain("-v {$base}/sentinel:/app/db") + ->toContain(escapeshellarg("{$directory}:{$directory}:ro")) + ->toContain(escapeshellarg("TRAFFIC_ACCESS_LOG_PATH={$directory}/access.log")); + if ($kind !== 'testing-host') { + expect($script)->not->toContain('/var/lib/docker/volumes'); + } +})->with(devPathKinds()); + +it('mounts the database proxy configuration from the server data path', function (string $kind, string $base) { + $server = devPathServer($kind); + $database = create_standalone_postgresql($this->environment->id, devPathDestination($server)); + $database->update(['is_public' => true, 'public_port' => 15432]); + + StartDatabaseProxy::run($database->fresh()); + + $script = devPathAllCommands(); + preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d \| tee [^ ]+docker-compose\.yaml/", $script, $matches); + $compose = Yaml::parse(base64_decode($matches[1])); + $volume = $compose['services']["{$database->uuid}-proxy"]['volumes'][0]; + + expect($volume['source'])->toBe("{$base}/databases/{$database->uuid}/proxy/nginx.conf") + // Coolify writes the file through SSH to the normal path. + ->and($script)->toContain("mkdir -p /data/coolify/databases/{$database->uuid}/proxy"); +})->with(devPathKinds()); + +it('mounts the database backup file from the server backups path', function (string $kind, string $base) { + $server = devPathServer($kind); + $backupsBase = $kind === 'testing-host' ? DEV_PATH_BACKUPS : '/data/coolify/backups'; + $job = (new ReflectionClass(DatabaseBackupJob::class))->newInstanceWithoutConstructor(); + $job->server = $server; + $job->backup_location = '/data/coolify/backups/databases/team-1/postgres-abc/pg-dump-1.dmp'; + + $source = (new ReflectionMethod($job, 'backupMountSource'))->invoke($job); + + expect($source)->toBe("{$backupsBase}/databases/team-1/postgres-abc/pg-dump-1.dmp"); +})->with(devPathKinds()); + +it('uses the normal Caddy proxy path on start and restart in development', function () { + config()->set('constants.coolify.base_config_path', '/srv/coolify'); + $server = devPathServer('kvm', 'CADDY'); + + StartProxy::run($server, async: false, force: true); + $restartCommands = (new ReflectionMethod(RestartProxyJob::class, 'buildRestartCommands')) + ->invoke(new RestartProxyJob($server), 'services: {}'); + + expect(devPathAllCommands())->toContain('mkdir -p /srv/coolify/proxy/caddy/dynamic') + ->not->toContain('mkdir -p /data/coolify/proxy/caddy/dynamic') + ->and(implode("\n", $restartCommands))->toContain('mkdir -p /srv/coolify/proxy/caddy/dynamic') + ->not->toContain('/data/coolify/proxy/caddy'); +}); + +it('replaces an old dev volume path in a saved proxy configuration in development', function (string $kind, string $expected) { + $server = devPathServer($kind, 'TRAEFIK'); + $saved = "services:\n traefik:\n image: 'traefik:v3.6'\n volumes:\n - '/var/run/docker.sock:/var/run/docker.sock:ro'\n - '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/:/traefik'\n"; + $server->proxy->last_saved_proxy_configuration = $saved; + $server->save(); + + $configuration = GetProxyConfiguration::run($server->fresh()); + + expect(Yaml::parse($configuration)['services']['traefik']['volumes'])->toContain("{$expected}:/traefik"); +})->with([ + 'dev KVM server' => ['kvm', '/data/coolify/proxy/'], + 'dev testing-host server' => ['testing-host', DEV_PATH_DATA.'/proxy/'], + 'production keeps the saved configuration' => ['production', '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/'], +]); diff --git a/tests/Feature/TrafficAnalytics/CaddyProxyVolumeTest.php b/tests/Feature/TrafficAnalytics/CaddyProxyVolumeTest.php index eadd8fa9ff..7b676cf2af 100644 --- a/tests/Feature/TrafficAnalytics/CaddyProxyVolumeTest.php +++ b/tests/Feature/TrafficAnalytics/CaddyProxyVolumeTest.php @@ -60,9 +60,13 @@ it('uses a default caddy image that supports per-app traffic attribution', funct ->and($server->fresh()->caddySupportsLogAppend())->toBeTrue(); }); -function caddyTrafficServer(object $test, bool $analyticsEnabled = true): Server +function caddyTrafficServer(object $test, bool $analyticsEnabled = true, ?string $ip = null): Server { - $server = Server::factory()->create(['team_id' => $test->team->id, 'private_key_id' => $test->privateKey->id]); + $server = Server::factory()->create(array_filter([ + 'team_id' => $test->team->id, + 'private_key_id' => $test->privateKey->id, + 'ip' => $ip, + ])); $server->proxy->set('type', 'CADDY'); $server->save(); $server->settings->is_traffic_analytics_enabled = $analyticsEnabled; @@ -80,40 +84,48 @@ it('mounts the Sentinel traffic log directory into Caddy in production', functio ->and($config['services']['caddy']['volumes'])->toContain('/data/coolify/proxy/caddy:/traffic'); }); -it('mounts the Sentinel traffic log directory into Caddy in development', function () { +it('mounts the Sentinel traffic log directory into Caddy in development', function (?string $ip, string $directory) { config()->set('app.env', 'local'); - $server = caddyTrafficServer($this); + $server = caddyTrafficServer($this, ip: $ip); $volumes = Yaml::parse(generateDefaultProxyConfiguration($server))['services']['caddy']['volumes']; $trafficVolumes = array_values(array_filter($volumes, fn (string $volume): bool => str_ends_with($volume, ':/traffic'))); // Caddy writes /traffic/access.log, Sentinel reads /access.log. expect($trafficVolumes)->toBe([StartSentinel::trafficLogDirectory($server).':/traffic']) - ->and($trafficVolumes[0])->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy:/traffic'); -}); + ->and($trafficVolumes[0])->toBe("{$directory}:/traffic"); +})->with([ + // A dev KVM server has its own Docker daemon and its own /data/coolify, like production. + 'dev KVM server' => ['10.221.1.10', '/data/coolify/proxy/caddy'], + // The testing-host server uses the host Docker daemon, so it mounts the dev data volume. + 'dev testing-host server' => [Server::DEV_TESTING_HOST_IP, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/caddy'], +]); -it('uses the configured dev data volume for Caddy, Traefik, and Sentinel', function () { +it('uses the configured dev data volume for Caddy, Traefik, and Sentinel on the testing-host server', function () { config()->set('app.env', 'local'); config()->set('constants.coolify.dev_data_volume', 'coolify-dev-feature_coolify_data'); - $caddy = caddyTrafficServer($this); + $caddy = caddyTrafficServer($this, ip: Server::DEV_TESTING_HOST_IP); $caddyVolumes = Yaml::parse(generateDefaultProxyConfiguration($caddy))['services']['caddy']['volumes']; - $traefik = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + $traefik = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, 'ip' => Server::DEV_TESTING_HOST_IP]); $traefik->proxy->set('type', 'TRAEFIK'); $traefik->save(); $traefikVolumes = Yaml::parse(generateDefaultProxyConfiguration($traefik->fresh()))['services']['traefik']['volumes']; - expect(StartSentinel::trafficLogDirectory($caddy))->toBe('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy') - ->and($caddyVolumes)->toContain('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy:/traffic') + expect(StartSentinel::trafficLogDirectory($caddy))->toBe('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy/caddy') + ->and($caddyVolumes)->toContain('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy/caddy:/traffic') ->and($traefikVolumes)->toContain('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy/:/traefik'); }); -it('falls back to the legacy dev data volume for an invalid volume name', function (?string $volume) { +it('falls back to the legacy dev volumes for an invalid volume name', function (?string $volume) { config()->set('app.env', 'local'); config()->set('constants.coolify.dev_data_volume', $volume); + config()->set('constants.coolify.dev_backups_volume', $volume); + $server = caddyTrafficServer($this, ip: Server::DEV_TESTING_HOST_IP); - expect(devCoolifyDataPath())->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data'); + expect(devHostDockerPath($server, '/data/coolify'))->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data') + ->and(devHostDockerPath($server, '/data/coolify/backups/x'))->toBe('/var/lib/docker/volumes/coolify_dev_backups_data/_data/x'); })->with([ 'empty' => [''], 'null' => [null], diff --git a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php index 39a4bde635..52ff4ab656 100644 --- a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php +++ b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php @@ -47,17 +47,21 @@ it('produces traffic + geoip env when enabled', function () { expect($env)->toHaveKey('TRAFFIC_ACCESS_LOG_PATH'); }); -it('uses the dev proxy volume for traffic logs locally', function () { +it('uses the dev proxy volume for traffic logs only on the testing-host server locally', function (string $ip, string $directory) { config()->set('app.env', 'local'); $server = trafficProxyServer($this); + $server->update(['ip' => $ip]); $server->settings->is_traffic_analytics_enabled = true; $server->settings->save(); expect(StartSentinel::trafficLogDirectory($server->fresh())) - ->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy'); + ->toBe($directory); expect(StartSentinel::sentinelTrafficEnvironment($server->fresh())['TRAFFIC_ACCESS_LOG_PATH']) - ->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/access.log'); -}); + ->toBe("{$directory}/access.log"); +})->with([ + 'dev KVM server' => ['10.221.1.10', '/data/coolify/proxy'], + 'dev testing-host server' => [Server::DEV_TESTING_HOST_IP, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy'], +]); it('passes custom traffic settings as sentinel env', function () { $server = trafficProxyServer($this); @@ -207,11 +211,13 @@ it('keeps the access log commands valid for non-root servers', function () { expect($syntax->isSuccessful())->toBeTrue($syntax->getErrorOutput()); }); -it('mounts the configured dev data volume for traffic logs and Sentinel data', function () { +it('mounts the configured dev data volume for traffic logs and Sentinel data on the testing-host server', function () { config()->set('app.env', 'local'); config()->set('constants.coolify.dev_data_volume', 'coolify-dev-feature_coolify_data'); $server = sentinelTrafficServer($this, 'CADDY', analyticsEnabled: true); - $directory = '/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy'; + $server->update(['ip' => Server::DEV_TESTING_HOST_IP]); + $server = $server->fresh(); + $directory = '/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy/caddy'; $script = runStartSentinelAndCaptureScript($server);