From a6305323081d06b15f13073f1a4252a7e949121a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:17:09 +0200 Subject: [PATCH] fix(deploy): preserve deploy key command metadata --- app/Jobs/ApplicationDeploymentJob.php | 40 +++- app/Models/Application.php | 200 +++++++++--------- app/Traits/ExecuteRemoteCommand.php | 2 +- .../DevelopmentRailpackExamplesSeeder.php | 58 ++++- .../DevelopmentRailpackExamplesSeederTest.php | 37 +++- tests/Unit/DeployKeyDedicatedPathTest.php | 101 ++++++--- tests/Unit/GitlabSourceCommandsTest.php | 56 ++++- 7 files changed, 335 insertions(+), 159 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 3e46f34f5..81c662cac 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2367,12 +2367,7 @@ private function clone_repository() if ($this->pull_request_id !== 0) { $this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head."); } - $this->execute_remote_command( - [ - $importCommands, - 'hidden' => true, - ] - ); + $this->execute_remote_command(...$this->gitCommandDefinitions($importCommands)); $this->create_workdir(); $this->execute_remote_command( [ @@ -2402,6 +2397,39 @@ private function generate_git_import_commands() return $commands; } + private function gitCommandDefinitions(Collection|array|string $commands): array + { + if (is_string($commands)) { + return [ + [ + $commands, + 'hidden' => true, + ], + ]; + } + + return collect($commands) + ->map(function ($command): array { + if (is_string($command)) { + return [ + $command, + 'hidden' => true, + ]; + } + + if (is_array($command)) { + return $command + ['hidden' => true]; + } + + return [ + 'command' => $command, + 'hidden' => true, + ]; + }) + ->values() + ->all(); + } + private function cleanup_git() { $this->execute_remote_command( diff --git a/app/Models/Application.php b/app/Models/Application.php index 4c53242ed..2c408483e 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1338,7 +1338,7 @@ public function getGitRemoteStatus(string $deployment_uuid) { try { ['commands' => $lsRemoteCommand] = $this->generateGitLsRemoteCommands(deployment_uuid: $deployment_uuid, exec_in_docker: false); - instant_remote_process([$lsRemoteCommand], $this->destination->server, true); + instant_remote_process([$this->gitCommandsAsShellCommand($lsRemoteCommand)], $this->destination->server, true); return [ 'is_accessible' => true, @@ -1390,13 +1390,13 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command))); } else { - $commands->push($base_command); + $commands->push($this->gitCommand($base_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1413,29 +1413,16 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'"; - if ($exec_in_docker) { - $commands = collect([ - executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), - executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), - ]); - } else { - $commands = collect([ - "trap 'rm -f {$customSshKeyLocation}' EXIT", - 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", - "chmod 600 {$customSshKeyLocation}", - ]); - } + $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command))); } else { - $commands->push($base_command); + $commands->push($this->gitCommand($base_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $commands, 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1447,13 +1434,13 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $base_command = "{$base_command} {$escapedCustomRepository}"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command))); } else { - $commands->push($base_command); + $commands->push($this->gitCommand($base_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1472,29 +1459,16 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'"; - if ($exec_in_docker) { - $commands = collect([ - executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), - executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), - ]); - } else { - $commands = collect([ - "trap 'rm -f {$customSshKeyLocation}' EXIT", - 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", - "chmod 600 {$customSshKeyLocation}", - ]); - } + $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command))); } else { - $commands->push($base_command); + $commands->push($this->gitCommand($base_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $commands, 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1506,19 +1480,60 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $base_command = "{$base_command} {$escapedCustomRepository}"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command))); } else { - $commands->push($base_command); + $commands->push($this->gitCommand($base_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; } } + private function gitCommand(string $command): array + { + return [ + 'command' => $command, + 'hidden' => true, + ]; + } + + private function gitCommandsAsShellCommand(Collection|array|string $commands): string + { + if (is_string($commands)) { + return $commands; + } + + return collect($commands) + ->map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command) + ->implode(' && '); + } + + private function gitSshKeySetupCommands(string $deploymentUuid, string $privateKey, bool $execInDocker): Collection + { + $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deploymentUuid}"; + $commands = collect([]); + + if (! $execInDocker) { + $commands->push($this->gitCommand("trap 'rm -f {$customSshKeyLocation}' EXIT")); + } + + $commands->push($this->gitCommand($execInDocker ? executeInDocker($deploymentUuid, 'mkdir -p /root/.ssh') : 'mkdir -p /root/.ssh')); + $commands->push([ + 'command' => $execInDocker + ? executeInDocker($deploymentUuid, "echo '{$privateKey}' | base64 -d | tee {$customSshKeyLocation} > /dev/null") + : "echo '{$privateKey}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + 'hidden' => true, + 'skip_command_log' => true, + ]); + $commands->push($this->gitCommand($execInDocker ? executeInDocker($deploymentUuid, "chmod 600 {$customSshKeyLocation}") : "chmod 600 {$customSshKeyLocation}")); + + return $commands; + } + private function withGitHttpTransportConfig(?string $gitConfigOptions = null): string { return trim(($gitConfigOptions ? "{$gitConfigOptions} " : '').'-c http.version=HTTP/1.1'); @@ -1590,9 +1605,9 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $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)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } } else { $github_access_token = generateGithubInstallationToken($this->source); @@ -1619,9 +1634,9 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $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)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } } if ($pull_request_id !== 0) { @@ -1631,14 +1646,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git'; $escapedPrBranch = escapeshellarg($branch); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"))); } else { - $commands->push("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"); + $commands->push($this->gitCommand("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command")); } } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1661,39 +1676,26 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand); } - if ($exec_in_docker) { - $commands = collect([ - executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), - executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), - ]); - } else { - $commands = collect([ - "trap 'rm -f {$customSshKeyLocation}' EXIT", - 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", - "chmod 600 {$customSshKeyLocation}", - ]); - } + $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($pull_request_id !== 0) { $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$gitlabGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $gitlabSshCommand); } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $commands, 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1710,13 +1712,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $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)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1738,55 +1740,42 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand); } - if ($exec_in_docker) { - $commands = collect([ - executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), - executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), - ]); - } else { - $commands = collect([ - "trap 'rm -f {$customSshKeyLocation}' EXIT", - 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", - "chmod 600 {$customSshKeyLocation}", - ]); - } + $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($pull_request_id !== 0) { if ($git_type === 'gitlab') { $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand); } } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $commands, 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1807,37 +1796,37 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req if ($git_type === 'gitlab') { $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $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) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $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'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOptions); } } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1926,6 +1915,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = } $uuid = new_public_id(); ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout'); + $cloneCommand = $this->gitCommandsAsShellCommand($cloneCommand); $cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand); $workdir = rtrim($this->base_directory, '/'); $composeFile = $this->docker_compose_location; diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 4d989f406..a2c3d06da 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -161,7 +161,7 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe } $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command); - $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden) { + $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) { $output = str($output)->trim(); if ($output->startsWith('╔')) { $output = "\n".$output; diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php index 78659b457..e736c5ecd 100644 --- a/database/seeders/DevelopmentRailpackExamplesSeeder.php +++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php @@ -7,6 +7,7 @@ use App\Models\Application; use App\Models\Environment; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; @@ -360,6 +361,36 @@ public static function examples(): array 'ports_exposes' => '3000', 'git_branch' => 'v4.x', ], + [ + 'uuid' => 'railpack-github-deploy-key', + 'name' => 'Railpack GitHub Deploy Key Example', + 'git_repository' => 'git@github.com:coollabsio/coolify-examples-deploy-key.git', + 'git_branch' => 'main', + 'ports_exposes' => '80', + 'private_key_id' => 1, + ], + [ + 'uuid' => 'railpack-gitlab-deploy-key', + 'name' => 'Railpack GitLab Deploy Key Example', + 'git_repository' => 'git@gitlab.com:coollabsio/php-example.git', + 'git_branch' => 'main', + 'ports_exposes' => '80', + 'source_id' => 1, + 'source_type' => GitlabApp::class, + 'private_key_id' => 1, + ], + [ + 'uuid' => 'railpack-gitlab-public-example', + 'name' => 'Railpack GitLab Public Example', + 'git_repository' => 'https://gitlab.com/andrasbacsai/coolify-examples.git', + 'git_branch' => 'main', + 'base_directory' => '/astro/static', + 'publish_directory' => '/dist', + 'ports_exposes' => '80', + 'source_id' => 1, + 'source_type' => GitlabApp::class, + 'is_static' => true, + ], ]; } @@ -420,6 +451,7 @@ private function ensureDevelopmentPrerequisitesExist(): void ); $this->ensurePublicGithubSourceExists(); + $this->ensurePublicGitlabSourceExists(); } private function ensurePublicGithubSourceExists(): void @@ -437,6 +469,21 @@ private function ensurePublicGithubSourceExists(): void ); } + private function ensurePublicGitlabSourceExists(): void + { + GitlabApp::query()->firstOrCreate( + ['id' => 1], + [ + 'uuid' => 'gitlab-public', + 'name' => 'Public GitLab', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'is_public' => true, + 'team_id' => 0, + ], + ); + } + private function isDevelopmentEnvironment(): bool { return in_array(config('app.env'), ['local', 'development', 'dev'], true); @@ -479,12 +526,12 @@ private function upsertApplication(Environment $environment, StandaloneDocker $d 'name' => $example['name'], 'description' => $example['name'], 'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io", - 'repository_project_id' => self::REPOSITORY_PROJECT_ID, - 'git_repository' => self::GIT_REPOSITORY, + 'repository_project_id' => $example['repository_project_id'] ?? self::REPOSITORY_PROJECT_ID, + 'git_repository' => $example['git_repository'] ?? self::GIT_REPOSITORY, 'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH, 'build_pack' => 'railpack', 'ports_exposes' => $example['ports_exposes'], - 'base_directory' => $example['base_directory'], + 'base_directory' => $example['base_directory'] ?? '/', 'publish_directory' => $example['publish_directory'] ?? null, 'static_image' => 'nginx:alpine', 'install_command' => $example['install_command'] ?? null, @@ -493,8 +540,9 @@ private function upsertApplication(Environment $environment, StandaloneDocker $d 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => StandaloneDocker::class, - 'source_id' => 0, - 'source_type' => GithubApp::class, + 'source_id' => $example['source_id'] ?? 0, + 'source_type' => $example['source_type'] ?? GithubApp::class, + 'private_key_id' => $example['private_key_id'] ?? null, ]); $application->save(); diff --git a/tests/Feature/DevelopmentRailpackExamplesSeederTest.php b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php index 59646a804..321b8e52b 100644 --- a/tests/Feature/DevelopmentRailpackExamplesSeederTest.php +++ b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php @@ -2,6 +2,7 @@ use App\Models\Application; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; @@ -42,6 +43,7 @@ function seedRailpackExamplePrerequisites(): void expect(Server::query()->find(0))->not->toBeNull(); expect(StandaloneDocker::query()->find(0))->not->toBeNull(); expect(GithubApp::query()->find(0))->not->toBeNull(); + expect(GitlabApp::query()->find(1))->not->toBeNull(); expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue(); expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples())); }); @@ -66,9 +68,10 @@ function seedRailpackExamplePrerequisites(): void expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples())); expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue(); - expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue(); - $examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid'); + expect($applications->every( + fn (Application $application) => $application->git_repository === ($examples->get($application->uuid)['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY) + ))->toBeTrue(); expect($applications->every( fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH) ))->toBeTrue(); @@ -79,6 +82,9 @@ function seedRailpackExamplePrerequisites(): void $pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask'); $goGin = $applications->firstWhere('uuid', 'railpack-go-gin'); $rust = $applications->firstWhere('uuid', 'railpack-rust'); + $githubDeployKey = $applications->firstWhere('uuid', 'railpack-github-deploy-key'); + $gitlabDeployKey = $applications->firstWhere('uuid', 'railpack-gitlab-deploy-key'); + $gitlabPublic = $applications->firstWhere('uuid', 'railpack-gitlab-public-example'); expect($nestjs) ->not->toBeNull() @@ -113,6 +119,33 @@ function seedRailpackExamplePrerequisites(): void expect($rust) ->not->toBeNull() ->and($rust->ports_exposes)->toBe('8000'); + + expect($githubDeployKey) + ->not->toBeNull() + ->and($githubDeployKey->git_repository)->toBe('git@github.com:coollabsio/coolify-examples-deploy-key.git') + ->and($githubDeployKey->git_branch)->toBe('main') + ->and($githubDeployKey->build_pack)->toBe('railpack') + ->and($githubDeployKey->private_key_id)->toBe(1) + ->and($githubDeployKey->source_type)->toBe(GithubApp::class) + ->and($githubDeployKey->source_id)->toBe(0); + + expect($gitlabDeployKey) + ->not->toBeNull() + ->and($gitlabDeployKey->git_repository)->toBe('git@gitlab.com:coollabsio/php-example.git') + ->and($gitlabDeployKey->git_branch)->toBe('main') + ->and($gitlabDeployKey->build_pack)->toBe('railpack') + ->and($gitlabDeployKey->private_key_id)->toBe(1) + ->and($gitlabDeployKey->source_type)->toBe(GitlabApp::class) + ->and($gitlabDeployKey->source_id)->toBe(1); + + expect($gitlabPublic) + ->not->toBeNull() + ->and($gitlabPublic->git_repository)->toBe('https://gitlab.com/andrasbacsai/coolify-examples.git') + ->and($gitlabPublic->base_directory)->toBe('/astro/static') + ->and($gitlabPublic->publish_directory)->toBe('/dist') + ->and($gitlabPublic->build_pack)->toBe('railpack') + ->and($gitlabPublic->source_type)->toBe(GitlabApp::class) + ->and($gitlabPublic->settings->is_static)->toBeTrue(); }); it('skips the railpack examples outside development mode', function () { diff --git a/tests/Unit/DeployKeyDedicatedPathTest.php b/tests/Unit/DeployKeyDedicatedPathTest.php index 04fa08b26..59a937fc5 100644 --- a/tests/Unit/DeployKeyDedicatedPathTest.php +++ b/tests/Unit/DeployKeyDedicatedPathTest.php @@ -4,11 +4,50 @@ use App\Models\ApplicationSetting; use App\Models\GitlabApp; use App\Models\PrivateKey; +use Illuminate\Support\Collection; afterEach(function () { Mockery::close(); }); +function commandStrings(array|Collection|string $commands): Collection +{ + if (is_string($commands)) { + return collect([$commands]); + } + + return collect($commands)->map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command); +} + +function privateKeyMaterializationCommands(array|Collection|string $commands): Collection +{ + if (is_string($commands)) { + $commands = [$commands]; + } + + return collect($commands)->filter(fn ($command) => str(commandStrings([$command])->first())->contains('base64 -d | tee /root/.ssh/id_rsa_coolify_')); +} + +function expectCommandListToContain(array|Collection|string $commands, string $expected): void +{ + expect(commandStrings($commands)->implode(' && '))->toContain($expected); +} + +function expectCommandListNotToContain(array|Collection|string $commands, string $expected): void +{ + expect(commandStrings($commands)->implode(' && '))->not->toContain($expected); +} + +function expectPrivateKeyMaterializationCommandsSkipLogging(array|Collection|string $commands): void +{ + $keyCommands = privateKeyMaterializationCommands($commands); + + expect($keyCommands)->not->toBeEmpty(); + $keyCommands->each(function ($command): void { + expect(data_get($command, 'skip_command_log'))->toBeTrue(); + }); +} + /** * Git operations authenticate with the SSH key assigned in the UI. Coolify writes that key to a * per-deployment path (/root/.ssh/id_rsa_coolify_) instead of the shared @@ -33,6 +72,7 @@ expect($source) ->toContain('$skip_command_log = data_get($single_command, \'skip_command_log\', false);') ->toContain('if ($command_hidden && ! $skip_command_log && isset($this->application_deployment_queue))') + ->toContain('use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log)') ->toContain('\'command\' => $skip_command_log || $command_hidden ? null : $this->redact_sensitive_info($command),'); }); @@ -48,11 +88,11 @@ $result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false); - expect($result['commands']) - ->toContain("tee {$keyPath}") - ->toContain("-i {$keyPath} -o IdentitiesOnly=yes") - ->toContain("trap 'rm -f {$keyPath}' EXIT") // removed when the shell exits - ->not->toContain('tee /root/.ssh/id_rsa >'); // never overwrites the host root's own key + expectCommandListToContain($result['commands'], "tee {$keyPath}"); + expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes"); + expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT"); // removed when the shell exits + expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >'); // never overwrites the host root's own key + expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']); }); it('writes a deploy key to a per-deployment path for ls-remote inside docker without a trap', function () use ($keyPath) { @@ -67,11 +107,11 @@ $result = $application->generateGitLsRemoteCommands('test-deployment-uuid', true); - expect($result['commands']) - ->toContain("tee {$keyPath}") - ->toContain("-i {$keyPath} -o IdentitiesOnly=yes") - ->not->toContain('trap ') // ephemeral container, no cleanup needed - ->not->toContain('tee /root/.ssh/id_rsa >'); + expectCommandListToContain($result['commands'], "tee {$keyPath}"); + expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes"); + expectCommandListNotToContain($result['commands'], 'trap '); // ephemeral container, no cleanup needed + expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >'); + expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']); }); it('writes a GitLab source private key to a per-deployment path with cleanup on the host', function () use ($keyPath) { @@ -94,11 +134,11 @@ $result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false); - expect($result['commands']) - ->toContain("tee {$keyPath}") - ->toContain("-i {$keyPath} -o IdentitiesOnly=yes") - ->toContain("trap 'rm -f {$keyPath}' EXIT") - ->not->toContain('tee /root/.ssh/id_rsa >'); + expectCommandListToContain($result['commands'], "tee {$keyPath}"); + expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes"); + expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT"); + expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >'); + expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']); }); it('writes a deploy key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) { @@ -121,11 +161,11 @@ // exec_in_docker = false → the loadComposeFile / host clone path $result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false); - expect($result['commands']) - ->toContain("tee {$keyPath}") - ->toContain("-i {$keyPath} -o IdentitiesOnly=yes") - ->toContain("trap 'rm -f {$keyPath}' EXIT") - ->not->toContain('tee /root/.ssh/id_rsa >'); + expectCommandListToContain($result['commands'], "tee {$keyPath}"); + expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes"); + expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT"); + expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >'); + expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']); }); it('writes a GitLab source private key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) { @@ -154,11 +194,11 @@ $result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false); - expect($result['commands']) - ->toContain("tee {$keyPath}") - ->toContain("-i {$keyPath} -o IdentitiesOnly=yes") - ->toContain("trap 'rm -f {$keyPath}' EXIT") - ->not->toContain('tee /root/.ssh/id_rsa >'); + expectCommandListToContain($result['commands'], "tee {$keyPath}"); + expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes"); + expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT"); + expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >'); + expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']); }); it('uses the per-deployment deploy key for pull request fetches', function () use ($keyPath) { @@ -180,9 +220,9 @@ $result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false); - expect($result['commands']) - ->toContain("GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$keyPath} -o IdentitiesOnly=yes\" git fetch origin pull/123/head:pr-123-coolify") - ->not->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify'); + expectCommandListToContain($result['commands'], "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$keyPath} -o IdentitiesOnly=yes\" git fetch origin pull/123/head:pr-123-coolify"); + expectCommandListNotToContain($result['commands'], 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify'); + expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']); }); it('does not force a missing per-deployment key for other repository pull request fetches', function () use ($keyPath) { @@ -200,7 +240,6 @@ $result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false); - expect($result['commands']) - ->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify') - ->not->toContain($keyPath); + expectCommandListToContain($result['commands'], 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify'); + expectCommandListNotToContain($result['commands'], $keyPath); }); diff --git a/tests/Unit/GitlabSourceCommandsTest.php b/tests/Unit/GitlabSourceCommandsTest.php index 077b21590..129a86506 100644 --- a/tests/Unit/GitlabSourceCommandsTest.php +++ b/tests/Unit/GitlabSourceCommandsTest.php @@ -3,11 +3,45 @@ use App\Models\Application; use App\Models\GitlabApp; use App\Models\PrivateKey; +use Illuminate\Support\Collection; afterEach(function () { Mockery::close(); }); +function gitlabCommandStrings(array|Collection|string $commands): Collection +{ + if (is_string($commands)) { + return collect([$commands]); + } + + return collect($commands)->map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command); +} + +function expectGitlabCommandListToContain(array|Collection|string $commands, string $expected): void +{ + expect(gitlabCommandStrings($commands)->implode(' && '))->toContain($expected); +} + +function expectGitlabCommandListNotToContain(array|Collection|string $commands, string $expected): void +{ + expect(gitlabCommandStrings($commands)->implode(' && '))->not->toContain($expected); +} + +function expectGitlabPrivateKeyMaterializationCommandsSkipLogging(array|Collection|string $commands): void +{ + if (is_string($commands)) { + $commands = [$commands]; + } + + $keyCommands = collect($commands)->filter(fn ($command) => str(data_get($command, 'command') ?? $command[0] ?? $command)->contains('base64 -d | tee /root/.ssh/id_rsa_coolify_')); + + expect($keyCommands)->not->toBeEmpty(); + $keyCommands->each(function ($command): void { + expect(data_get($command, 'skip_command_log'))->toBeTrue(); + }); +} + it('generates ls-remote commands for GitLab source with private key', function () { $deploymentUuid = 'test-deployment-uuid'; @@ -15,7 +49,8 @@ $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); - $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey); $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1); $gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22); @@ -34,16 +69,18 @@ expect($result)->toBeArray(); expect($result)->toHaveKey('commands'); - expect($result['commands'])->toContain('git ls-remote'); - expect($result['commands'])->toContain('id_rsa'); - expect($result['commands'])->toContain('mkdir -p /root/.ssh'); + expectGitlabCommandListToContain($result['commands'], 'git ls-remote'); + expectGitlabCommandListToContain($result['commands'], 'id_rsa'); + expectGitlabCommandListToContain($result['commands'], 'mkdir -p /root/.ssh'); + expectGitlabPrivateKeyMaterializationCommandsSkipLogging($result['commands']); }); it('generates ls-remote commands for GitLab source without private key', function () { $deploymentUuid = 'test-deployment-uuid'; $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); - $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); @@ -61,17 +98,18 @@ expect($result)->toBeArray(); expect($result)->toHaveKey('commands'); - expect($result['commands'])->toContain('git ls-remote'); - expect($result['commands'])->toContain('https://gitlab.com/user/repo.git'); + expectGitlabCommandListToContain($result['commands'], 'git ls-remote'); + expectGitlabCommandListToContain($result['commands'], 'https://gitlab.com/user/repo.git'); // Should NOT contain SSH key setup - expect($result['commands'])->not->toContain('id_rsa'); + expectGitlabCommandListNotToContain($result['commands'], 'id_rsa'); }); it('does not return null for GitLab source type', function () { $deploymentUuid = 'test-deployment-uuid'; $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); - $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);