mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
e49e540ec0
commit
029827db9e
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user