Files
coolify/tests/Feature/ComposeContentVolumeConfinementTest.php
Andras Bacsai 8d083deffb fix(storage): stop syncing file storages on every compose parse
The compose parser queued ServerFilesFromServerJob for every volume on
each parse, and pages that only show data parse the Compose file. Each
sync also sent two SSH commands for each file.

- Queue the sync only for a compose deployment, and for each service
  resource with file storages when the service starts or deploys.
- Read the compose service names without a parse on the Domains and
  scheduled task pages.
- Check all file storages with one remote command, and stop when a
  symbolic link, a special file, or an unknown state is at a file path.
- Keep the application path in a file storage when a pull request
  preview is parsed. Preview deployments of the current parser write
  their `-pr-<id>` copies through LocalFileVolume::forPullRequest().
- Remove the unreachable file sync from the service resource page.
2026-10-27 12:04:37 +01:00

442 lines
18 KiB
PHP

<?php
use App\Livewire\Project\Application\General;
use App\Livewire\Project\Service\StackForm;
use App\Models\Application;
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\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Livewire\Livewire;
/**
* Only administrators can edit a Compose file, and they can mount any host path. Coolify writes the
* `content:` of a Compose bind volume to the source path, like Coolify v4.3.23 did, also outside
* the resource directory. Every path in a remote command is shell-escaped, and the source must not
* contain shell metacharacters.
*/
uses(RefreshDatabase::class);
const CONTENT_HOST_PATH_SAFE_COMPOSE = "services:\n app:\n image: nginx:alpine\n";
const CONTENT_HOST_PATH_COMPOSE = <<<'YAML'
services:
app:
image: nginx:alpine
volumes:
- type: bind
source: /etc/myapp/app.conf
target: /etc/a.conf
content: a
- type: bind
source: ~/x/app.conf
target: /etc/b.conf
content: b
- type: bind
source: app.conf
target: /etc/c.conf
content: c
- type: bind
source: ./../shared/app.conf
target: /etc/d.conf
content: d
YAML;
const CONTENT_HOST_PATH_INJECTION_COMPOSE = <<<'YAML'
services:
app:
image: nginx:alpine
volumes:
- type: bind
source: /etc/$(id)/app.conf
target: /etc/app.conf
content: x
YAML;
beforeEach(function () {
Server::flushIdentityMap();
Bus::fake();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
config([
'app.maintenance.store' => 'array',
'constants.ssh.mux_enabled' => false,
]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$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]);
});
afterEach(function () {
Server::flushIdentityMap();
});
function contentHostPathService(string $compose): Service
{
return Service::factory()->create([
'name' => 'content-host-path',
'environment_id' => test()->environment->id,
'server_id' => test()->server->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
'docker_compose_raw' => $compose,
'connect_to_docker_network' => false,
]);
}
function contentHostPathApplication(string $compose): Application
{
return Application::factory()->create([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
'build_pack' => 'dockercompose',
'git_repository' => 'https://github.com/coollabsio/compose-app',
'git_branch' => 'main',
'base_directory' => '/',
'docker_compose_location' => '/docker-compose.yml',
'docker_compose_raw' => $compose,
]);
}
function contentHostPathUseServerUser(string $user): void
{
test()->server->update(['user' => $user]);
Server::flushIdentityMap();
}
/**
* Fakes the server: every checked path is missing, the symlink check prints OK.
*/
function contentHostPathFakeServer(): void
{
Process::fake(fn ($process) => Process::result(output: match (true) {
str_contains(remoteProcessScript($process), 'empty-directory') => remoteFileStatesOutput('missing'),
str_contains(remoteProcessScript($process), 'readlink -f') => 'OK',
str_contains(remoteProcessScript($process), 'test -') => 'NOK',
default => '',
}));
}
/**
* The stored fs_path and the host path that Coolify writes, for a source in CONTENT_HOST_PATH_COMPOSE.
* `~` and `./` are relative to the resource directory, as in v4.3.23. A bare relative path is
* stored as written and written inside the resource directory (never relative to the SSH user's
* working directory).
*
* @return array{0: string, 1: string}
*/
function contentHostPathExpected(string $target, string $workdir): array
{
$parent = dirname($workdir);
return match ($target) {
'/etc/a.conf' => ['/etc/myapp/app.conf', '/etc/myapp/app.conf'],
'/etc/b.conf' => [$workdir.'/x/app.conf', $workdir.'/x/app.conf'],
'/etc/c.conf' => ['app.conf', $workdir.'/app.conf'],
'/etc/d.conf' => [$parent.'/shared/app.conf', $parent.'/shared/app.conf'],
};
}
function contentHostPathAssertWritten(string $path, string $content, string $user): void
{
$sudo = $user === 'root' ? '' : 'sudo ';
$escapedPath = escapeshellarg($path);
$escapedParent = escapeshellarg(dirname($path));
$base64 = base64_encode($content);
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}mkdir -p {$escapedParent}"));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee {$escapedPath} > /dev/null"));
}
/**
* @return array<string, array{string, string}>
*/
function contentHostPathTargets(): array
{
return [
'absolute host file' => ['/etc/a.conf', 'a'],
'home directory' => ['/etc/b.conf', 'b'],
'bare relative path' => ['/etc/c.conf', 'c'],
'parent directory' => ['/etc/d.conf', 'd'],
];
}
test('the service parser stores a content volume at its host path and Coolify writes it there', function (string $target, string $content, string $user) {
contentHostPathUseServerUser($user);
$service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE);
serviceParser($service);
[$fsPath, $writePath] = contentHostPathExpected($target, $service->workdir());
$fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole();
expect($fileVolume->fs_path)->toBe($fsPath)
->and($fileVolume->content)->toBe($content)
->and($fileVolume->is_directory)->toBeFalse()
->and($fileVolume->contentPathOnServer())->toBe($writePath);
contentHostPathFakeServer();
$fileVolume->saveStorageOnServer();
contentHostPathAssertWritten($writePath, $content, $user);
})->with(contentHostPathTargets())->with(['root', 'coolify']);
test('the application parser stores a content volume at its host path and Coolify writes it there', function (string $target, string $content, string $user) {
contentHostPathUseServerUser($user);
$application = contentHostPathApplication(CONTENT_HOST_PATH_COMPOSE);
applicationParser($application);
[$fsPath, $writePath] = contentHostPathExpected($target, $application->workdir());
$fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole();
expect($fileVolume->fs_path)->toBe($fsPath)
->and($fileVolume->content)->toBe($content)
->and($fileVolume->contentPathOnServer())->toBe($writePath);
contentHostPathFakeServer();
$fileVolume->saveStorageOnServer();
contentHostPathAssertWritten($writePath, $content, $user);
})->with(contentHostPathTargets())->with(['root', 'coolify']);
test('loading files for a service writes content inside the resource directory', function (string $target, string $content, string $user) {
contentHostPathUseServerUser($user);
$service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE);
serviceParser($service);
$fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole();
[, $writePath] = contentHostPathExpected($target, $service->workdir());
contentHostPathFakeServer();
getFilesystemVolumesFromServer($fileVolume->resource, true);
$sudo = $user === 'root' ? '' : 'sudo ';
$escapedPath = escapeshellarg($writePath);
$base64 = base64_encode($content);
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}mkdir -p -- \"$({$sudo}dirname -- {$escapedPath})\""));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee -- {$escapedPath}"));
})->with([
'home directory' => ['/etc/b.conf', 'b'],
'bare relative path' => ['/etc/c.conf', 'c'],
])->with(['root', 'coolify']);
// A token with only the write permission can save a Compose file. Content outside the resource
// directory is written when the resource is deployed or started, not when its files are loaded.
test('loading files for a service does not write content outside the resource directory', function (string $target, string $content) {
$service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE);
serviceParser($service);
$fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole();
contentHostPathFakeServer();
getFilesystemVolumesFromServer($fileVolume->resource, true);
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), base64_encode($content)));
})->with([
'absolute host file' => ['/etc/a.conf', 'a'],
'parent directory' => ['/etc/d.conf', 'd'],
]);
test('a quote in a content path is shell-escaped in every write command', function (string $user) {
contentHostPathUseServerUser($user);
$path = "/etc/my'app/app.conf";
$compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: \"{$path}\"\n target: /etc/app.conf\n content: x\n";
$service = contentHostPathService($compose);
serviceParser($service);
$fileVolume = LocalFileVolume::query()->sole();
expect($fileVolume->fs_path)->toBe($path);
contentHostPathFakeServer();
$fileVolume->saveStorageOnServer();
$sudo = $user === 'root' ? '' : 'sudo ';
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}tee '/etc/my'\\''app/app.conf' > /dev/null"));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}mkdir -p '/etc/my'\\''app'"));
})->with(['root', 'coolify']);
test('the parsers reject shell injection in a content volume source', function () {
expect(fn () => serviceParser(contentHostPathService(CONTENT_HOST_PATH_INJECTION_COMPOSE)))
->toThrow(Exception::class, 'Invalid Docker volume definition (array syntax)')
->and(fn () => applicationParser(contentHostPathApplication(CONTENT_HOST_PATH_INJECTION_COMPOSE)))
->toThrow(Exception::class, 'Invalid Docker volume definition (array syntax)');
expect(LocalFileVolume::query()->count())->toBe(0);
});
test('a bind mount without content keeps its administrator-selected host path', function () {
$compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - /srv/shared:/keys\n";
$service = contentHostPathService($compose);
$serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]);
$fileVolume = LocalFileVolume::create([
'fs_path' => '/srv/shared',
'mount_path' => '/keys',
'is_directory' => true,
'resource_id' => $serviceApplication->id,
'resource_type' => $serviceApplication->getMorphClass(),
])->fresh();
contentHostPathFakeServer();
$fileVolume->saveStorageOnServer();
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p '/srv/shared'"));
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'readlink -f'));
});
test('the service stack form saves content volumes with host paths', function () {
$this->actingAs($this->user);
$service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE);
contentHostPathFakeServer();
Livewire::test(StackForm::class, ['service' => $service])
->set('dockerComposeRaw', CONTENT_HOST_PATH_COMPOSE)
->call('submit')
->assertNotDispatched('error');
expect($service->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf')
->and(LocalFileVolume::query()->where('fs_path', '/etc/myapp/app.conf')->value('content'))->toBe('a');
});
test('the service stack form rejects shell injection in a content volume source', function () {
$this->actingAs($this->user);
$service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE);
Livewire::test(StackForm::class, ['service' => $service])
->set('dockerComposeRaw', CONTENT_HOST_PATH_INJECTION_COMPOSE)
->call('submit')
->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'Invalid Docker volume definition'));
expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE)
->and(LocalFileVolume::query()->count())->toBe(0);
});
test('the application General form saves content volumes with host paths', function () {
$this->actingAs($this->user);
$application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE);
contentHostPathFakeGitCompose(CONTENT_HOST_PATH_COMPOSE);
Livewire::test(General::class, ['application' => $application->fresh()])
->set('dockerComposeRaw', CONTENT_HOST_PATH_COMPOSE)
->call('submit')
->assertNotDispatched('error');
expect($application->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf')
->and(LocalFileVolume::query()->where('fs_path', '/etc/myapp/app.conf')->value('content'))->toBe('a');
});
test('the application General form rejects shell injection in a content volume source', function () {
$this->actingAs($this->user);
$application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE);
Process::fake();
Livewire::test(General::class, ['application' => $application->fresh()])
->set('dockerComposeRaw', CONTENT_HOST_PATH_INJECTION_COMPOSE)
->call('submit')
->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'Invalid Docker volume definition'))
->assertNotDispatched('success');
expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE)
->and(LocalFileVolume::query()->count())->toBe(0);
});
function contentHostPathFakeGitCompose(string $compose): void
{
Process::fake(function ($process) use ($compose) {
$command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process);
return Process::result(output: match (true) {
str_contains($command, 'git cat-file blob') => $compose,
default => '',
});
});
}
test('loading a Git Compose file accepts content volumes with host paths', function () {
$application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE);
contentHostPathFakeGitCompose(CONTENT_HOST_PATH_COMPOSE);
$application->loadComposeFile();
expect($application->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf')
->and(LocalFileVolume::query()->orderBy('mount_path')->pluck('fs_path')->all())->toBe([
'/etc/myapp/app.conf',
$application->workdir().'/x/app.conf',
'app.conf',
dirname($application->workdir()).'/shared/app.conf',
]);
});
test('loading a Git Compose file rejects shell injection in a content volume source', function () {
$application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE);
contentHostPathFakeGitCompose(CONTENT_HOST_PATH_INJECTION_COMPOSE);
expect(fn () => $application->loadComposeFile())
->toThrow(Exception::class, 'Invalid Docker volume definition');
expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE)
->and(LocalFileVolume::query()->count())->toBe(0);
});
function contentHostPathPatchService(Service $service, string $compose): TestResponse
{
$plainTextToken = Str::random(40);
$token = test()->user->tokens()->create([
'name' => 'content-host-path',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => test()->team->id,
]);
return test()->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken])
->patchJson("/api/v1/services/{$service->uuid}", ['docker_compose_raw' => base64_encode($compose)]);
}
test('the service API saves content volumes with host paths', function () {
$service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE);
contentHostPathFakeServer();
contentHostPathPatchService($service, CONTENT_HOST_PATH_COMPOSE)->assertSuccessful();
expect($service->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf')
->and(LocalFileVolume::query()->where('fs_path', '/etc/myapp/app.conf')->value('content'))->toBe('a');
});
test('the service API rejects shell injection in a content volume source', function () {
$service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE);
contentHostPathPatchService($service, CONTENT_HOST_PATH_INJECTION_COMPOSE)
->assertStatus(422)
->assertJsonPath('errors.docker_compose_raw', fn (string $error): bool => str_contains($error, 'Invalid Docker volume definition'));
expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE)
->and(LocalFileVolume::query()->count())->toBe(0);
});
test('loading files for a service does not create directories outside the resource directory', function () {
$compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: /etc/cron.d/coolify-test\n target: /data\n";
$service = contentHostPathService($compose);
serviceParser($service);
$fileVolume = LocalFileVolume::query()->sole();
contentHostPathFakeServer();
getFilesystemVolumesFromServer($fileVolume->resource, true);
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), '/etc/cron.d/coolify-test') && str_contains(remoteProcessScript($process), 'mkdir'));
});