fix(compose): read the Compose file from Git objects (#12320)

This commit is contained in:
Andras Bacsai
2026-10-26 08:49:48 +01:00
committed by GitHub
parent 26814e2a94
commit d88b45a0d0
8 changed files with 163 additions and 59 deletions
+37 -35
View File
@@ -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 => '',
});
});
+109 -4
View File
@@ -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');
});
+11 -2
View File
@@ -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");
}
+2 -6
View File
@@ -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([