From ddd84e5adc5d374524594f276190eb34b783938d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=CC=88mer=20Faruk=20S=CC=A7AHI=CC=87N?= Date: Wed, 27 May 2026 00:57:07 +0300 Subject: [PATCH 01/16] fix(git): write deploy key to per-deployment path, not root's id_rsa --- app/Jobs/ApplicationDeploymentJob.php | 7 +- app/Models/Application.php | 60 ++++++------ tests/Unit/DeployKeyDedicatedPathTest.php | 112 ++++++++++++++++++++++ 3 files changed, 149 insertions(+), 30 deletions(-) create mode 100644 tests/Unit/DeployKeyDedicatedPathTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 098cf7804b..c85040c17d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2261,18 +2261,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $private_key = data_get($this->application, 'private_key.private_key'); if ($private_key) { $private_key = base64_encode($private_key); + $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}"; $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'), ], [ - executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), + executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), ], [ - executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ], [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), + executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), 'hidden' => true, 'save' => 'git_commit_sha', ] diff --git a/app/Models/Application.php b/app/Models/Application.php index fd7f486b90..183d538581 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1343,6 +1343,7 @@ class Application extends BaseModel $branch = $this->git_branch; ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); $commands = collect([]); + $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}"; $base_command = 'git ls-remote'; if ($this->deploymentType() === 'source') { @@ -1396,19 +1397,20 @@ class Application extends BaseModel $private_key = base64_encode($private_key); $gitlabPort = $gitlabSource->custom_port ?? 22; $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 /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'"; + $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 /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + 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 /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } @@ -1454,19 +1456,20 @@ class Application extends BaseModel // When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context // Replace ' with '\'' to safely escape within single-quoted bash strings $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 /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'"; + $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 /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + 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 /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } @@ -1507,6 +1510,7 @@ class Application extends BaseModel $branch = $this->git_branch; ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); $baseDir = $custom_base_dir ?? $this->generateBaseDir($deployment_uuid); + $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}"; // Escape shell arguments for safety to prevent command injection $escapedBranch = escapeshellarg($branch); @@ -1603,7 +1607,7 @@ class Application extends BaseModel $private_key = base64_encode($private_key); $gitlabPort = $gitlabSource->custom_port ?? 22; $escapedCustomRepository = escapeshellarg($customRepository); - $gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; + $gitlabSshCommand = "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\""; $git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; @@ -1613,14 +1617,15 @@ class Application extends BaseModel if ($exec_in_docker) { $commands = collect([ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + 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 /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } @@ -1631,7 +1636,7 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } if ($exec_in_docker) { @@ -1674,7 +1679,7 @@ class Application extends BaseModel } $private_key = base64_encode($private_key); $escapedCustomRepository = escapeshellarg($customRepository); - $deployKeySshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; + $deployKeySshCommand = "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\""; $git_clone_command_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; @@ -1684,14 +1689,15 @@ class Application extends BaseModel if ($exec_in_docker) { $commands = collect([ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + 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 /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } if ($pull_request_id !== 0) { @@ -1702,7 +1708,7 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1710,14 +1716,14 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" ".$this->buildGitCheckoutCommand($commit); } } @@ -1747,7 +1753,7 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1755,14 +1761,14 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" ".$this->buildGitCheckoutCommand($commit); } } diff --git a/tests/Unit/DeployKeyDedicatedPathTest.php b/tests/Unit/DeployKeyDedicatedPathTest.php new file mode 100644 index 0000000000..9fb4e1b1d7 --- /dev/null +++ b/tests/Unit/DeployKeyDedicatedPathTest.php @@ -0,0 +1,112 @@ +) instead of the shared + * /root/.ssh/id_rsa, so it can neither overwrite the server root's own key nor race with other + * concurrent operations on the same host. `-o IdentitiesOnly=yes` makes ssh offer only that key. + * On the host path an EXIT trap removes the key when the shell finishes; the docker path runs in an + * ephemeral container, so it needs no cleanup. + */ +$keyPath = '/root/.ssh/id_rsa_coolify_test-deployment-uuid'; + +it('writes a deploy key to a per-deployment path and cleans it up for ls-remote on the host', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('deploy_key'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey); + + $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 +}); + +it('writes a deploy key to a per-deployment path for ls-remote inside docker without a trap', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('deploy_key'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey); + + $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 >'); +}); + +it('writes a GitLab source private key to a per-deployment path with cleanup on the host', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $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); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $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 >'); +}); + +it('writes a deploy key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $settings = Mockery::mock(ApplicationSetting::class)->makePartial(); + $settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('deploy_key'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD'); + + // 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 >'); +}); From e39a9ad8274badb55eba5ccfacbe9a1918fbbea1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:39:05 +0200 Subject: [PATCH 02/16] fix(git): use deploy key path for PR fetches --- app/Models/Application.php | 2 +- tests/Unit/DeployKeyDedicatedPathTest.php | 77 +++++++++++++++++++++++ tests/Unit/GitSubmoduleCredentialTest.php | 2 +- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index 809b5336ae..b2f852f15c 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1801,7 +1801,7 @@ class Application extends BaseModel $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 {$customSshKeyLocation} -o IdentitiesOnly=yes"; + $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'; diff --git a/tests/Unit/DeployKeyDedicatedPathTest.php b/tests/Unit/DeployKeyDedicatedPathTest.php index 9fb4e1b1d7..a3373f42a1 100644 --- a/tests/Unit/DeployKeyDedicatedPathTest.php +++ b/tests/Unit/DeployKeyDedicatedPathTest.php @@ -110,3 +110,80 @@ it('writes a deploy key to a per-deployment path and cleans it up when cloning o ->toContain("trap 'rm -f {$keyPath}' EXIT") ->not->toContain('tee /root/.ssh/id_rsa >'); }); + +it('writes a GitLab source private key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $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('custom_port')->andReturn(22); + + $settings = Mockery::mock(ApplicationSetting::class)->makePartial(); + $settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->source = $gitlabSource; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@gitlab.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD'); + + $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 >'); +}); + +it('uses the per-deployment deploy key for pull request fetches', function () use ($keyPath) { + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $settings = Mockery::mock(ApplicationSetting::class)->makePartial(); + $settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('deploy_key'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@github.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD'); + + $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'); +}); + +it('does not force a missing per-deployment key for other repository pull request fetches', function () use ($keyPath) { + $settings = Mockery::mock(ApplicationSetting::class)->makePartial(); + $settings->shouldReceive('getAttribute')->with('is_git_shallow_clone_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_submodules_enabled')->andReturn(false); + $settings->shouldReceive('getAttribute')->with('is_git_lfs_enabled')->andReturn(false); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('other'); + $application->shouldReceive('customRepository')->andReturn(['repository' => 'git@github.com:user/repo.git', 'port' => 22]); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $application->shouldReceive('getAttribute')->with('git_commit_sha')->andReturn('HEAD'); + + $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); +}); diff --git a/tests/Unit/GitSubmoduleCredentialTest.php b/tests/Unit/GitSubmoduleCredentialTest.php index 5ac5c501ad..d22e1ad4c1 100644 --- a/tests/Unit/GitSubmoduleCredentialTest.php +++ b/tests/Unit/GitSubmoduleCredentialTest.php @@ -156,7 +156,7 @@ describe('Git submodule credential propagation', function () { exec_in_docker: false, ); - $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa'; + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa_coolify_test-uuid -o IdentitiesOnly=yes'; expect($result['commands']) ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify') From 2833c68af99dee2f38b87a8f37e984e669f26ec5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:15:12 +0200 Subject: [PATCH 03/16] fix(upgrade): preserve compose override expansion --- other/nightly/upgrade.sh | 4 ++-- scripts/upgrade.sh | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index c8fb9a98fc..8ccacb8a07 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -248,11 +248,11 @@ nohup bash -c " COMPOSE_FILES='-f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml' if [ -f /data/coolify/source/docker-compose.custom.yml ]; then log 'Using custom docker-compose.yml' - COMPOSE_FILES="\$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml" + COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml\" fi if [ -f /data/coolify/source/docker-compose.postgres-upgrade.yml ]; then log 'Using PostgreSQL upgrade compose override' - COMPOSE_FILES="\$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml" + COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml\" fi log 'Running docker compose up...' diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 199b531fd6..fb27694b0e 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -257,11 +257,11 @@ nohup bash -c " COMPOSE_FILES='-f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml' if [ -f /data/coolify/source/docker-compose.custom.yml ]; then log 'Using custom docker-compose.yml' - COMPOSE_FILES="\$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml" + COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml\" fi if [ -f /data/coolify/source/docker-compose.postgres-upgrade.yml ]; then log 'Using PostgreSQL upgrade compose override' - COMPOSE_FILES="\$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml" + COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml\" fi log 'Running docker compose up...' From 981b670eb4e816c020016c3bd20a55a17c8c1540 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:34:11 +0200 Subject: [PATCH 04/16] feat(services): show template update timestamps --- app/Livewire/Project/New/Select.php | 57 ++++++++++++++- .../livewire/project/new/select.blade.php | 19 ++++- .../ServiceTemplatesLastUpdatedHintTest.php | 70 +++++++++++++++++++ 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/ServiceTemplatesLastUpdatedHintTest.php diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 165e4b59e6..d6d234b18e 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -4,7 +4,9 @@ namespace App\Livewire\Project\New; use App\Models\Project; use App\Models\Server; +use Carbon\CarbonImmutable; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Livewire\Component; class Select extends Component @@ -105,7 +107,9 @@ class Select extends Component public function loadServices() { $services = get_service_templates(); - $services = collect($services)->map(function ($service, $key) { + $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services->keys()); + + $services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) { $default_logo = 'images/default.webp'; $logo = data_get($service, 'logo', $default_logo); $local_logo_path = public_path($logo); @@ -116,6 +120,7 @@ class Select extends Component 'logo_github_url' => file_exists($local_logo_path) ? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo : asset($default_logo), + 'templateLastUpdated' => $templateLastUpdatedMap[(string) $key] ?? null, ] + (array) $service; })->all(); @@ -247,6 +252,7 @@ class Select extends Component ]; return [ + 'serviceTemplatesLastUpdated' => $this->serviceTemplatesLastUpdated(), 'services' => $services, 'categories' => $categories, 'gitBasedApplications' => $gitBasedApplications, @@ -268,6 +274,55 @@ class Select extends Component } } + private function serviceTemplatesLastUpdated(): ?string + { + return $this->formatLastModified($this->serviceTemplatesPath()); + } + + private function serviceTemplateLastUpdatedMap(Collection $serviceNames): array + { + $bundleMtime = file_exists($this->serviceTemplatesPath()) ? filemtime($this->serviceTemplatesPath()) : 0; + + return Cache::remember( + "service-template-last-updated-map:{$bundleMtime}", + now()->addDay(), + fn () => $serviceNames + ->mapWithKeys(fn ($serviceName) => [ + (string) $serviceName => $this->serviceTemplateLastUpdated((string) $serviceName), + ]) + ->all() + ); + } + + private function serviceTemplateLastUpdated(string $serviceName): ?string + { + foreach (['yaml', 'yml'] as $extension) { + $templatePath = base_path("templates/compose/{$serviceName}.{$extension}"); + + if (file_exists($templatePath)) { + return $this->formatLastModified($templatePath); + } + } + + return null; + } + + private function serviceTemplatesPath(): string + { + return base_path('templates/'.config('constants.services.file_name')); + } + + private function formatLastModified(string $path): ?string + { + if (! file_exists($path)) { + return null; + } + + return CarbonImmutable::createFromTimestamp(filemtime($path)) + ->timezone(config('app.timezone')) + ->format('M j, Y H:i'); + } + public function setType(string $type) { $type = str($type)->lower()->slug()->value(); diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php index c5482d9f7a..debe3326f3 100644 --- a/resources/views/livewire/project/new/select.blade.php +++ b/resources/views/livewire/project/new/select.blade.php @@ -138,9 +138,14 @@
-
+

Services

Reload List +
+ Last Updated on Service Templates: + +
The respective trademarks mentioned here are owned by the respective companies, and use of them @@ -154,7 +159,14 @@ @@ -237,6 +249,7 @@ isSticky: false, selecting: false, services: [], + serviceTemplatesLastUpdated: null, gitBasedApplications: [], dockerBasedApplications: [], databases: [], @@ -251,12 +264,14 @@ this.loading = true; const { services, + serviceTemplatesLastUpdated, categories, gitBasedApplications, dockerBasedApplications, databases } = await this.$wire.loadServices(); this.services = services; + this.serviceTemplatesLastUpdated = serviceTemplatesLastUpdated; this.categories = categories || []; this.gitBasedApplications = gitBasedApplications; this.dockerBasedApplications = dockerBasedApplications; diff --git a/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php b/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php new file mode 100644 index 0000000000..99b9f7ad70 --- /dev/null +++ b/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php @@ -0,0 +1,70 @@ +loadServices(); + + expect($resources) + ->toHaveKey('serviceTemplatesLastUpdated') + ->and($resources['serviceTemplatesLastUpdated']) + ->toBe(CarbonImmutable::createFromTimestamp(filemtime($templatePath))->timezone(config('app.timezone'))->format('M j, Y H:i')); +}); + +it('returns each service template last updated timestamp', function () { + $component = new Select; + $templatePath = base_path('templates/compose/activepieces.yaml'); + + $resources = $component->loadServices(); + + expect($resources['services']['activepieces']) + ->toHaveKey('templateLastUpdated') + ->and($resources['services']['activepieces']['templateLastUpdated']) + ->toBe(CarbonImmutable::createFromTimestamp(filemtime($templatePath))->timezone(config('app.timezone'))->format('M j, Y H:i')); +}); + +it('uses a service template timestamp cache keyed by bundle mtime', function () { + $bundleMtime = filemtime(base_path('templates/'.config('constants.services.file_name'))); + Cache::put("service-template-last-updated-map:{$bundleMtime}", [ + 'activepieces' => 'Cached timestamp', + ], now()->addDay()); + + $resources = (new Select)->loadServices(); + + expect($resources['services']['activepieces']['templateLastUpdated'])->toBe('Cached timestamp'); +}); + +it('does not use stale service template timestamp cache entries from another bundle mtime', function () { + $bundleMtime = filemtime(base_path('templates/'.config('constants.services.file_name'))); + Cache::put('service-template-last-updated-map:'.($bundleMtime - 1), [ + 'activepieces' => 'Stale cached timestamp', + ], now()->addDay()); + + $resources = (new Select)->loadServices(); + + expect($resources['services']['activepieces']['templateLastUpdated'])->not->toBe('Stale cached timestamp'); +}); + +it('renders the service templates last updated hint placeholder', function () { + View::share('errors', new ViewErrorBag); + + $view = $this->view('livewire.project.new.select', [ + 'current_step' => 'type', + 'environments' => collect(), + ]); + + $view->assertSee('Last Updated on Service Templates:'); + $view->assertSee('serviceTemplatesLastUpdated'); + $view->assertSee('service.templateLastUpdated'); +}); From 22f9f96db68efb6009a3cd2085b317ab8d882992 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:24:53 +0200 Subject: [PATCH 05/16] chore: inspect staged changes for commit message --- app/Console/Commands/Generate/Services.php | 24 ++ app/Livewire/GlobalSearch.php | 8 + app/Livewire/Project/New/Select.php | 37 +- bootstrap/helpers/shared.php | 16 +- public/svgs/clickhouse-icon.svg | 8 + public/svgs/clickhouse.svg | 9 +- public/svgs/dragonfly.svg | 1 + public/svgs/keydb.svg | 1 + .../views/livewire/global-search.blade.php | 12 +- .../views/livewire/project/index.blade.php | 71 ++-- templates/service-templates-latest.json | 401 ++++++++++++++++-- templates/service-templates.json | 401 ++++++++++++++++-- tests/Feature/ProjectIndexEmptyStateTest.php | 38 ++ .../ServiceTemplateGitTimestampTest.php | 29 ++ .../ServiceTemplatesLastUpdatedHintTest.php | 51 ++- .../GlobalSearchNewImageQuickActionTest.php | 106 ++++- 16 files changed, 1071 insertions(+), 142 deletions(-) create mode 100644 public/svgs/clickhouse-icon.svg create mode 100644 public/svgs/dragonfly.svg create mode 100644 public/svgs/keydb.svg create mode 100644 tests/Feature/ProjectIndexEmptyStateTest.php create mode 100644 tests/Feature/ServiceTemplateGitTimestampTest.php diff --git a/app/Console/Commands/Generate/Services.php b/app/Console/Commands/Generate/Services.php index e316fc3911..2978feb3c8 100644 --- a/app/Console/Commands/Generate/Services.php +++ b/app/Console/Commands/Generate/Services.php @@ -4,6 +4,7 @@ namespace App\Console\Commands\Generate; use Illuminate\Console\Command; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Process; use Symfony\Component\Yaml\Yaml; class Services extends Command @@ -77,6 +78,7 @@ class Services extends Command 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { @@ -99,6 +101,26 @@ class Services extends Command return $payload; } + private function templateLastUpdatedAt(string $file): ?string + { + $process = Process::path(base_path())->run([ + 'git', + 'log', + '-1', + '--format=%cI', + '--', + "templates/compose/{$file}", + ]); + + if ($process->failed()) { + return null; + } + + $timestamp = trim($process->output()); + + return $timestamp === '' ? null : $timestamp; + } + private function generateServiceTemplatesWithFqdn(): void { $serviceTemplatesWithFqdn = collect(array_merge( @@ -155,6 +177,7 @@ class Services extends Command 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { @@ -232,6 +255,7 @@ class Services extends Command 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index df2adf22b5..4148764de2 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1053,6 +1053,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new postgresql)', 'type' => 'postgresql', 'category' => 'Databases', + 'logo' => 'svgs/postgresql.svg', 'resourceType' => 'database', ]); @@ -1062,6 +1063,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new mysql)', 'type' => 'mysql', 'category' => 'Databases', + 'logo' => 'svgs/mysql.svg', 'resourceType' => 'database', ]); @@ -1071,6 +1073,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new mariadb)', 'type' => 'mariadb', 'category' => 'Databases', + 'logo' => 'svgs/mariadb.svg', 'resourceType' => 'database', ]); @@ -1080,6 +1083,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new redis)', 'type' => 'redis', 'category' => 'Databases', + 'logo' => 'svgs/redis.svg', 'resourceType' => 'database', ]); @@ -1089,6 +1093,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new keydb)', 'type' => 'keydb', 'category' => 'Databases', + 'logo' => 'svgs/keydb.svg', 'resourceType' => 'database', ]); @@ -1098,6 +1103,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new dragonfly)', 'type' => 'dragonfly', 'category' => 'Databases', + 'logo' => 'svgs/dragonfly.svg', 'resourceType' => 'database', ]); @@ -1107,6 +1113,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new mongodb)', 'type' => 'mongodb', 'category' => 'Databases', + 'logo' => 'svgs/mongodb.svg', 'resourceType' => 'database', ]); @@ -1116,6 +1123,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new clickhouse)', 'type' => 'clickhouse', 'category' => 'Databases', + 'logo' => 'svgs/clickhouse-icon.svg', 'resourceType' => 'database', ]); } diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index d6d234b18e..cff886f989 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -6,7 +6,6 @@ use App\Models\Project; use App\Models\Server; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; use Livewire\Component; class Select extends Component @@ -107,7 +106,7 @@ class Select extends Component public function loadServices() { $services = get_service_templates(); - $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services->keys()); + $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services); $services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) { $default_logo = 'images/default.webp'; @@ -279,19 +278,31 @@ class Select extends Component return $this->formatLastModified($this->serviceTemplatesPath()); } - private function serviceTemplateLastUpdatedMap(Collection $serviceNames): array + private function serviceTemplateLastUpdatedMap(Collection $services): array { - $bundleMtime = file_exists($this->serviceTemplatesPath()) ? filemtime($this->serviceTemplatesPath()) : 0; + return $services + ->mapWithKeys(fn ($service, $serviceName) => [ + (string) $serviceName => $this->serviceTemplateLastUpdatedFromPayload($service) + ?? $this->serviceTemplateLastUpdated((string) $serviceName), + ]) + ->all(); + } - return Cache::remember( - "service-template-last-updated-map:{$bundleMtime}", - now()->addDay(), - fn () => $serviceNames - ->mapWithKeys(fn ($serviceName) => [ - (string) $serviceName => $this->serviceTemplateLastUpdated((string) $serviceName), - ]) - ->all() - ); + private function serviceTemplateLastUpdatedFromPayload(mixed $service): ?string + { + $timestamp = data_get($service, 'template_last_updated_at'); + + if (! is_string($timestamp) || $timestamp === '') { + return null; + } + + try { + return CarbonImmutable::parse($timestamp) + ->timezone(config('app.timezone')) + ->format('M j, Y H:i'); + } catch (\Throwable) { + return null; + } } private function serviceTemplateLastUpdated(string $serviceName): ?string diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f2b672fef6..8fdfceaf16 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1057,7 +1057,6 @@ function sslip(Server $server) function get_service_templates(bool $force = false): Collection { - if ($force) { try { $response = Http::retry(3, 1000)->get(config('constants.services.official')); @@ -1068,15 +1067,16 @@ function get_service_templates(bool $force = false): Collection return collect($services); } catch (Throwable) { - $services = File::get(base_path('templates/'.config('constants.services.file_name'))); - - return collect(json_decode($services))->sortKeys(); + return get_service_templates(); } - } else { - $services = File::get(base_path('templates/'.config('constants.services.file_name'))); - - return collect(json_decode($services))->sortKeys(); } + + $path = base_path('templates/'.config('constants.services.file_name')); + $mtime = filemtime($path) ?: 0; + + return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) { + return collect(json_decode(File::get($path)))->sortKeys(); + }); } function getResourceByUuid(string $uuid, ?int $teamId = null) diff --git a/public/svgs/clickhouse-icon.svg b/public/svgs/clickhouse-icon.svg new file mode 100644 index 0000000000..e327a2a733 --- /dev/null +++ b/public/svgs/clickhouse-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/svgs/clickhouse.svg b/public/svgs/clickhouse.svg index d536536de9..e327a2a733 100644 --- a/public/svgs/clickhouse.svg +++ b/public/svgs/clickhouse.svg @@ -1 +1,8 @@ - + + + + + + + + diff --git a/public/svgs/dragonfly.svg b/public/svgs/dragonfly.svg new file mode 100644 index 0000000000..d762f3e03c --- /dev/null +++ b/public/svgs/dragonfly.svg @@ -0,0 +1 @@ + diff --git a/public/svgs/keydb.svg b/public/svgs/keydb.svg new file mode 100644 index 0000000000..0abc24a81c --- /dev/null +++ b/public/svgs/keydb.svg @@ -0,0 +1 @@ + diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index 3316c110f8..3169c7fc9f 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -632,7 +632,7 @@ @foreach ($searchResults as $result) @if (!isset($result['is_creatable_suggestion'])) + class="search-result-item block px-4 py-3 hover:bg-neutral-100 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-neutral-100 dark:focus:bg-coolgray-200 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-coollabs dark:focus-visible:ring-warning">
@@ -696,12 +696,12 @@ @foreach ($items as $item)