From 029827db9ec50f35575110ef5c030b5d42003fad Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:13:00 +0200 Subject: [PATCH] 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 --- app/Models/Application.php | 49 ++++++++++++++- tests/Feature/ComposeFileLoadCommandsTest.php | 62 +++++++++++++++++++ .../GitHubAppSubmoduleCredentialsTest.php | 33 ++++++++++ tests/Unit/RemoteOutputSizeLimitsTest.php | 2 +- 4 files changed, 142 insertions(+), 4 deletions(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index cd8d33893f..45e6eb3615 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -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 + */ + 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. diff --git a/tests/Feature/ComposeFileLoadCommandsTest.php b/tests/Feature/ComposeFileLoadCommandsTest.php index fae03551cc..9a0c7e847e 100644 --- a/tests/Feature/ComposeFileLoadCommandsTest.php +++ b/tests/Feature/ComposeFileLoadCommandsTest.php @@ -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'], +]); diff --git a/tests/Unit/GitHubAppSubmoduleCredentialsTest.php b/tests/Unit/GitHubAppSubmoduleCredentialsTest.php index 48f18d2a59..089ff46f6b 100644 --- a/tests/Unit/GitHubAppSubmoduleCredentialsTest.php +++ b/tests/Unit/GitHubAppSubmoduleCredentialsTest.php @@ -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)); + }); } diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php index 8720144946..f1bea3c744 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -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();