fix(storage): write content files before compose up and never drop their content

- Compose content files are written by a queued job. Deployments and
  service starts now check all content files with one command before
  docker compose up: a missing file or an empty directory (left by
  Docker) is written; an existing file is kept; a directory with files
  is kept with a visible warning.
- The server file sync that runs after every parse marked a file
  storage as a directory and deleted its configured content when a
  directory was at its path. A storage with content now keeps its
  content; an empty directory is replaced with the file.
- Saving a storage no longer fails for an existing file, replaces an
  empty directory, and no longer deletes a directory with files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-27 16:26:56 +02:00
co-authored by Claude Opus 5.5
parent a77fe4d4ed
commit fe6854c73e
8 changed files with 1019 additions and 36 deletions
@@ -2,6 +2,7 @@
namespace App\Actions\Service;
use App\Actions\Shared\EnsureContentFilesOnServer;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -32,6 +33,7 @@ class DeployServiceApplication
$commands = collect([
'echo '.escapeshellarg("Saved configuration files to {$workdir}."),
'touch '.escapeshellarg("{$workdir}/.env"),
...EnsureContentFilesOnServer::echoCommands($serviceApplication->fileStorages()->get(), $service->server),
]);
if ($pullLatestImages) {
+23
View File
@@ -2,7 +2,12 @@
namespace App\Actions\Service;
use App\Actions\Shared\EnsureContentFilesOnServer;
use App\Models\LocalFileVolume;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
@@ -32,6 +37,7 @@ class StartService
// This is defensive programming - saveComposeConfigs() already creates it,
// but we guarantee it here in case of any edge cases or manual deployments
$commands[] = "touch {$workdir}/.env";
$commands = array_merge($commands, EnsureContentFilesOnServer::echoCommands($this->contentFileStorages($service), $service->server));
if ($pullLatestImages) {
$commands[] = "echo 'Pulling images.'";
$commands[] = "docker compose --project-directory {$workdir} pull";
@@ -57,6 +63,23 @@ class StartService
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
}
/**
* @return Collection<int, LocalFileVolume>
*/
private function contentFileStorages(Service $service): Collection
{
return LocalFileVolume::query()
->where(function ($query) use ($service) {
$query->where('resource_type', (new ServiceApplication)->getMorphClass())
->whereIn('resource_id', $service->applications()->select('id'));
})
->orWhere(function ($query) use ($service) {
$query->where('resource_type', (new ServiceDatabase)->getMorphClass())
->whereIn('resource_id', $service->databases()->select('id'));
})
->get();
}
private function logDrainNetworkConnectCommands(Service $service): array
{
if (! data_get($service, 'connect_to_docker_network')) {
@@ -0,0 +1,100 @@
<?php
namespace App\Actions\Shared;
use App\Models\LocalFileVolume;
use App\Models\Server;
use Closure;
use Lorisleiva\Actions\Concerns\AsAction;
/**
* `docker compose up` creates a missing bind source as an empty directory. The queued
* ServerStorageSaveJob writes new content files, but it can run after the containers start. This
* action writes each missing content file before `docker compose up`.
*
* It checks all files with one server command. It writes a file only when the path is missing or
* is an empty directory. It does not change a file that exists, and it does not delete a directory
* that is not empty.
*/
class EnsureContentFilesOnServer
{
use AsAction;
/**
* @param iterable<LocalFileVolume> $fileStorages
* @param Closure(string, string): void $log Gets a log line and its type (`stdout` or `stderr`). Log lines never contain file content.
* @return int The number of files that Coolify wrote
*/
public function handle(iterable $fileStorages, Server $server, Closure $log): int
{
$candidates = [];
foreach ($fileStorages as $fileStorage) {
if (! $fileStorage->hasContentToWrite()) {
continue;
}
try {
$candidates[] = [$fileStorage, $fileStorage->contentPathOnServer()];
} catch (\Throwable $e) {
$log('Warning: '.$this->plainText($e->getMessage()), 'stderr');
}
}
if ($candidates === []) {
return 0;
}
$states = LocalFileVolume::remoteFileStates(array_column($candidates, 1), $server);
$toWrite = [];
foreach ($candidates as $index => [$fileStorage, $path]) {
match ($states[$index]) {
'missing', 'empty-directory' => $toWrite[] = [$fileStorage, $path],
'file' => null,
'directory' => $log("Warning: A directory that is not empty is at {$path} on the server. Coolify did not write the configuration file for {$fileStorage->mount_path}. Remove the directory on the server, then start the resource again.", 'stderr'),
'other' => $log("Warning: A symbolic link or a special file is at {$path} on the server. Coolify did not write the configuration file for {$fileStorage->mount_path}.", 'stderr'),
default => $log("Warning: Coolify cannot check the configuration file {$path} on the server.", 'stderr'),
};
}
if ($toWrite === []) {
return 0;
}
$count = count($toWrite);
$log($count === 1 ? 'Writing 1 missing configuration file.' : "Writing {$count} missing configuration files.", 'stdout');
$written = 0;
foreach ($toWrite as [$fileStorage, $path]) {
try {
$fileStorage->saveStorageOnServer();
$written++;
} catch (\Throwable $e) {
$log("Warning: Coolify cannot write the configuration file {$path}: ".$this->plainText($e->getMessage()), 'stderr');
}
}
return $written;
}
/**
* Runs the action and returns its log lines as `echo` commands, for a command list that runs
* later in an activity log.
*
* @param iterable<LocalFileVolume> $fileStorages
* @return list<string>
*/
public static function echoCommands(iterable $fileStorages, Server $server): array
{
$commands = [];
static::run($fileStorages, $server, function (string $message) use (&$commands): void {
$commands[] = 'echo '.escapeshellarg($message);
});
return $commands;
}
private function plainText(string $message): string
{
return trim(preg_replace('/\s+/', ' ', strip_tags(str_replace('<br>', ' ', $message))));
}
}
+52 -18
View File
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Actions\Docker\GetContainersStatus;
use App\Actions\Shared\EnsureContentFilesOnServer;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\ProcessStatus;
use App\Enums\StaticImageTypes;
@@ -869,7 +870,39 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
]);
}
// Start compose file
$this->start_docker_compose_services();
$this->application_deployment_queue->addLogEntry('New container started.');
}
private function pull_docker_compose_images(): void
{
$this->application_deployment_queue->addLogEntry('Pulling image-based services before stopping the current deployment.');
if ($this->use_build_server) {
$this->write_deployment_configurations();
$this->server = $this->mainServer;
$workdir = $this->application->workdir();
$command = "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable";
} else {
$workdir = $this->workdir;
$command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable");
}
$this->execute_remote_command([
$command,
'hidden' => true,
]);
}
/**
* Starts the Compose containers. Content files are written first, because `docker compose up`
* creates a missing bind source as an empty directory.
*/
private function start_docker_compose_services(): void
{
$this->write_missing_content_files();
$server_workdir = $this->application->workdir();
if ($this->application->settings->is_raw_compose_deployment_enabled) {
if ($this->docker_compose_custom_start_command) {
@@ -948,28 +981,29 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
}
$this->application_deployment_queue->addLogEntry('New container started.');
}
private function pull_docker_compose_images(): void
/**
* The queued ServerStorageSaveJob writes new content files, but it can run after the containers
* start. Preserve-repository deployments write all file storages in write_deployment_configurations().
* Coolify writes file storages only on the main server of the application.
*/
private function write_missing_content_files(): void
{
$this->application_deployment_queue->addLogEntry('Pulling image-based services before stopping the current deployment.');
if ($this->use_build_server) {
$this->write_deployment_configurations();
$this->server = $this->mainServer;
$workdir = $this->application->workdir();
$command = "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable";
} else {
$workdir = $this->workdir;
$command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable");
if ($this->preserveRepository) {
return;
}
if ((int) data_get($this->application, 'destination.server_id') !== (int) $this->mainServer->id) {
return;
}
$this->execute_remote_command([
$command,
'hidden' => true,
]);
EnsureContentFilesOnServer::run(
$this->application->fileStorages()->get(),
$this->mainServer,
function (string $message, string $type): void {
$this->application_deployment_queue->addLogEntry($message, $type);
},
);
}
private function deploy_dockerfile_buildpack()
+133 -18
View File
@@ -43,6 +43,33 @@ class LocalFileVolume extends BaseModel
case $target in "$base"|"$base"/*) echo OK ;; *) echo NOK ;; esac
SH;
/**
* Prints `<number>:<state>` for each path argument, in order. It only reads. A state is `file`
* (also a symlink to a file), `missing`, `empty-directory`, `directory` (not empty) or `other`
* (another symlink or a special file). A directory that it cannot read is `directory`.
*/
private const REMOTE_FILE_STATE_SCRIPT = <<<'SH'
i=0
for path in "$@"; do
i=$((i + 1))
if [ -L "$path" ]; then
if [ -f "$path" ]; then state=file; else state=other; fi
elif [ -f "$path" ]; then
state=file
elif [ -d "$path" ]; then
state=directory
if entries=$(ls -A "$path" 2>/dev/null); then
if [ -z "$entries" ]; then state=empty-directory; fi
fi
elif [ -e "$path" ]; then
state=other
else
state=missing
fi
printf "%s:%s\n" "$i" "$state"
done
SH;
protected $casts = [
// 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted',
@@ -311,6 +338,7 @@ class LocalFileVolume extends BaseModel
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
$replacesEmptyDirectory = false;
if ($isFile === 'OK' && $this->is_directory) {
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
@@ -327,13 +355,17 @@ class LocalFileVolume extends BaseModel
$this->save();
throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file. <br><br>Please delete the directory on the server or mark it as directory.');
}
instant_remote_process([
"rm -fr {$escapedPath}",
"touch {$escapedPath}",
], $server, false);
FileStorageChanged::dispatch(data_get($server, 'team_id'));
// Docker creates a missing bind source as an empty directory. Replace only an empty
// directory; never delete files that are on the server.
if (self::remoteFileStates([(string) $path], $server)[0] !== 'empty-directory') {
throw new \Exception("The following file is a directory on the server, but you are trying to mark it as a file: {$path}<br><br>Please delete the directory on the server or mark it as directory.");
}
$replacesEmptyDirectory = true;
}
if ($isDir === 'NOK' && ! $this->is_directory) {
if (($isDir === 'NOK' || $replacesEmptyDirectory) && ! $this->is_directory) {
if ($replacesEmptyDirectory) {
$commands->push("rmdir {$escapedPath}");
}
$chmod = data_get($this, 'chmod');
$chown = data_get($this, 'chown');
if ($content) {
@@ -353,7 +385,72 @@ class LocalFileVolume extends BaseModel
$commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
}
return instant_remote_process($commands, $server);
$result = instant_remote_process($commands, $server);
if ($replacesEmptyDirectory) {
FileStorageChanged::dispatch(data_get($server, 'team_id'));
}
return $result;
}
/**
* One `sh -c` line with the paths as arguments, so the non-root sudo parser only puts sudo in
* front of it and never changes the script. See REMOTE_FILE_STATE_SCRIPT for the output.
*
* @param list<string> $paths
*/
public static function remoteFileStateCommand(array $paths): string
{
return 'sh -c '.escapeshellarg(self::REMOTE_FILE_STATE_SCRIPT).' sh '.implode(' ', array_map('escapeshellarg', $paths));
}
/**
* Gets the state of each path with one server command. A path without a state in the output is `unknown`.
*
* @param list<string> $paths
* @return list<string>
*/
public static function remoteFileStates(array $paths, Server $server): array
{
$states = [];
$output = (string) instant_remote_process([self::remoteFileStateCommand($paths)], $server, false);
foreach (preg_split('/\R/', trim($output)) as $line) {
if (preg_match('/^(\d+):([a-z-]+)$/', trim($line), $matches)) {
$states[(int) $matches[1] - 1] = $matches[2];
}
}
return array_map(fn (int $index) => $states[$index] ?? 'unknown', array_keys($paths));
}
/**
* The absolute path where Coolify writes the content of this file on the server. This check is
* only on the Coolify side; saveStorageOnServer() checks the path on the server again.
*
* @throws \RuntimeException If the path is not inside the resource directory
*/
public function contentPathOnServer(): string
{
$path = (string) $this->fs_path;
if (str_starts_with($path, '.')) {
$path = $this->ownerResource()->workdir().substr($path, 1);
}
return $this->localConfinedContentPath($path)[1];
}
/**
* Coolify writes this file from its content when the file is missing on the server. Files from
* the Git repository, host files and placeholders for binary or large files are not written.
*/
public function hasContentToWrite(): bool
{
return ! $this->is_directory
&& ! $this->is_host_file
&& ! $this->is_based_on_git
&& ! $this->is_binary
&& ! $this->is_too_large
&& (string) $this->content !== '';
}
/**
@@ -394,10 +491,27 @@ class LocalFileVolume extends BaseModel
*/
public function confinedContentPath(string $path, Server $server): string
{
$error = new \RuntimeException(
"Coolify writes file content only inside the resource directory. The path {$path} is outside of it. Use a relative source such as ./config/app.conf."
);
[$baseDirectory, $confinedPath] = $this->localConfinedContentPath($path);
try {
self::assertRemotePathIsConfined($baseDirectory, $confinedPath, $server);
} catch (\RuntimeException) {
throw $this->contentOutsideResourceDirectoryException($path);
}
return $confinedPath;
}
/**
* The first resource base directory that contains the path (not the directory itself), and the
* normalized path. This check is only on the Coolify side.
*
* @return array{0: string, 1: string}
*
* @throws \RuntimeException If the path is not inside the resource directory
*/
protected function localConfinedContentPath(string $path): array
{
foreach ($this->contentBaseDirectories() as $baseDirectory) {
try {
$confinedPath = confinePathToBase($baseDirectory, $path, 'storage path');
@@ -408,16 +522,17 @@ class LocalFileVolume extends BaseModel
continue;
}
try {
self::assertRemotePathIsConfined($baseDirectory, $confinedPath, $server);
} catch (\RuntimeException) {
throw $error;
}
return $confinedPath;
return [$baseDirectory, $confinedPath];
}
throw $error;
throw $this->contentOutsideResourceDirectoryException($path);
}
protected function contentOutsideResourceDirectoryException(string $path): \RuntimeException
{
return new \RuntimeException(
"Coolify writes file content only inside the resource directory. The path {$path} is outside of it. Use a relative source such as ./config/app.conf."
);
}
/**
+8
View File
@@ -26,6 +26,7 @@ function service_logo_urls(mixed $logo): array
}
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@@ -224,6 +225,13 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
if ($fileVolume->is_based_on_git) {
$fileVolume->loadStorageOnServer();
}
} elseif ($isDir === 'OK' && ! $fileVolume->is_directory && filled($content)) {
// A configured file must not lose its content because a directory is at its path.
// Docker leaves an empty directory when it starts before the file exists: replace it.
// A directory with files stays; the start or deployment shows a warning for it.
if (LocalFileVolume::remoteFileStates([(string) $fileLocation], $server)[0] === 'empty-directory') {
$fileVolume->saveStorageOnServer();
}
} elseif ($isDir === 'OK') {
// If its a directory & exists
$fileVolume->content = null;
@@ -0,0 +1,548 @@
<?php
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\StartService;
use App\Actions\Shared\EnsureContentFilesOnServer;
use App\Jobs\ApplicationDeploymentJob;
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 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($process->command) ? implode(' ', $process->command) : $process->command;
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' : '');
});
}
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($process->command, 'base64 -d'));
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rmdir'));
Process::assertDidntRun(fn ($process) => str_contains($process->command, '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,
'mainServer' => $test->server,
'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(1)
->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($process->command, 'tee '.escapeshellarg($second->fs_path)));
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'tee '.escapeshellarg($first->fs_path)));
Process::assertDidntRun(fn ($process) => str_contains($process->command, '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($process->command, '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-pr-7', ['is_preview_suffix_enabled' => true]);
fakeContentFilesServer([$volume->fs_path => 'missing']);
runComposeStart($this, ['pull_request_id' => 7]);
$write = ContentFilesUpRecorder::indexOf('ssh', 'tee '.escapeshellarg($this->application->workdir().'/config/app.conf-pr-7'));
expect($write)->not->toBeNull()
->and($write)->toBeLessThan(ContentFilesUpRecorder::indexOf('deployment', ' up -d'));
});
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 another server does not check the content files of the main 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,
]);
fakeContentFilesServer([$volume->fs_path => 'missing']);
runComposeStart($this, ['server' => $otherServer, 'mainServer' => $otherServer]);
expect(contentFilesStateChecks())->toBe([]);
contentFilesAssertNoWrite();
});
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($process->command, "sudo rmdir {$escapedPath}")
&& str_contains($process->command, "| 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 = $activity->getExtraProperty('command');
expect(contentFilesStateChecks())->toHaveCount(1)
->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($process->command, 'base64 -d | tee '.escapeshellarg($appConf->fs_path))
&& str_contains($process->command, base64_encode('listen 8080;')));
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'tee '.escapeshellarg($workerConf->fs_path)));
Process::assertDidntRun(fn ($process) => str_contains($process->command, '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 = $activity->getExtraProperty('command');
expect(contentFilesStateChecks())->toHaveCount(1)
->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($process->command, 'tee '.escapeshellarg($appConf->fs_path)));
Process::assertDidntRun(fn ($process) => str_contains($process->command, '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($activity->getExtraProperty('command'))->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($process->command, 'rm -fr'));
Process::assertDidntRun(fn ($process) => str_contains($process->command, '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($process->command, "rmdir {$escapedPath}")
&& str_contains($process->command, base64_encode(CONTENT_FILES_UP_CONTENT))
&& strpos($process->command, "rmdir {$escapedPath}") < strpos($process->command, "tee {$escapedPath}"));
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -fr'));
Process::assertDidntRun(fn ($process) => str_contains($process->command, "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 Compose deployment writes content files after it removes the old containers', function () {
$source = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
$methodStart = strpos($source, 'private function deploy_docker_compose_buildpack()');
$methodEnd = strpos($source, 'private function pull_docker_compose_images()', $methodStart);
$deploymentMethod = substr($source, $methodStart, $methodEnd - $methodStart);
$stopPosition = strpos($deploymentMethod, '$this->stop_running_container(force: true);');
$startPosition = strpos($deploymentMethod, '$this->start_docker_compose_services();');
expect($stopPosition)->not->toBeFalse()
->and($startPosition)->not->toBeFalse()
->and($stopPosition)->toBeLessThan($startPosition)
->and(substr_count($deploymentMethod, ' up -d'))->toBe(0);
});
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($process->command, 'rmdir'));
Process::assertRan(fn ($process) => str_contains($process->command, 'base64 -d'));
});
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();
});
+153
View File
@@ -0,0 +1,153 @@
<?php
use App\Models\LocalFileVolume;
use App\Models\Server;
use Symfony\Component\Process\Process;
/**
* Creates this tree in a temporary directory:
* base/existing.conf, base/empty-dir/, base/full-dir/keep.conf, base/with space.conf
* base/file-link -> base/existing.conf
* base/dir-link -> base/empty-dir
* base/dangling -> base/missing-target.conf
*/
function makeRemoteFileStateTree(): string
{
$root = sys_get_temp_dir().'/coolify-file-state-'.bin2hex(random_bytes(4));
mkdir($root.'/base/empty-dir', 0755, true);
mkdir($root.'/base/full-dir');
file_put_contents($root.'/base/existing.conf', 'inside');
file_put_contents($root.'/base/with space.conf', 'inside');
file_put_contents($root.'/base/full-dir/keep.conf', 'keep');
symlink($root.'/base/existing.conf', $root.'/base/file-link');
symlink($root.'/base/empty-dir', $root.'/base/dir-link');
symlink($root.'/base/missing-target.conf', $root.'/base/dangling');
// bin-busybox has only BusyBox tools (like Alpine); both bin directories have a sudo that runs its arguments.
mkdir($root.'/bin-gnu');
mkdir($root.'/bin-busybox');
foreach (['bin-gnu', 'bin-busybox'] as $binaries) {
file_put_contents("{$root}/{$binaries}/sudo", "#!/bin/sh\nexec \"\$@\"\n");
chmod("{$root}/{$binaries}/sudo", 0755);
}
symlink('/bin/bash', $root.'/bin-busybox/bash');
foreach (['sh', 'ls'] as $applet) {
symlink('/usr/bin/busybox', "{$root}/bin-busybox/{$applet}");
}
return $root;
}
/**
* Runs the state command like a server would: with GNU or BusyBox tools, as root or through the
* non-root sudo parser.
*
* @param list<string> $paths
*/
function runRemoteFileStateCommand(string $toolset, string $root, array $paths): Process
{
$command = LocalFileVolume::remoteFileStateCommand(array_map(fn (string $path) => $root.'/'.$path, $paths));
[$usesBusyBox, $isNonRoot] = match ($toolset) {
'GNU as root' => [false, false],
'BusyBox as root' => [true, false],
'GNU as non-root' => [false, true],
'BusyBox as non-root' => [true, true],
};
if ($isNonRoot) {
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
$server->shouldReceive('setAttribute')->andReturnSelf();
$command = parseCommandsByLineForSudo(collect([$command]), $server)[0];
}
$shell = $usesBusyBox && ! $isNonRoot ? ['/usr/bin/busybox', 'sh'] : ['/bin/bash'];
$process = new Process([...$shell, '-c', $command], env: [
'PATH' => $usesBusyBox ? $root.'/bin-busybox' : $root.'/bin-gnu:'.getenv('PATH'),
]);
$process->run();
return $process;
}
afterEach(function () {
if (isset($this->fileStateRoot)) {
(new Process(['rm', '-rf', $this->fileStateRoot]))->run();
}
Mockery::close();
});
test('the file state check is one shell command for root and non-root servers', function () {
$command = LocalFileVolume::remoteFileStateCommand(['/data/coolify/services/abc/a.conf', '/data/coolify/services/abc/b.conf']);
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
$server->shouldReceive('setAttribute')->andReturnSelf();
$parsed = parseCommandsByLineForSudo(collect([$command]), $server);
expect($command)->toStartWith('sh -c ')
->toEndWith(" sh '/data/coolify/services/abc/a.conf' '/data/coolify/services/abc/b.conf'")
->and($parsed)->toHaveCount(1)
->and($parsed[0])->toStartWith("sudo bash -c 'sh -c ");
});
test('the file state check reports the state of every path in order', function (string $toolset) {
if (str_starts_with($toolset, 'BusyBox') && ! is_executable('/usr/bin/busybox')) {
$this->markTestSkipped('BusyBox is not installed.');
}
$this->fileStateRoot = makeRemoteFileStateTree();
$process = runRemoteFileStateCommand($toolset, $this->fileStateRoot, [
'base/existing.conf',
'base/missing.conf',
'base/empty-dir',
'base/full-dir',
'base/file-link',
'base/dir-link',
'base/dangling',
'base/with space.conf',
'base/missing-dir/app.conf',
]);
expect($process->getErrorOutput())->toBe('')
->and(explode("\n", trim($process->getOutput())))->toBe([
'1:file',
'2:missing',
'3:empty-directory',
'4:directory',
'5:file',
'6:other',
'7:other',
'8:file',
'9:missing',
]);
})->with([
'GNU as root',
'BusyBox as root',
'GNU as non-root',
'BusyBox as non-root',
]);
test('the file state check does not change the server', function () {
$this->fileStateRoot = makeRemoteFileStateTree();
runRemoteFileStateCommand('GNU as non-root', $this->fileStateRoot, ['base/empty-dir', 'base/full-dir', 'base/missing.conf']);
expect(is_dir($this->fileStateRoot.'/base/empty-dir'))->toBeTrue()
->and(file_exists($this->fileStateRoot.'/base/full-dir/keep.conf'))->toBeTrue()
->and(file_exists($this->fileStateRoot.'/base/missing.conf'))->toBeFalse();
});
test('the file state check never runs a path as a command', function () {
$this->fileStateRoot = makeRemoteFileStateTree();
$marker = $this->fileStateRoot.'/pwned';
runRemoteFileStateCommand('GNU as non-root', $this->fileStateRoot, [
"base/x'; touch {$marker}; '",
"base/\$(touch {$marker})",
"base/`touch {$marker}`",
]);
expect(file_exists($marker))->toBeFalse();
});