diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index f26b8cbb25..3070e88ac6 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -127,8 +127,9 @@ class StartDatabaseProxy "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", + // Pass -f so Compose uses only the file that Coolify wrote. + "docker compose --project-directory {$configuration_dir} -f {$configuration_dir}/docker-compose.yaml pull", + "docker compose --project-directory {$configuration_dir} -f {$configuration_dir}/docker-compose.yaml up -d", ], $server); } catch (\RuntimeException $e) { if ($this->isNonTransientError($e->getMessage())) { diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 196df2b954..709e459956 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -45,16 +45,17 @@ class StartService // doesn't match configuration. Recreate?") would read the next script lines as the answer. // Give Compose no stdin so it uses the default answer (keep the volume). Never pass --yes: // it recreates the volume and deletes its data. + // Pass -f so Compose uses only the file that Coolify wrote. + $compose = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid}"; if ($pullLatestImages) { $commands[] = "echo 'Pulling images.'"; - $commands[] = "docker compose --project-directory {$workdir} pull < /dev/null"; + $commands[] = "{$compose} pull < /dev/null"; } if ($service->networks()->count() > 0) { $commands[] = "echo 'Creating Docker network.'"; $commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid"; } $commands[] = 'echo Starting service.'; - $compose = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid}"; if (data_get($service, 'connect_to_docker_network')) { // Connect the created containers to the predefined network before they start. A container // that needs this network during a service_healthy wait never gets it after `up -d`. diff --git a/app/Models/Application.php b/app/Models/Application.php index 02c6d0935b..2e455bc238 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -546,24 +546,21 @@ class Application extends BaseModel } } + /** + * Removes the volumes by the names that Coolify stored. + */ public function deleteVolumes() { - $persistentStorages = $this->persistentStorages()->get() ?? collect(); - if ($this->build_pack === 'dockercompose') { - $server = data_get($this, 'destination.server'); - // --project-directory instead of cd: a non-root SSH user cannot enter the directory on the Coolify host. - instant_remote_process(["docker compose --project-directory {$this->dirOnServer()} down -v"], $server, false); - } else { - if ($persistentStorages->count() === 0) { - return; - } - $server = data_get($this, 'destination.server'); - foreach ($persistentStorages as $storage) { - if ($storage->isSharedWithAnotherResource()) { - continue; - } - instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); + $persistentStorages = $this->persistentStorages()->get(); + if ($persistentStorages->isEmpty()) { + return; + } + $server = data_get($this, 'destination.server'); + foreach ($persistentStorages as $storage) { + if ($storage->isSharedWithAnotherResource()) { + continue; } + instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } diff --git a/tests/Feature/ComposeExternalVolumeTest.php b/tests/Feature/ComposeExternalVolumeTest.php index 4577b9b2b5..08d8f16c09 100644 --- a/tests/Feature/ComposeExternalVolumeTest.php +++ b/tests/Feature/ComposeExternalVolumeTest.php @@ -12,6 +12,7 @@ use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; use App\Models\Environment; use App\Models\InstanceSettings; +use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; use App\Models\PrivateKey; use App\Models\Project; @@ -794,8 +795,9 @@ describe('delete', function () { ->not->toContain("{$uuid}_shared-data"); }); - it('lets Docker Compose keep the external volume when a Compose application is deleted with its volumes', function () { + it('never removes the external volume when a Compose application is deleted with its volumes', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); + $uuid = $application->uuid; applicationParser($application); $application->delete(); $commands = []; @@ -804,11 +806,29 @@ describe('delete', function () { (new DeleteResourceJob($application))->handle(); $all = implode("\n", $commands); - // --project-directory, not cd: a non-root SSH user cannot enter the directory on the Coolify host. - expect($all)->toContain("docker compose --project-directory {$application->dirOnServer()} down -v") - ->not->toContain("cd {$application->dirOnServer()}") - ->not->toContain('docker volume rm') - ->and($application->docker_compose)->toContain("shared-data:\n external: true"); + expect($all)->toContain("docker volume rm -f '{$uuid}_app-data'") + ->not->toContain("'shared-data'") + ->not->toContain("{$uuid}_shared-data"); + }); + + it('removes the volumes by name without Docker Compose when a Compose application is deleted', function () { + $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); + applicationParser($application); + LocalFileVolume::create([ + 'fs_path' => $application->dirOnServer().'/compose.yaml', + 'mount_path' => '/compose.yaml', + 'content' => "services:\n other:\n image: busybox\n", + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $application->delete(); + $commands = []; + fakeExternalVolumeServer($commands); + + (new DeleteResourceJob($application, deleteConfigurations: false))->handle(); + + expect(implode("\n", $commands))->not->toContain('docker compose') + ->toContain("docker volume rm -f '{$application->uuid}_app-data'"); }); it('removes only the old prefixed volume when a service with an old storage entry is deleted with its volumes', function () { diff --git a/tests/Feature/Service/StartDatabaseProxyTest.php b/tests/Feature/Service/StartDatabaseProxyTest.php index 2d525945c5..a0e98a9241 100644 --- a/tests/Feature/Service/StartDatabaseProxyTest.php +++ b/tests/Feature/Service/StartDatabaseProxyTest.php @@ -118,3 +118,34 @@ test('database proxy resolves the database container on every connection', funct ->toContain('proxy_pass $upstream;') ->not->toContain("proxy_pass {$database->uuid}:5432;"); }); + +test('database proxy runs Docker Compose only with the compose file that Coolify wrote', function () { + Server::flushIdentityMap(); + InstanceSettings::forceCreate(['id' => 0]); + config(['constants.ssh.mux_enabled' => false]); + + $commands = []; + Process::fake(function ($process) use (&$commands) { + $commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + + return Process::result(output: ''); + }); + + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = create_standalone_postgresql($environment->id, $destination); + $database->update(['is_public' => true, 'public_port' => 15432]); + + StartDatabaseProxy::run($database->fresh()); + + $proxyDir = database_proxy_dir($database->uuid); + preg_match_all('/docker compose [^\n]*/', implode("\n", $commands), $matches); + expect($matches[0])->toHaveCount(2); + foreach ($matches[0] as $composeCommand) { + expect($composeCommand)->toContain("--project-directory {$proxyDir} -f {$proxyDir}/docker-compose.yaml "); + } +}); diff --git a/tests/Feature/ServiceComposeStdinTest.php b/tests/Feature/ServiceComposeStdinTest.php index a45772ad22..15ee254050 100644 --- a/tests/Feature/ServiceComposeStdinTest.php +++ b/tests/Feature/ServiceComposeStdinTest.php @@ -142,6 +142,18 @@ it('gives service compose up and pull no stdin and never answers the recreate pr } }); +it('runs every service compose command with the compose file that Coolify wrote', function () { + $service = serviceComposeStdinService('deploy'); + $workdir = $service->workdir(); + + $lines = serviceComposeCommandLines(StartService::run($service, pullLatestImages: true)); + + expect($lines)->toHaveCount(3); + foreach ($lines as $line) { + expect($line)->toContain("-f {$workdir}/docker-compose.yml --project-name {$service->uuid} "); + } +}); + /** * @param list $calls */