fix(compose): load the Compose file from inside a Git submodule

The Compose file read uses a sparse clone without a checkout, so submodules
were never cloned. Now only the submodule that contains the Compose file is
cloned, with the credentials of the clone.

Closes #11018

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-24 10:13:00 +02:00
co-authored by Claude Opus 5.5
parent e49e540ec0
commit 029827db9e
4 changed files with 142 additions and 4 deletions
+46 -3
View File
@@ -1931,6 +1931,7 @@ class Application extends BaseModel
'commands' => $this->gitCommandsAsShellCommand($commands),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
'git' => 'git'.(isset($gitConfigOption) ? " {$gitConfigOption}" : ''),
];
}
@@ -1981,6 +1982,7 @@ class Application extends BaseModel
'commands' => $commands->implode(' && '),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
'git' => "git {$gitConfigOption}",
];
}
@@ -2021,6 +2023,7 @@ class Application extends BaseModel
'commands' => $commands,
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
'git' => "{$gitlabGitSshCommand} git",
];
}
@@ -2039,6 +2042,7 @@ class Application extends BaseModel
'commands' => $this->gitCommandsAsShellCommand($commands),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
'git' => 'git',
];
}
}
@@ -2096,6 +2100,7 @@ class Application extends BaseModel
'commands' => $commands,
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
'git' => "{$deployKeyGitSshCommand} git",
];
}
if ($this->deploymentType() === 'other') {
@@ -2142,6 +2147,7 @@ class Application extends BaseModel
'commands' => $this->gitCommandsAsShellCommand($commands),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
'git' => 'git',
];
}
}
@@ -2246,9 +2252,22 @@ class Application extends BaseModel
*/
public function serverCheckoutCommand(string $uuid, string $checkoutDir): string
{
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir);
return $this->serverCheckout($uuid, $checkoutDir)['command'];
}
return str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand));
/**
* The clone command of serverCheckoutCommand(), and the git command with the same credentials for later steps.
*
* @return array{command: string, git: string}
*/
private function serverCheckout(string $uuid, string $checkoutDir): array
{
['commands' => $cloneCommand, 'git' => $git] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir);
return [
'command' => str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)),
'git' => $git,
];
}
/**
@@ -2260,7 +2279,7 @@ class Application extends BaseModel
private function composeFileReadCommands(string $uuid, string $gitVersion): Collection
{
$checkoutDir = "/tmp/{$uuid}/checkout";
$cloneCommand = $this->serverCheckoutCommand($uuid, $checkoutDir);
['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}");
@@ -2294,10 +2313,34 @@ class Application extends BaseModel
$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,
]);
}
/**
* 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.
*
* @return list<string>
*/
private function composeFileSubmoduleCommands(string $git, string $composeFile): array
{
if (! $this->settings?->is_git_submodules_enabled) {
return [];
}
$depthFlag = $this->settings->is_git_shallow_clone_enabled ? ' --depth=1' : '';
$escapedComposeFile = escapeshellarg($composeFile);
// One sh -c line: the non-root sudo parser cannot change commands inside a loop.
return ['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"
)];
}
/**
* Reads the Compose file from the repository and saves it only after validateDockerComposeForInjection()
* accepts it, because the parser and the deployment build shell commands from parts of it.
@@ -73,3 +73,65 @@ test('the Compose file loads through the server commands for root and non-root u
'branch head' => [false],
'pinned commit' => [true],
]);
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);
mkdir("{$submoduleSource}/{$composeDir}", 0755, true);
file_put_contents("{$submoduleSource}/{$composeDir}/docker-compose.yml", $this->compose);
// Git refuses local submodule URLs unless the file protocol is allowed.
$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', $submodulePath]],
[$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' => $composeLocation,
]);
$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);
if ($user !== 'root') {
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('getAttribute')->with('user')->andReturn($user);
$server->shouldReceive('setAttribute')->andReturnSelf();
$commands = collect(parseCommandsByLineForSudo($commands, $server));
}
$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->getErrorOutput())->not->toContain('syntax error');
if (str_contains($composeLocation, 'missing')) {
// loadComposeFile() turns this error into "Docker Compose file not found".
expect($process->getExitCode())->not->toBe(0)
->and($process->getErrorOutput())->toContain('No such file');
} else {
expect($process->getExitCode())->toBe(0)
->and($process->getOutput())->toBe($this->compose);
}
})->with([
'submodule at the top level' => ['infra', '/infra/templates/wordpress-staging/docker-compose.yml'],
'nested submodule' => ['infra/templates', '/infra/templates/wordpress-staging/docker-compose.yml'],
'missing file' => ['infra', '/infra/missing/docker-compose.yml'],
])->with([
'root' => ['root'],
'non-root' => ['ubuntu'],
]);
@@ -46,4 +46,37 @@ namespace {
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule sync")
->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule update --init --recursive");
});
test('the compose file submodule step uses the github app credentials of the clone', function () {
$application = new Application;
$application->forceFill([
'uuid' => 'test-app-uuid',
'git_repository' => 'coollabsio/private-app',
'git_branch' => 'main',
'git_commit_sha' => 'HEAD',
'base_directory' => '/',
'docker_compose_location' => '/infra/docker-compose.yml',
]);
$settings = new ApplicationSetting;
$settings->is_git_shallow_clone_enabled = true;
$settings->is_git_submodules_enabled = true;
$settings->is_git_lfs_enabled = false;
$application->setRelation('settings', $settings);
$source = new GithubApp;
$source->forceFill([
'html_url' => 'https://github.com',
'api_url' => 'https://api.github.com',
'is_public' => false,
]);
$application->setRelation('source', $source);
$commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application, 'test-uuid', '2.47.0');
expect($commands->implode("\n"))
->toContain('--no-checkout')
// The step is quoted for `sh -c`, so compare without the outer quotes.
->toContain(substr(escapeshellarg("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule update --init --recursive --depth=1"), 1, -1));
});
}
+1 -1
View File
@@ -88,7 +88,7 @@ it('rejects oversized docker compose files loaded from git before parsing', func
$application->base_directory = '/';
$application->docker_compose_location = '/docker-compose.yml';
$application->setRelation('destination', (object) ['server' => $server]);
$application->shouldReceive('generateGitImportCommands')->andReturn(['commands' => collect(['git clone example checkout'])]);
$application->shouldReceive('generateGitImportCommands')->andReturn(['commands' => collect(['git clone example checkout']), 'git' => 'git']);
$application->shouldReceive('getGitRemoteStatus')->andReturn(['is_accessible' => true]);
$application->shouldReceive('save')->once();