mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(compose): read the Compose file from Git objects (#12320)
This commit is contained in:
+37
-35
@@ -2271,37 +2271,40 @@ class Application extends BaseModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands that check out only the Compose file on the server and print it. They run on the
|
||||
* server itself, not in a helper container, so the checkout uses an absolute folder in /tmp.
|
||||
* Commands that clone the repository on the server and print the Compose file. They run on the
|
||||
* server itself, not in a helper container, so the clone uses an absolute folder in /tmp.
|
||||
*
|
||||
* The file is read from the Git objects, not from a working tree, so no checkout is needed. Only
|
||||
* regular files are loaded; a symbolic link (mode 120000) gets a clear error message.
|
||||
*
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
private function composeFileReadCommands(string $uuid, string $gitVersion): Collection
|
||||
private function composeFileReadCommands(string $uuid): Collection
|
||||
{
|
||||
$checkoutDir = "/tmp/{$uuid}/checkout";
|
||||
['command' => $cloneCommand, 'git' => $git] = $this->serverCheckout($uuid, $checkoutDir);
|
||||
$workdir = rtrim($this->base_directory, '/');
|
||||
$fileList = collect([".{$workdir}{$this->docker_compose_location}"]);
|
||||
$composeFilePath = escapeshellarg(".{$workdir}{$this->docker_compose_location}");
|
||||
$composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1;
|
||||
$readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi";
|
||||
|
||||
$isConeMode = version_compare($gitVersion, '2.35.1', '>=');
|
||||
if (! $isConeMode) {
|
||||
$fileList = $fileList->map(function ($file) {
|
||||
$parts = explode('/', trim($file, '.'));
|
||||
$paths = collect();
|
||||
$currentPath = '';
|
||||
foreach ($parts as $part) {
|
||||
$currentPath .= ($currentPath ? '/' : '').$part;
|
||||
if (str($currentPath)->isNotEmpty()) {
|
||||
$paths->push($currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
return $paths;
|
||||
})->flatten()->unique()->values();
|
||||
}
|
||||
$composeFile = ltrim(rtrim($this->base_directory, '/').$this->docker_compose_location, '/');
|
||||
$submoduleCommands = $this->composeFileSubmoduleCommands($git, $composeFile);
|
||||
// Go into the submodule that contains the Compose file, and read the file from the objects of that submodule.
|
||||
$enterSubmodule = $submoduleCommands === [] ? '' : implode(' ', [
|
||||
'while sub=$(git ls-tree -r HEAD | while read -r mode type object path; do',
|
||||
'if [ "$mode" = 160000 ]; then case "$file" in "$path"/*) echo "$path"; break ;; esac; fi; done) && [ -n "$sub" ];',
|
||||
'do cd "$sub" || exit 1; file="${file#"$sub"/}"; done;',
|
||||
]);
|
||||
$readComposeFile = implode(' ', [
|
||||
'file='.escapeshellarg($composeFile).';',
|
||||
$enterSubmodule,
|
||||
'git ls-tree HEAD -- "$file" | {',
|
||||
'read -r mode type object path;',
|
||||
'case "$mode" in',
|
||||
'100644|100755) ;;',
|
||||
"120000) echo 'The Docker Compose file is a symbolic link.' >&2; exit 1 ;;",
|
||||
"*) echo 'No such file' >&2; exit 1 ;;",
|
||||
'esac;',
|
||||
'if [ "$(git cat-file -s "$object")" -gt '.self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__';",
|
||||
'else git cat-file blob "$object"; fi;',
|
||||
'}',
|
||||
]);
|
||||
|
||||
return collect([
|
||||
"rm -rf /tmp/{$uuid}",
|
||||
@@ -2310,17 +2313,14 @@ class Application extends BaseModel
|
||||
// One sh -c line: the non-root sudo parser would turn `&& if ...; then` into invalid `&& sudo if`.
|
||||
'sh -c '.escapeshellarg($cloneCommand),
|
||||
"cd {$checkoutDir}",
|
||||
$isConeMode ? 'git sparse-checkout init --cone' : 'git sparse-checkout init',
|
||||
"git sparse-checkout set {$fileList->implode(' ')}",
|
||||
'git read-tree -mu HEAD',
|
||||
...$this->composeFileSubmoduleCommands($git, ltrim("{$workdir}{$this->docker_compose_location}", '/')),
|
||||
$readComposeFile,
|
||||
...$submoduleCommands,
|
||||
'sh -c '.escapeshellarg($readComposeFile),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sparse checkout has no submodules. If the Compose file is inside a submodule, this clones only that
|
||||
* submodule, with the credentials of the clone.
|
||||
* The clone has no checkout and no submodules. If the Compose file is inside a submodule, this fills the
|
||||
* index (without a working tree) and clones only that submodule, with the credentials of the clone.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -2334,7 +2334,7 @@ class Application extends BaseModel
|
||||
$escapedComposeFile = escapeshellarg($composeFile);
|
||||
|
||||
// One sh -c line: the non-root sudo parser cannot change commands inside a loop.
|
||||
return ['sh -c '.escapeshellarg(
|
||||
return ['git read-tree HEAD', 'sh -c '.escapeshellarg(
|
||||
'git ls-files -s | while read -r mode object stage path; do '
|
||||
."if [ \"\$mode\" = 160000 ]; then case {$escapedComposeFile} in \"\$path\"/*) "
|
||||
."{$git} submodule update --init --recursive{$depthFlag} -- \"\$path\" >/dev/null || exit 1 ;; esac; fi; done"
|
||||
@@ -2362,8 +2362,7 @@ class Application extends BaseModel
|
||||
if (! $gitRemoteStatus['is_accessible']) {
|
||||
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'.$this->gitFailureReason('Failed to read Git source.', (string) $gitRemoteStatus['error']));
|
||||
}
|
||||
$getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false);
|
||||
$commands = $this->composeFileReadCommands($uuid, (string) str($getGitVersion)->explode(' ')->last());
|
||||
$commands = $this->composeFileReadCommands($uuid);
|
||||
try {
|
||||
$composeFileContent = instant_remote_process($commands, $this->destination->server);
|
||||
if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) {
|
||||
@@ -2375,6 +2374,9 @@ class Application extends BaseModel
|
||||
$this->base_directory = $initialBaseDirectory;
|
||||
$this->save();
|
||||
|
||||
if (str($e->getMessage())->contains('The Docker Compose file is a symbolic link.')) {
|
||||
throw new RuntimeException("Docker Compose file at $workdir$composeFile (branch: {$this->git_branch}) is a symbolic link. Coolify reads only regular files from the repository. Commit the file itself instead of a link to it.");
|
||||
}
|
||||
if (str($e->getMessage())->contains('No such file')) {
|
||||
throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
|
||||
}
|
||||
|
||||
@@ -76,10 +76,7 @@ function fakeRepositoryCompose(string $compose): void
|
||||
{
|
||||
Process::fake(function ($process) use ($compose) {
|
||||
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
|
||||
if (str_contains($command, 'git --version')) {
|
||||
return Process::result(output: 'git version 2.43.0');
|
||||
}
|
||||
if (str_contains($command, 'head -c')) {
|
||||
if (str_contains($command, 'git cat-file blob')) {
|
||||
return Process::result(output: $compose);
|
||||
}
|
||||
|
||||
|
||||
@@ -360,8 +360,7 @@ function contentHostPathFakeGitCompose(string $compose): void
|
||||
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
|
||||
|
||||
return Process::result(output: match (true) {
|
||||
str_contains($command, 'git --version') => 'git version 2.43.0',
|
||||
str_contains($command, 'head -c') => $compose,
|
||||
str_contains($command, 'git cat-file blob') => $compose,
|
||||
default => '',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,9 +46,8 @@ test('the Compose file loads through the server commands for root and non-root u
|
||||
]);
|
||||
$application->settings->update(['is_git_submodules_enabled' => true]);
|
||||
$uuid = 'compose-load-'.bin2hex(random_bytes(4));
|
||||
$gitVersion = (string) str((new Process(['git', '--version']))->mustRun()->getOutput())->trim()->explode(' ')->last();
|
||||
|
||||
$commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid, $gitVersion);
|
||||
$commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid);
|
||||
if ($user !== 'root') {
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('getAttribute')->with('user')->andReturn($user);
|
||||
@@ -74,6 +73,72 @@ test('the Compose file loads through the server commands for root and non-root u
|
||||
'pinned commit' => [true],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Commits the given symlinks to the repository, pushes them to the bare remote, and runs the
|
||||
* Compose read commands for an application with the given base directory and Compose file location.
|
||||
*/
|
||||
function runComposeReadWithSymlinks(object $test, array $symlinks, string $baseDirectory, string $composeLocation): Process
|
||||
{
|
||||
foreach ($symlinks as $link => $target) {
|
||||
@mkdir(dirname($test->root.'/src/'.$link), 0755, true);
|
||||
symlink($target, $test->root.'/src/'.$link);
|
||||
}
|
||||
foreach ([
|
||||
['git', 'add', '-A'],
|
||||
['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'symlinks'],
|
||||
['git', 'push', '-q', $test->root.'/repo.git', 'main'],
|
||||
] as $command) {
|
||||
(new Process($command, $test->root.'/src'))->mustRun();
|
||||
}
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'build_pack' => 'dockercompose',
|
||||
'git_repository' => $test->root.'/repo.git',
|
||||
'git_branch' => 'main',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
'base_directory' => $baseDirectory,
|
||||
'docker_compose_location' => $composeLocation,
|
||||
]);
|
||||
$uuid = 'compose-load-'.bin2hex(random_bytes(4));
|
||||
$commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid);
|
||||
|
||||
$process = new Process(['bash', '-se'], env: ['PATH' => $test->root.'/bin:'.getenv('PATH')]);
|
||||
$process->setInput($commands->implode("\n")."\n");
|
||||
$process->run();
|
||||
(new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run();
|
||||
|
||||
return $process;
|
||||
}
|
||||
|
||||
test('a symlinked Compose file is rejected', function (string $target) {
|
||||
if ($target === 'outside') {
|
||||
$target = $this->root.'/linked-target.yml';
|
||||
}
|
||||
file_put_contents($this->root.'/linked-target.yml', "services:\n linked:\n environment:\n TOKEN: linked-target-value\n");
|
||||
|
||||
$process = runComposeReadWithSymlinks($this, ['docker-compose.yml' => $target], '/', '/docker-compose.yml');
|
||||
|
||||
expect($process->getExitCode())->not->toBe(0)
|
||||
->and($process->getOutput())->toBe('')
|
||||
->and($process->getErrorOutput())->toContain('symbolic link')
|
||||
->and($process->getErrorOutput())->not->toContain('linked-target-value');
|
||||
})->with([
|
||||
'absolute path' => ['outside'],
|
||||
'file in the repository' => ['apps/web/docker-compose.yml'],
|
||||
]);
|
||||
|
||||
test('a symlinked base directory is not found', function () {
|
||||
mkdir($this->root.'/linked-app');
|
||||
file_put_contents($this->root.'/linked-app/docker-compose.yml', "services:\n linked:\n image: linked-target-value\n");
|
||||
|
||||
$process = runComposeReadWithSymlinks($this, ['linked' => $this->root.'/linked-app'], '/linked', '/docker-compose.yml');
|
||||
|
||||
expect($process->getExitCode())->not->toBe(0)
|
||||
->and($process->getOutput())->toBe('')
|
||||
->and($process->getErrorOutput())->toContain('No such file')
|
||||
->and($process->getErrorOutput())->not->toContain('linked-target-value');
|
||||
});
|
||||
|
||||
test('the Compose file loads from inside a Git submodule', function (string $submodulePath, string $composeLocation, string $user) {
|
||||
$submoduleSource = $this->root.'/infra-src';
|
||||
$composeDir = substr('infra/templates/wordpress-staging', strlen($submodulePath) + 1);
|
||||
@@ -103,9 +168,8 @@ test('the Compose file loads from inside a Git submodule', function (string $sub
|
||||
]);
|
||||
$application->settings->update(['is_git_submodules_enabled' => true]);
|
||||
$uuid = 'compose-load-'.bin2hex(random_bytes(4));
|
||||
$gitVersion = (string) str((new Process(['git', '--version']))->mustRun()->getOutput())->trim()->explode(' ')->last();
|
||||
|
||||
$commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid, $gitVersion);
|
||||
$commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid);
|
||||
if ($user !== 'root') {
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('getAttribute')->with('user')->andReturn($user);
|
||||
@@ -135,3 +199,44 @@ test('the Compose file loads from inside a Git submodule', function (string $sub
|
||||
'root' => ['root'],
|
||||
'non-root' => ['ubuntu'],
|
||||
]);
|
||||
|
||||
test('a symlinked Compose file inside a Git submodule is rejected', function () {
|
||||
$submoduleSource = $this->root.'/infra-src';
|
||||
mkdir($submoduleSource);
|
||||
file_put_contents($this->root.'/linked-target.yml', "services:\n linked:\n image: linked-target-value\n");
|
||||
symlink($this->root.'/linked-target.yml', "{$submoduleSource}/docker-compose.yml");
|
||||
$gitEnv = ['GIT_CONFIG_COUNT' => '1', 'GIT_CONFIG_KEY_0' => 'protocol.file.allow', 'GIT_CONFIG_VALUE_0' => 'always'];
|
||||
foreach ([
|
||||
[$submoduleSource, ['git', 'init', '-q', '-b', 'main']],
|
||||
[$submoduleSource, ['git', 'add', '-A']],
|
||||
[$submoduleSource, ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'infra']],
|
||||
[$submoduleSource, ['git', 'clone', '-q', '--bare', '.', $this->root.'/infra.git']],
|
||||
[$this->root.'/src', ['git', 'submodule', '-q', 'add', $this->root.'/infra.git', 'infra']],
|
||||
[$this->root.'/src', ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'add infra']],
|
||||
[$this->root.'/src', ['git', 'push', '-q', $this->root.'/repo.git', 'main']],
|
||||
] as [$cwd, $command]) {
|
||||
(new Process($command, $cwd, $gitEnv))->mustRun();
|
||||
}
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'build_pack' => 'dockercompose',
|
||||
'git_repository' => $this->root.'/repo.git',
|
||||
'git_branch' => 'main',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
'base_directory' => '/',
|
||||
'docker_compose_location' => '/infra/docker-compose.yml',
|
||||
]);
|
||||
$application->settings->update(['is_git_submodules_enabled' => true]);
|
||||
$uuid = 'compose-load-'.bin2hex(random_bytes(4));
|
||||
$commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid);
|
||||
|
||||
$process = new Process(['bash', '-se'], env: ['PATH' => $this->root.'/bin:'.getenv('PATH')] + $gitEnv);
|
||||
$process->setInput($commands->implode("\n")."\n");
|
||||
$process->run();
|
||||
(new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run();
|
||||
|
||||
expect($process->getExitCode())->not->toBe(0)
|
||||
->and($process->getOutput())->toBe('')
|
||||
->and($process->getErrorOutput())->toContain('symbolic link')
|
||||
->and($process->getErrorOutput())->not->toContain('linked-target-value');
|
||||
});
|
||||
|
||||
@@ -40,12 +40,12 @@ function fakeComposeLoadServer(?string $failingStep, string $errorOutput): void
|
||||
return Process::result(errorOutput: $errorOutput, exitCode: 128);
|
||||
}
|
||||
|
||||
return Process::result(output: str_contains($command, 'git --version') ? 'git version 2.43.0' : '');
|
||||
return Process::result(output: '');
|
||||
});
|
||||
}
|
||||
|
||||
it('shows and logs why the Compose file could not be read, without credentials', function () {
|
||||
fakeComposeLoadServer('sparse-checkout', $this->gitError);
|
||||
fakeComposeLoadServer('git ls-tree', $this->gitError);
|
||||
|
||||
expect(fn () => $this->application->loadComposeFile())
|
||||
->toThrow(function (RuntimeException $exception) {
|
||||
@@ -77,3 +77,12 @@ it('shows and logs why the Git source could not be read, without credentials', f
|
||||
Log::shouldHaveReceived('warning')->withArgs(fn (string $message, array $context) => $message === 'Failed to read Git source.'
|
||||
&& ! str_contains(json_encode($context), 'ghs_SECRET123'));
|
||||
});
|
||||
|
||||
it('tells the user that a symlinked Compose file is not loaded', function () {
|
||||
$this->application->update(['docker_compose_raw' => "services:\n web:\n image: nginx\n"]);
|
||||
fakeComposeLoadServer('git ls-tree', 'The Docker Compose file is a symbolic link.');
|
||||
|
||||
expect(fn () => $this->application->loadComposeFile())
|
||||
->toThrow(RuntimeException::class, 'Docker Compose file at /docker-compose.yml (branch: main) is a symbolic link.');
|
||||
expect($this->application->fresh()->docker_compose_raw)->toBe("services:\n web:\n image: nginx\n");
|
||||
});
|
||||
|
||||
@@ -75,10 +75,7 @@ test('reloading the compose file refreshes only the environment variable list',
|
||||
]);
|
||||
Process::fake(function ($process) {
|
||||
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
|
||||
if (str_contains($command, 'git --version')) {
|
||||
return Process::result(output: 'git version 2.43.0');
|
||||
}
|
||||
if (str_contains($command, 'head -c')) {
|
||||
if (str_contains($command, 'git cat-file blob')) {
|
||||
return Process::result(output: "services:\n web:\n image: nginx:alpine\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -93,11 +93,7 @@ it('rejects oversized docker compose files loaded from git before parsing', func
|
||||
$application->shouldReceive('save')->once();
|
||||
|
||||
Process::fake(function ($process) {
|
||||
if (str_contains($process->command, 'git --version')) {
|
||||
return Process::result(output: 'git version 2.40.0');
|
||||
}
|
||||
|
||||
if (str_contains($process->command, 'wc -c')) {
|
||||
if (str_contains($process->command, 'git cat-file -s')) {
|
||||
return Process::result(output: '__COOLIFY_COMPOSE_TOO_LARGE__');
|
||||
}
|
||||
|
||||
@@ -107,7 +103,7 @@ it('rejects oversized docker compose files loaded from git before parsing', func
|
||||
expect(fn () => $application->loadComposeFile())
|
||||
->toThrow(RuntimeException::class, 'Docker Compose file exceeds the 5 MiB size limit.');
|
||||
expect($application->docker_compose_raw)->toBeNull();
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'head -c '.(Application::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1)));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, '-gt '.Application::MAX_DOCKER_COMPOSE_SIZE_BYTES));
|
||||
});
|
||||
it('bounds scheduled task output before storing or notifying', function () {
|
||||
$source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php');
|
||||
|
||||
@@ -201,8 +201,7 @@ it('saves application name description and ports from the general form', functio
|
||||
|
||||
it('shows the unsaved bar for a Compose location edit and saves it with the name', function () {
|
||||
Process::fake(fn ($process) => match (true) {
|
||||
str_contains($process->command, 'git --version') => Process::result('git version 2.43.0'),
|
||||
str_contains($process->command, 'head -c') => Process::result("services:\n web:\n image: nginx\n"),
|
||||
str_contains($process->command, 'git cat-file blob') => Process::result("services:\n web:\n image: nginx\n"),
|
||||
default => Process::result(''),
|
||||
});
|
||||
$this->application->update([
|
||||
|
||||
Reference in New Issue
Block a user