mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 10:04:36 +08:00
647 lines
29 KiB
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'],
|
|
]);
|