*/ public static array $commands = []; public static function indexOf(string $channel, string $needle): ?int { foreach (self::$commands as $index => [$recordedChannel, $command]) { if ($recordedChannel === $channel && str_contains($command, $needle)) { return $index; } } return null; } /** @return list */ public static function ssh(): array { return collect(self::$commands)->where(0, 'ssh')->pluck(1)->values()->all(); } } /** * Records the deployment commands instead of running them. */ class ContentFilesUpDeploymentJob extends ApplicationDeploymentJob { public function __construct() {} public function execute_remote_command(...$commands): void { foreach ($commands as $command) { ContentFilesUpRecorder::$commands[] = ['deployment', $command['command'] ?? $command[0]]; } } } /** * Fakes the server file system. $states maps a path to `file`, `missing`, `empty-directory`, * `directory` or `other`. The symlink check always passes. * * @param array $states */ function fakeContentFilesServer(array $states): void { ContentFilesUpRecorder::$commands = []; Process::fake(function ($process) use ($states) { $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); ContentFilesUpRecorder::$commands[] = ['ssh', $command]; if (str_contains($command, 'empty-directory')) { $positions = collect($states) ->map(fn (string $state, string $path) => strpos($command, escapeshellarg($path))) ->filter(fn ($position) => $position !== false) ->sort(); $lines = $positions->keys()->values()->map(fn (string $path, int $index) => ($index + 1).':'.$states[$path]); return Process::result(output: $lines->implode("\n")); } if (str_contains($command, 'readlink -f')) { return Process::result(output: 'OK'); } foreach ($states as $path => $state) { if (str_contains($command, 'test -f '.escapeshellarg($path))) { return Process::result(output: $state === 'file' ? 'OK' : 'NOK'); } if (str_contains($command, 'test -d '.escapeshellarg($path))) { return Process::result(output: in_array($state, ['empty-directory', 'directory'], true) ? 'OK' : 'NOK'); } } return Process::result(output: str_contains($command, 'test -') ? 'NOK' : ''); }); } /** * The file state checks: one for all content files, then one by each write of a missing file. */ function contentFilesStateChecks(): array { return array_values(array_filter(ContentFilesUpRecorder::ssh(), fn (string $command) => str_contains($command, 'empty-directory'))); } function contentFilesAssertNoWrite(): void { Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'base64 -d')); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rmdir')); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -fr')); } beforeEach(function () { Server::flushIdentityMap(); Bus::fake(); InstanceSettings::forceCreate(['id' => 0]); config([ 'app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false, ]); $this->team = Team::factory()->create(); $this->server = Server::factory()->create([ 'team_id' => $this->team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, ]); $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); $project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $project->id]); $this->application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'build_pack' => 'dockercompose', 'docker_compose_location' => '/docker-compose.yaml', ]); $this->logEntries = []; }); /** * Bus::fake() keeps the ServerStorageSaveJob of the `created` hook from running. * * @param array $attributes */ function contentFilesVolume(Application|Service $resource, string $relativePath, array $attributes = []): LocalFileVolume { $owner = $resource instanceof Service ? $resource->applications()->firstOrFail() : $resource; return LocalFileVolume::create([ 'fs_path' => $resource->workdir().'/'.$relativePath, 'mount_path' => '/etc/'.basename($relativePath), 'content' => CONTENT_FILES_UP_CONTENT, 'is_directory' => false, 'resource_id' => $owner->id, 'resource_type' => $owner->getMorphClass(), ...$attributes, ])->fresh(); } /** * Runs the part of a Compose deployment that starts the containers. * * @param array $properties */ function runComposeStart(object $test, array $properties = []): ContentFilesUpDeploymentJob { $job = new ContentFilesUpDeploymentJob; $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); $queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message, string $type = 'stdout') use ($test) { $test->logEntries[] = [$message, $type]; }); foreach ([ 'application' => $test->application->fresh(), 'application_deployment_queue' => $queue, 'server' => $test->server->fresh(), 'mainServer' => $test->server->fresh(), 'deployment_uuid' => 'deployment-uuid', 'workdir' => '/artifacts/deployment-uuid', 'configuration_dir' => $test->application->workdir(), 'docker_compose_location' => '/docker-compose.yaml', 'coolify_variables' => '', 'pull_request_id' => 0, 'preserveRepository' => false, 'saved_outputs' => collect(), ...$properties, ] as $property => $value) { (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value); } (new ReflectionMethod(ApplicationDeploymentJob::class, 'start_docker_compose_services'))->invoke($job); return $job; } function contentFilesLogText(object $test): string { return collect($test->logEntries)->pluck(0)->implode("\n"); } test('a Compose deployment writes a missing content file before docker compose up', function () { $volume = contentFilesVolume($this->application, 'config/app.conf'); fakeContentFilesServer([$volume->fs_path => 'missing']); runComposeStart($this); $write = ContentFilesUpRecorder::indexOf('ssh', 'base64 -d | tee '.escapeshellarg($volume->fs_path)); $up = ContentFilesUpRecorder::indexOf('deployment', ' up -d'); expect($write)->not->toBeNull() ->and($up)->not->toBeNull() ->and($write)->toBeLessThan($up) ->and(ContentFilesUpRecorder::ssh()[$write])->toContain(base64_encode(CONTENT_FILES_UP_CONTENT)) ->and($this->logEntries)->toContain(['Writing 1 missing configuration file.', 'stdout']) ->and(contentFilesLogText($this))->not->toContain(CONTENT_FILES_UP_CONTENT) ->and(contentFilesLogText($this))->not->toContain(base64_encode(CONTENT_FILES_UP_CONTENT)); }); test('a Compose deployment checks all content files with one server command', function () { $first = contentFilesVolume($this->application, 'config/app.conf'); $second = contentFilesVolume($this->application, 'config/worker.conf'); $third = contentFilesVolume($this->application, 'nginx.conf'); fakeContentFilesServer([$first->fs_path => 'file', $second->fs_path => 'missing', $third->fs_path => 'file']); runComposeStart($this); expect(contentFilesStateChecks())->toHaveCount(2) ->and(contentFilesStateChecks()[0])->toContain(escapeshellarg($first->fs_path)) ->toContain(escapeshellarg($second->fs_path)) ->toContain(escapeshellarg($third->fs_path)) ->and($this->logEntries)->toContain(['Writing 1 missing configuration file.', 'stdout']); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($second->fs_path))); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($first->fs_path))); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($third->fs_path))); }); test('a Compose deployment does not change a content file that exists on the server', function () { $volume = contentFilesVolume($this->application, 'config/app.conf'); fakeContentFilesServer([$volume->fs_path => 'file']); runComposeStart($this); contentFilesAssertNoWrite(); expect(contentFilesStateChecks())->toHaveCount(1) ->and(contentFilesLogText($this))->not->toContain('Writing') ->and(ContentFilesUpRecorder::indexOf('deployment', ' up -d'))->not->toBeNull(); }); test('a Compose deployment replaces an empty directory with the content file', function () { $volume = contentFilesVolume($this->application, 'config/app.conf'); fakeContentFilesServer([$volume->fs_path => 'empty-directory']); runComposeStart($this); $escapedPath = escapeshellarg($volume->fs_path); $write = ContentFilesUpRecorder::indexOf('ssh', "base64 -d | tee {$escapedPath}"); $writeCommand = ContentFilesUpRecorder::$commands[$write][1]; expect($write)->not->toBeNull() ->and($write)->toBeLessThan(ContentFilesUpRecorder::indexOf('deployment', ' up -d')) ->and(strpos($writeCommand, "rmdir {$escapedPath}"))->toBeLessThan(strpos($writeCommand, "tee {$escapedPath}")) ->and($this->logEntries)->toContain(['Writing 1 missing configuration file.', 'stdout']); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -fr')); }); test('a Compose deployment keeps a directory that is not empty and shows a warning', function () { $volume = contentFilesVolume($this->application, 'config/app.conf'); fakeContentFilesServer([$volume->fs_path => 'directory']); runComposeStart($this); contentFilesAssertNoWrite(); expect($this->logEntries)->toContain([ "Warning: A directory that is not empty is at {$volume->fs_path} on the server. Coolify did not write the configuration file for /etc/app.conf. Remove the directory on the server, then start the resource again.", 'stderr', ]) ->and(ContentFilesUpRecorder::indexOf('deployment', ' up -d'))->not->toBeNull(); }); test('a Compose deployment does not write storages without Coolify content', function (array $attributes) { contentFilesVolume($this->application, 'config/app.conf', $attributes); fakeContentFilesServer([]); runComposeStart($this); expect(contentFilesStateChecks())->toBe([]); contentFilesAssertNoWrite(); })->with([ 'host file' => [['is_host_file' => true]], 'file from the Git repository' => [['is_based_on_git' => true]], 'directory' => [['is_directory' => true, 'content' => null]], 'file without content' => [['content' => null]], 'file with empty content' => [['content' => '']], 'binary file placeholder' => [['content' => LocalFileVolume::BINARY_PLACEHOLDER]], 'too large file placeholder' => [['content' => LocalFileVolume::TOO_LARGE_PLACEHOLDER]], ]); test('a pull request deployment writes the preview content file path', function () { $volume = contentFilesVolume($this->application, 'config/app.conf'); $previewPath = $volume->fs_path.'-pr-7'; fakeContentFilesServer([$volume->fs_path => 'file', $previewPath => 'missing']); runComposeStart($this, ['pull_request_id' => 7]); $write = ContentFilesUpRecorder::indexOf('ssh', 'base64 -d | tee '.escapeshellarg($previewPath)); expect($write)->not->toBeNull() ->and($write)->toBeLessThan(ContentFilesUpRecorder::indexOf('deployment', ' up -d')) ->and(ContentFilesUpRecorder::indexOf('ssh', 'tee '.escapeshellarg($volume->fs_path).' '))->toBeNull() ->and($volume->fresh()->fs_path)->toBe($this->application->workdir().'/config/app.conf'); }); test('a pull request deployment of the legacy compose parser writes the content file path of the application', function () { $this->application->update(['compose_parsing_version' => '2']); $volume = contentFilesVolume($this->application, 'config/app.conf'); fakeContentFilesServer([$volume->fs_path => 'missing']); runComposeStart($this, ['pull_request_id' => 7]); expect(ContentFilesUpRecorder::indexOf('ssh', 'base64 -d | tee '.escapeshellarg($volume->fs_path)))->not->toBeNull() ->and(ContentFilesUpRecorder::indexOf('ssh', '-pr-7'))->toBeNull(); }); test('a preserve-repository deployment writes content files before docker compose up', function () { $this->application->settings()->update(['is_preserve_repository_enabled' => true]); $volume = contentFilesVolume($this->application, 'config/app.conf'); fakeContentFilesServer([$volume->fs_path => 'missing']); runComposeStart($this, ['preserveRepository' => true]); $write = ContentFilesUpRecorder::indexOf('ssh', 'tee '.escapeshellarg($volume->fs_path)); expect($write)->not->toBeNull() ->and($write)->toBeLessThan(ContentFilesUpRecorder::indexOf('deployment', ' up -d')); }); test('a deployment to an additional server writes missing content files on that server', function () { $volume = contentFilesVolume($this->application, 'config/app.conf'); $otherServer = Server::factory()->create([ 'team_id' => $this->team->id, 'private_key_id' => $this->server->private_key_id, ])->fresh(); fakeContentFilesServer([$volume->fs_path => 'missing']); runComposeStart($this, ['server' => $otherServer, 'mainServer' => $otherServer]); $write = ContentFilesUpRecorder::indexOf('ssh', 'base64 -d | tee '.escapeshellarg($volume->fs_path)); expect(contentFilesStateChecks())->toHaveCount(2) ->and(contentFilesStateChecks()[0])->toContain("@'{$otherServer->ip}'") ->and($write)->not->toBeNull() ->and(ContentFilesUpRecorder::ssh()[$write])->toContain("@'{$otherServer->ip}'") ->and(ContentFilesUpRecorder::ssh()[$write])->not->toContain("@'{$this->server->ip}'"); }); test('a Dockerfile deployment to an additional server writes missing content files before the container starts', function (string $state) { $this->application->update(['build_pack' => 'dockerfile', 'docker_compose_location' => null]); $volume = contentFilesVolume($this->application, 'config/app.conf'); $otherServer = Server::factory()->create([ 'team_id' => $this->team->id, 'private_key_id' => $this->server->private_key_id, ])->fresh(); fakeContentFilesServer([$volume->fs_path => $state]); $job = new ContentFilesUpDeploymentJob; $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); $queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message, string $type = 'stdout') { $this->logEntries[] = [$message, $type]; }); foreach ([ 'application' => $this->application->fresh(), 'application_deployment_queue' => $queue, 'server' => $otherServer, 'mainServer' => $otherServer, 'destination' => $this->destination, 'deployment_uuid' => 'deployment-uuid', 'workdir' => '/artifacts/deployment-uuid', 'configuration_dir' => $this->application->workdir(), 'docker_compose_location' => '/docker-compose.yaml', 'coolify_variables' => '', 'pull_request_id' => 0, 'preserveRepository' => false, 'use_build_server' => false, 'saved_outputs' => collect(), ] as $property => $value) { (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value); } (new ReflectionMethod(ApplicationDeploymentJob::class, 'start_by_compose_file'))->invoke($job); $write = ContentFilesUpRecorder::indexOf('ssh', 'base64 -d | tee '.escapeshellarg($volume->fs_path)); $up = ContentFilesUpRecorder::indexOf('deployment', ' up --build -d'); expect($write)->not->toBeNull() ->and($up)->not->toBeNull() ->and($write)->toBeLessThan($up) ->and(ContentFilesUpRecorder::ssh()[$write])->toContain("@'{$otherServer->ip}'") ->and($this->logEntries)->toContain(['Writing 1 missing configuration file.', 'stdout']); if ($state === 'empty-directory') { $writeCommand = ContentFilesUpRecorder::ssh()[$write]; $escapedPath = escapeshellarg($volume->fs_path); expect(strpos($writeCommand, "rmdir {$escapedPath}"))->toBeLessThan(strpos($writeCommand, "tee {$escapedPath}")); } $connect = ContentFilesUpRecorder::indexOf('deployment', "docker network connect '{$this->destination->network}' coolify-proxy"); expect($connect)->not->toBeNull() ->and($connect)->toBeLessThan($up); })->with([ 'missing file' => ['missing'], // Docker creates a missing bind source as an empty directory (#10398). 'empty directory left by Docker' => ['empty-directory'], ]); test('the content file check and write are safe for non-root servers', function () { $this->server->update(['user' => 'ubuntu']); $volume = contentFilesVolume($this->application, 'config/app.conf'); fakeContentFilesServer([$volume->fs_path => 'empty-directory']); runComposeStart($this, ['server' => $this->server->fresh(), 'mainServer' => $this->server->fresh()]); $escapedPath = escapeshellarg($volume->fs_path); expect(contentFilesStateChecks())->not->toBeEmpty() ->and(contentFilesStateChecks()[0])->toContain("sudo bash -c 'sh -c "); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "sudo rmdir {$escapedPath}") && str_contains(remoteProcessScript($process), "| sudo tee {$escapedPath}")); }); test('starting a service writes missing content files before docker compose up', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => CONTENT_FILES_UP_SERVICE_COMPOSE, ]); fakeContentFilesServer([]); $service->parse(); $appConf = LocalFileVolume::query()->where('mount_path', '/etc/app.conf')->firstOrFail(); $workerConf = LocalFileVolume::query()->where('mount_path', '/etc/worker.conf')->firstOrFail(); fakeContentFilesServer([$appConf->fs_path => 'missing', $workerConf->fs_path => 'directory']); $activity = StartService::run($service->fresh()); $command = RemoteProcessCommand::read($activity); expect(contentFilesStateChecks())->toHaveCount(2) ->and($command)->toContain("echo 'Writing 1 missing configuration file.'") ->and($command)->toContain("echo 'Warning: A directory that is not empty is at {$workerConf->fs_path} on the server.") ->and(strpos($command, 'Writing 1 missing configuration file.'))->toBeLessThan(strpos($command, ' up -d')) ->and($command)->not->toContain(base64_encode('listen 8080;')); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'base64 -d | tee '.escapeshellarg($appConf->fs_path)) && str_contains(remoteProcessScript($process), base64_encode('listen 8080;'))); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($workerConf->fs_path))); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -fr')); }); test('deploying one service application writes only its missing content files', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => CONTENT_FILES_UP_SERVICE_COMPOSE, ]); fakeContentFilesServer([]); $service->parse(); $appConf = LocalFileVolume::query()->where('mount_path', '/etc/app.conf')->firstOrFail(); $workerConf = LocalFileVolume::query()->where('mount_path', '/etc/worker.conf')->firstOrFail(); fakeContentFilesServer([$appConf->fs_path => 'missing', $workerConf->fs_path => 'missing']); $activity = DeployServiceApplication::run($service->applications()->where('name', 'app')->firstOrFail()); $command = RemoteProcessCommand::read($activity); expect(contentFilesStateChecks())->toHaveCount(2) ->and(contentFilesStateChecks()[0])->not->toContain(escapeshellarg($workerConf->fs_path)) ->and(strpos($command, 'Writing 1 missing configuration file.'))->toBeLessThan(strpos($command, ' up -d')); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($appConf->fs_path))); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($workerConf->fs_path))); }); test('starting a service without content files does not run the file check', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n", ]); fakeContentFilesServer([]); $activity = StartService::run($service); expect(contentFilesStateChecks())->toBe([]) ->and($activity)->toBeInstanceOf(Activity::class) ->and(RemoteProcessCommand::read($activity))->not->toContain('missing configuration file'); }); test('the content file step continues when the server does not report a state', function () { $volume = contentFilesVolume($this->application, 'config/app.conf'); fakeContentFilesServer([]); $messages = []; $written = EnsureContentFilesOnServer::run( [$volume], $this->server, function (string $message, string $type) use (&$messages) { $messages[] = [$message, $type]; }, ); expect($written)->toBe(0) ->and($messages)->toBe([[ "Warning: Coolify cannot check the configuration file {$volume->fs_path} on the server.", 'stderr', ]]); contentFilesAssertNoWrite(); }); test('the storage save job writes the content again when the same file exists', function () { $volume = contentFilesVolume($this->application, 'config/app.conf'); fakeContentFilesServer([$volume->fs_path => 'file']); (new ServerStorageSaveJob($volume))->handle(); (new ServerStorageSaveJob($volume->fresh()))->handle(); $writes = array_filter(ContentFilesUpRecorder::ssh(), fn (string $command) => str_contains($command, 'base64 -d | tee '.escapeshellarg($volume->fs_path))); expect($writes)->toHaveCount(2); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -fr')); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rmdir')); }); test('the storage save job replaces an empty directory with the content file', function () { $volume = contentFilesVolume($this->application, 'config/app.conf'); fakeContentFilesServer([$volume->fs_path => 'empty-directory']); (new ServerStorageSaveJob($volume))->handle(); $escapedPath = escapeshellarg($volume->fs_path); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "rmdir {$escapedPath}") && str_contains(remoteProcessScript($process), base64_encode(CONTENT_FILES_UP_CONTENT)) && strpos(remoteProcessScript($process), "rmdir {$escapedPath}") < strpos(remoteProcessScript($process), "tee {$escapedPath}")); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -fr')); Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), "touch {$escapedPath}")); }); test('the storage save job does not delete a directory that is not empty', function () { $volume = contentFilesVolume($this->application, 'config/app.conf'); fakeContentFilesServer([$volume->fs_path => 'directory']); expect(fn () => (new ServerStorageSaveJob($volume))->handle()) ->toThrow(Exception::class, "The following file is a directory on the server, but you are trying to mark it as a file: {$volume->fs_path}"); contentFilesAssertNoWrite(); expect($volume->fresh()->is_directory)->toBeFalse(); }); test('the server file sync keeps the content of a file storage when a directory is at its path', function () { $volume = contentFilesVolume($this->application, 'conf/app.conf'); fakeContentFilesServer([$volume->fs_path => 'directory']); getFilesystemVolumesFromServer($this->application->fresh(), isInit: true); expect($volume->fresh()) ->is_directory->toBeFalsy() ->content->toBe(CONTENT_FILES_UP_CONTENT); contentFilesAssertNoWrite(); }); test('the server file sync replaces an empty directory with the content file', function () { $volume = contentFilesVolume($this->application, 'conf/app.conf'); fakeContentFilesServer([$volume->fs_path => 'empty-directory']); getFilesystemVolumesFromServer($this->application->fresh(), isInit: true); expect($volume->fresh()) ->is_directory->toBeFalsy() ->content->toBe(CONTENT_FILES_UP_CONTENT); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'rmdir')); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'base64 -d')); }); test('the server file sync checks all file storages with one server command', function () { $first = contentFilesVolume($this->application, 'conf/app.conf'); $second = contentFilesVolume($this->application, 'conf/worker.conf'); $directory = contentFilesVolume($this->application, 'data/cache', ['content' => null]); fakeContentFilesServer([$first->fs_path => 'file', $second->fs_path => 'file', $directory->fs_path => 'directory']); getFilesystemVolumesFromServer($this->application->fresh()); expect(ContentFilesUpRecorder::ssh())->toHaveCount(1) ->and(contentFilesStateChecks())->toHaveCount(1) ->and($directory->fresh()->is_directory)->toBeTruthy() ->and($first->fresh()->is_directory)->toBeFalsy(); }); test('the server file sync still marks a storage without content as a directory', function () { $volume = contentFilesVolume($this->application, 'data/cache', ['content' => null]); fakeContentFilesServer([$volume->fs_path => 'directory']); getFilesystemVolumesFromServer($this->application->fresh(), isInit: true); expect($volume->fresh()->is_directory)->toBeTruthy(); }); test('starting a service runs its commands on the deployment queue', function (bool $selfHosted, string $queue) { config(['constants.coolify.self_hosted' => $selfHosted]); $service = Service::factory()->create([ 'environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n", ]); fakeContentFilesServer([]); StartService::run($service); Bus::assertDispatched(CoolifyTask::class, fn (CoolifyTask $job) => $job->queue === $queue); })->with([ 'cloud' => [false, 'deployments'], 'self-hosted' => [true, 'high'], ]);