Files
coolify/tests/Feature/ContentFilesBeforeComposeUpTest.php

647 lines
29 KiB
PHP

<?php
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\StartService;
use App\Actions\Shared\EnsureContentFilesOnServer;
use App\Jobs\ApplicationDeploymentJob;
use App\Jobs\CoolifyTask;
use App\Jobs\ServerStorageSaveJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Support\RemoteProcessCommand;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Process;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
const CONTENT_FILES_UP_CONTENT = 'listen 8080;';
const CONTENT_FILES_UP_SERVICE_COMPOSE = <<<'YAML'
services:
app:
image: nginx:alpine
volumes:
- type: bind
source: ./config/app.conf
target: /etc/app.conf
content: 'listen 8080;'
worker:
image: nginx:alpine
volumes:
- type: bind
source: ./config/worker.conf
target: /etc/worker.conf
content: 'workers 4;'
YAML;
/**
* Every server command and every deployment command, in the order they ran.
*/
final class ContentFilesUpRecorder
{
/** @var list<array{string, string}> */
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<string> */
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<string, string> $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<string, mixed> $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<string, mixed> $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'],
]);