diff --git a/app/Models/Application.php b/app/Models/Application.php index 1ffa62584..03d177ac7 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1517,6 +1517,28 @@ class Application extends BaseModel } } + private function withGitHttpTransportConfig(?string $gitConfigOptions = null): string + { + return trim(($gitConfigOptions ? "{$gitConfigOptions} " : '').'-c http.version=HTTP/1.1'); + } + + private function isHttpGitRepository(string $repository): bool + { + return str_starts_with($repository, 'https://') || str_starts_with($repository, 'http://'); + } + + private function applyGitConfigOptionsToCloneCommand(string $gitCloneCommand, string $gitConfigOptions): string + { + $configuredCommand = preg_replace( + "/^git(?:\s+-c\s+(?:'[^']*'|\S+))*\s+clone\b/", + "git {$gitConfigOptions} clone", + $gitCloneCommand, + 1 + ); + + return $configuredCommand ?: $gitCloneCommand; + } + public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null) { $branch = $this->git_branch; @@ -1559,8 +1581,10 @@ class Application extends BaseModel $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; + $gitConfigOptions = $this->withGitHttpTransportConfig(); + $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); } if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1573,6 +1597,7 @@ class Application extends BaseModel // Rewrite same-host HTTPS URLs only for these git commands so submodules can authenticate without persisting credentials. $gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}/"); + $gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption); $git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command); if ($exec_in_docker) { @@ -1586,8 +1611,9 @@ class Application extends BaseModel $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } + $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions); } if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1598,12 +1624,13 @@ class Application extends BaseModel if ($pull_request_id !== 0) { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; - $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null); + $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions ?? null); + $gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git'; $escapedPrBranch = escapeshellarg($branch); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command")); + $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command")); } else { - $commands->push("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"); + $commands->push("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"); } } @@ -1672,7 +1699,11 @@ class Application extends BaseModel $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + $gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null; + if ($gitConfigOptions) { + $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); + } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1759,10 +1790,15 @@ class Application extends BaseModel $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + $gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null; + if ($gitConfigOptions) { + $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); + } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); $otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; if ($pull_request_id !== 0) { + $gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git'; if ($git_type === 'gitlab') { $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1770,7 +1806,7 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1778,7 +1814,7 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); @@ -1884,7 +1920,8 @@ class Application extends BaseModel return; } $uuid = new Cuid2; - ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: '.'); + ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout'); + $cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand); $workdir = rtrim($this->base_directory, '/'); $composeFile = $this->docker_compose_location; $fileList = collect([".$workdir$composeFile"]); @@ -1914,6 +1951,7 @@ class Application extends BaseModel "mkdir -p /tmp/{$uuid}", "cd /tmp/{$uuid}", $cloneCommand, + 'cd checkout', 'git sparse-checkout init', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', @@ -1925,6 +1963,7 @@ class Application extends BaseModel "mkdir -p /tmp/{$uuid}", "cd /tmp/{$uuid}", $cloneCommand, + 'cd checkout', 'git sparse-checkout init --cone', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', diff --git a/tests/Feature/GitHttpTransportCommandsTest.php b/tests/Feature/GitHttpTransportCommandsTest.php new file mode 100644 index 000000000..f5ef6ade5 --- /dev/null +++ b/tests/Feature/GitHttpTransportCommandsTest.php @@ -0,0 +1,120 @@ +forceFill([ + 'uuid' => 'test-app-uuid', + 'git_repository' => 'coollabsio/private-app', + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = $shallow; + $settings->is_git_submodules_enabled = false; + $settings->is_git_lfs_enabled = false; + $application->setRelation('settings', $settings); + + return $application; +} + +it('uses http 1 transport for public https source clones', function () { + $application = applicationWithGitSettings(); + + $source = new GithubApp; + $source->forceFill([ + 'html_url' => 'https://github.com', + 'api_url' => 'https://api.github.com', + 'is_public' => true, + ]); + $application->setRelation('source', $source); + + $result = $application->generateGitImportCommands( + deployment_uuid: 'test-deployment', + exec_in_docker: false, + ); + + expect($result['commands']) + ->toContain("git -c http.version=HTTP/1.1 clone --depth=1 -b 'main' 'https://github.com/coollabsio/private-app' '/artifacts/test-deployment'") + ->not->toContain('Primary repository import failed, retrying with HTTP/1.1') + ->not->toContain('mktemp') + ->not->toContain('git_retry_dir'); +}); + +it('applies http 1 transport to https fetches after clone', function () { + $application = applicationWithGitSettings(); + $application->git_commit_sha = 'abc123def456abc123def456abc123def456abc1'; + + $source = new GithubApp; + $source->forceFill([ + 'html_url' => 'https://github.com', + 'api_url' => 'https://api.github.com', + 'is_public' => true, + ]); + $application->setRelation('source', $source); + + $result = $application->generateGitImportCommands( + deployment_uuid: 'test-deployment', + exec_in_docker: false, + ); + + expect($result['commands']) + ->toContain("git -c http.version=HTTP/1.1 fetch --depth=1 origin 'abc123def456abc123def456abc123def456abc1'") + ->toContain("git -c http.version=HTTP/1.1 -c advice.detachedHead=false checkout 'abc123def456abc123def456abc123def456abc1'"); +}); + +it('does not add http transport config to ssh deploy key clones', function () { + $application = applicationWithGitSettings(); + $application->private_key_id = 1; + $application->setRelation('private_key', new class extends PrivateKey + { + public function getAttribute($key) + { + if ($key === 'private_key') { + return 'fake-private-key'; + } + + return parent::getAttribute($key); + } + }); + $application->git_repository = 'git@github.com:coollabsio/private-app.git'; + + $result = $application->generateGitImportCommands( + deployment_uuid: 'test-deployment', + exec_in_docker: false, + ); + + expect($result['commands']) + ->not->toContain('http.version=HTTP/1.1') + ->not->toContain('Primary repository import failed, retrying with HTTP/1.1'); +}); + +it('supports dedicated checkout directories for compose file loading', function () { + $application = applicationWithGitSettings(); + + $source = new GithubApp; + $source->forceFill([ + 'html_url' => 'https://github.com', + 'api_url' => 'https://api.github.com', + 'is_public' => true, + ]); + $application->setRelation('source', $source); + + $result = $application->generateGitImportCommands( + deployment_uuid: 'test-deployment', + only_checkout: true, + exec_in_docker: false, + custom_base_dir: 'checkout', + ); + + expect($result['commands']) + ->toContain("git -c http.version=HTTP/1.1 clone --depth=1 --no-checkout -b 'main' 'https://github.com/coollabsio/private-app' 'checkout'") + ->not->toContain('mktemp') + ->not->toContain('git_retry_dir'); +}); diff --git a/tests/Unit/GitHubAppSubmoduleCredentialsTest.php b/tests/Unit/GitHubAppSubmoduleCredentialsTest.php index 48f18d2a5..36266fce6 100644 --- a/tests/Unit/GitHubAppSubmoduleCredentialsTest.php +++ b/tests/Unit/GitHubAppSubmoduleCredentialsTest.php @@ -42,8 +42,8 @@ namespace { expect($result['commands']) ->not->toContain('git config --global') - ->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' clone --recurse-submodules -b 'main'") - ->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"); + ->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 clone --recurse-submodules -b 'main'") + ->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 submodule sync") + ->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' -c http.version=HTTP/1.1 submodule update --init --recursive"); }); }