diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php index 9181c537d6..2a57b093de 100644 --- a/app/Actions/Service/DeployServiceApplication.php +++ b/app/Actions/Service/DeployServiceApplication.php @@ -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) { diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index fc5dc88bb8..09b63ceeb2 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -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 + */ + 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')) { diff --git a/app/Actions/Shared/EnsureContentFilesOnServer.php b/app/Actions/Shared/EnsureContentFilesOnServer.php new file mode 100644 index 0000000000..847665f1df --- /dev/null +++ b/app/Actions/Shared/EnsureContentFilesOnServer.php @@ -0,0 +1,100 @@ + $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 $fileStorages + * @return list + */ + 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('
', ' ', $message)))); + } +} diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 6650df00f9..4678c7b214 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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() diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 7021b24af4..86d9508d80 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -43,6 +43,33 @@ class LocalFileVolume extends BaseModel case $target in "$base"|"$base"/*) echo OK ;; *) echo NOK ;; esac SH; + /** + * Prints `:` 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.

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}

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 $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 $paths + * @return list + */ + 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." + ); } /** diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 4273115ed5..cce4be5742 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -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; diff --git a/tests/Feature/ContentFilesBeforeComposeUpTest.php b/tests/Feature/ContentFilesBeforeComposeUpTest.php new file mode 100644 index 0000000000..bd76d94531 --- /dev/null +++ b/tests/Feature/ContentFilesBeforeComposeUpTest.php @@ -0,0 +1,548 @@ + */ + public static array $commands = []; + + public static function indexOf(string $channel, string $needle): ?int + { + foreach (self::$commands as $index => [$recordedChannel, $command]) { + if ($recordedChannel === $channel && str_contains($command, $needle)) { + return $index; + } + } + + return null; + } + + /** @return list */ + public static function ssh(): array + { + return collect(self::$commands)->where(0, 'ssh')->pluck(1)->values()->all(); + } +} + +/** + * Records the deployment commands instead of running them. + */ +class ContentFilesUpDeploymentJob extends ApplicationDeploymentJob +{ + public function __construct() {} + + public function execute_remote_command(...$commands): void + { + foreach ($commands as $command) { + ContentFilesUpRecorder::$commands[] = ['deployment', $command['command'] ?? $command[0]]; + } + } +} + +/** + * Fakes the server file system. $states maps a path to `file`, `missing`, `empty-directory`, + * `directory` or `other`. The symlink check always passes. + * + * @param array $states + */ +function fakeContentFilesServer(array $states): void +{ + ContentFilesUpRecorder::$commands = []; + + Process::fake(function ($process) use ($states) { + $command = is_array($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 $attributes + */ +function contentFilesVolume(Application|Service $resource, string $relativePath, array $attributes = []): LocalFileVolume +{ + $owner = $resource instanceof Service ? $resource->applications()->firstOrFail() : $resource; + + return LocalFileVolume::create([ + 'fs_path' => $resource->workdir().'/'.$relativePath, + 'mount_path' => '/etc/'.basename($relativePath), + 'content' => CONTENT_FILES_UP_CONTENT, + 'is_directory' => false, + 'resource_id' => $owner->id, + 'resource_type' => $owner->getMorphClass(), + ...$attributes, + ])->fresh(); +} + +/** + * Runs the part of a Compose deployment that starts the containers. + * + * @param array $properties + */ +function runComposeStart(object $test, array $properties = []): ContentFilesUpDeploymentJob +{ + $job = new ContentFilesUpDeploymentJob; + $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message, string $type = 'stdout') use ($test) { + $test->logEntries[] = [$message, $type]; + }); + + foreach ([ + 'application' => $test->application->fresh(), + 'application_deployment_queue' => $queue, + 'server' => $test->server, + '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(); +}); diff --git a/tests/Unit/RemoteFileStateCommandTest.php b/tests/Unit/RemoteFileStateCommandTest.php new file mode 100644 index 0000000000..9690f0ca9c --- /dev/null +++ b/tests/Unit/RemoteFileStateCommandTest.php @@ -0,0 +1,153 @@ + 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 $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(); +});