From 9b65b82ccba58af4691f58ee095298007854bf96 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen Date: Sat, 31 Jan 2026 01:56:17 +1100 Subject: [PATCH 001/235] feat(template): cloudflare-ddns --- public/svgs/cloudflare-ddns.svg | 8 ++++++++ templates/compose/cloudflare-ddns.yaml | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 public/svgs/cloudflare-ddns.svg create mode 100644 templates/compose/cloudflare-ddns.yaml diff --git a/public/svgs/cloudflare-ddns.svg b/public/svgs/cloudflare-ddns.svg new file mode 100644 index 000000000..efe800bcc --- /dev/null +++ b/public/svgs/cloudflare-ddns.svg @@ -0,0 +1,8 @@ + + + + + + DDNS + + diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml new file mode 100644 index 000000000..874f2cffb --- /dev/null +++ b/templates/compose/cloudflare-ddns.yaml @@ -0,0 +1,20 @@ +# documentation: https://github.com/favonia/cloudflare-ddns +# slogan: A small, feature-rich, and robust Cloudflare DDNS updater. +# category: automation +# tags: cloud, ddns +# logo: svgs/cloudflare-ddns.svg + +services: + cloudflare-ddns: + image: favonia/cloudflare-ddns:1 + network_mode: host + restart: unless-stopped + user: "1000:1000" + read_only: true + cap_drop: [all] + security_opt: [no-new-privileges:true] + environment: + - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN} + - DOMAINS=${DOMAINS} + - PROXIED=false + - IP6_PROVIDER=none From 90449d2bb5e7b8370dadb0899f511bb9c5b6c2cc Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:55:44 +1100 Subject: [PATCH 002/235] fix: remove restart: unless-stopped Update templates/compose/cloudflare-ddns.yaml Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> --- templates/compose/cloudflare-ddns.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index 874f2cffb..b44828a70 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -8,7 +8,6 @@ services: cloudflare-ddns: image: favonia/cloudflare-ddns:1 network_mode: host - restart: unless-stopped user: "1000:1000" read_only: true cap_drop: [all] From 96b9cd3fa543c4e78554af27607ab231d7122e60 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:56:09 +1100 Subject: [PATCH 003/235] fix: mark the API token env as required, and other env as configurable from the UI Update templates/compose/cloudflare-ddns.yaml Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> --- templates/compose/cloudflare-ddns.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index b44828a70..92f857c41 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -13,7 +13,7 @@ services: cap_drop: [all] security_opt: [no-new-privileges:true] environment: - - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN} + - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?} - DOMAINS=${DOMAINS} - - PROXIED=false - - IP6_PROVIDER=none + - PROXIED=${PROXIED:-false} + - IP6_PROVIDER=${IP6_PROVIDER:-none} From b65f6399df736777a48498aca17c43f9609a5a1f Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:52:14 +1100 Subject: [PATCH 004/235] fix: make domains env compulsory Update templates/compose/cloudflare-ddns.yaml --- templates/compose/cloudflare-ddns.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index 92f857c41..4f29a98d4 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -14,6 +14,6 @@ services: security_opt: [no-new-privileges:true] environment: - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?} - - DOMAINS=${DOMAINS} + - DOMAINS=${DOMAINS:?} - PROXIED=${PROXIED:-false} - IP6_PROVIDER=${IP6_PROVIDER:-none} From dcd976ae065e1a2a003f62a12b29e49e41ac157e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:30:58 +0100 Subject: [PATCH 005/235] fix(git): ensure ssh credentials are propagated to submodule operations - Add gitSshCommand parameter to setGitImportSettings and buildGitCheckoutCommand - Extract hardcoded ssh commands to variables for consistency - Apply custom ssh credentials to all git operations including submodule sync/update - Configure git url rewriting for github token-based authentication with submodules - Add comprehensive test suite for submodule credential propagation --- app/Models/Application.php | 41 +++++--- openapi.json | 27 +++++ openapi.yaml | 21 ++++ tests/Unit/GitSubmoduleCredentialTest.php | 122 ++++++++++++++++++++++ 4 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 tests/Unit/GitSubmoduleCredentialTest.php diff --git a/app/Models/Application.php b/app/Models/Application.php index 34ab4141e..e622c9d54 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1087,12 +1087,14 @@ public function dirOnServer() return application_configuration_dir()."/{$this->uuid}"; } - public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null) + public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null) { $baseDir = $this->generateBaseDir($deployment_uuid); $escapedBaseDir = escapeshellarg($baseDir); $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; + $sshCommand = $gitSshCommand ?? 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; + // Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha. // Invalid refs will cause the git checkout/fetch command to fail on the remote server. $commitToUse = $commit ?? $this->git_commit_sha; @@ -1102,9 +1104,9 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ // If shallow clone is enabled and we need a specific commit, // we need to fetch that specific commit with depth=1 if ($isShallowCloneEnabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$sshCommand}\" git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } else { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$sshCommand}\" git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } } if ($this->settings->is_git_submodules_enabled) { @@ -1115,10 +1117,10 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ } // Add shallow submodules flag if shallow clone is enabled $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; - $git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi"; + $git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"{$sshCommand}\" git submodule update --init --recursive {$submoduleFlags}; fi"; } if ($this->settings->is_git_lfs_enabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$sshCommand}\" git lfs pull"; } return $git_clone_command; @@ -1301,12 +1303,18 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } } else { $github_access_token = generateGithubInstallationToken($this->source); + // Configure git to rewrite URLs with the token so submodules on the same host authenticate correctly + $escapedTokenUrl = escapeshellarg("{$source_html_url_scheme}://x-access-token:{$github_access_token}@{$source_html_url_host}/"); + $escapedHostUrl = escapeshellarg("{$source_html_url_scheme}://{$source_html_url_host}/"); + $gitConfigCommand = "git config --global url.{$escapedTokenUrl}.insteadOf {$escapedHostUrl}"; if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $gitConfigCommand)); $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } else { + $commands->push($gitConfigCommand); $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; @@ -1348,11 +1356,12 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } $private_key = base64_encode($private_key); $escapedCustomRepository = escapeshellarg($customRepository); - $git_clone_command_base = "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_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $deployKeySshCommand = "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_clone_command_base = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand); } if ($exec_in_docker) { $commands = collect([ @@ -1375,7 +1384,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } 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=\"{$deployKeySshCommand}\" 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) { @@ -1383,14 +1392,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } 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=\"{$deployKeySshCommand}\" 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'")); } 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=\"{$deployKeySshCommand}\" ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand); } } @@ -1411,6 +1420,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $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); + $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) { if ($git_type === 'gitlab') { @@ -1420,7 +1430,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } 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=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1428,14 +1438,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } 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=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); } 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=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand); } } @@ -1684,13 +1694,14 @@ public function fqdns(): Attribute ); } - protected function buildGitCheckoutCommand($target): string + protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null): string { $escapedTarget = escapeshellarg($target); $command = "git checkout {$escapedTarget}"; if ($this->settings->is_git_submodules_enabled) { - $command .= ' && git submodule update --init --recursive'; + $sshCommand = $gitSshCommand ?? 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; + $command .= " && GIT_SSH_COMMAND=\"{$sshCommand}\" git submodule update --init --recursive"; } return $command; diff --git a/openapi.json b/openapi.json index 69f5ef53d..849dee363 100644 --- a/openapi.json +++ b/openapi.json @@ -3339,6 +3339,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -5864,6 +5873,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -10561,6 +10579,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { diff --git a/openapi.yaml b/openapi.yaml index fab3df54e..226295cdb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2111,6 +2111,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop application.' @@ -3806,6 +3813,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop database.' @@ -6645,6 +6659,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop service.' diff --git a/tests/Unit/GitSubmoduleCredentialTest.php b/tests/Unit/GitSubmoduleCredentialTest.php new file mode 100644 index 000000000..1adaf735f --- /dev/null +++ b/tests/Unit/GitSubmoduleCredentialTest.php @@ -0,0 +1,122 @@ +application = new Application; + $this->application->forceFill([ + 'uuid' => 'test-app-uuid', + 'git_commit_sha' => 'HEAD', + ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = true; + $settings->is_git_lfs_enabled = false; + $this->application->setRelation('settings', $settings); + }); + + test('setGitImportSettings uses provided gitSshCommand for submodule update', function () { + $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'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive') + ->toContain('git submodule sync'); + }); + + test('setGitImportSettings uses default ssh command when no gitSshCommand provided', function () { + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive'); + }); + + test('setGitImportSettings uses provided gitSshCommand for fetch and checkout', function () { + $this->application->git_commit_sha = 'abc123def456'; + $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'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git -c advice.detachedHead=false checkout'); + }); + + test('setGitImportSettings uses provided gitSshCommand for shallow fetch', function () { + $this->application->git_commit_sha = 'abc123def456'; + $this->application->settings->is_git_shallow_clone_enabled = true; + $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'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch --depth=1 origin'); + }); + + test('setGitImportSettings uses provided gitSshCommand for lfs pull', function () { + $this->application->settings->is_git_lfs_enabled = true; + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git lfs pull'); + }); + + test('buildGitCheckoutCommand includes GIT_SSH_COMMAND for submodule update when provided', function () { + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa'; + + $method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand'); + $result = $method->invoke($this->application, 'main', $sshCommand); + + expect($result) + ->toContain("git checkout 'main'") + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive'); + }); + + test('buildGitCheckoutCommand uses default ssh command for submodule update when none provided', function () { + $method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand'); + $result = $method->invoke($this->application, 'main'); + + expect($result) + ->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive'); + }); + + test('buildGitCheckoutCommand omits submodule update when submodules disabled', function () { + $this->application->settings->is_git_submodules_enabled = false; + + $method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand'); + $result = $method->invoke($this->application, 'main'); + + expect($result) + ->toContain("git checkout 'main'") + ->not->toContain('submodule'); + }); +}); From 793077d74fdbadc3c7388f13cfc6e4135dc96bf4 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 23 Mar 2026 17:12:02 +0000 Subject: [PATCH 006/235] feat(buildpack): add Railpack as a build pack option --- app/Enums/BuildPackTypes.php | 1 + .../Api/ApplicationsController.php | 10 +- app/Jobs/ApplicationDeploymentJob.php | 176 +++++++++++++++++- app/Livewire/Project/Application/General.php | 2 +- .../Project/New/GithubPrivateRepository.php | 4 +- .../New/GithubPrivateRepositoryDeployKey.php | 4 +- .../Project/New/PublicGitRepository.php | 4 +- app/Models/Application.php | 24 ++- docker/coolify-helper/Dockerfile | 20 ++ openapi.json | 6 + openapi.yaml | 11 +- .../project/application/general.blade.php | 11 +- ...ub-private-repository-deploy-key.blade.php | 1 + .../new/github-private-repository.blade.php | 1 + .../new/public-git-repository.blade.php | 1 + .../ApplicationBuildpackCleanupTest.php | 46 +++++ tests/Feature/ApplicationRailpackTest.php | 168 +++++++++++++++++ tests/Feature/BuildpackSwitchCleanupTest.php | 23 +++ 18 files changed, 485 insertions(+), 28 deletions(-) create mode 100644 tests/Feature/ApplicationRailpackTest.php diff --git a/app/Enums/BuildPackTypes.php b/app/Enums/BuildPackTypes.php index cb51db6d6..eee898823 100644 --- a/app/Enums/BuildPackTypes.php +++ b/app/Enums/BuildPackTypes.php @@ -8,4 +8,5 @@ enum BuildPackTypes: string case STATIC = 'static'; case DOCKERFILE = 'dockerfile'; case DOCKERCOMPOSE = 'dockercompose'; + case RAILPACK = 'railpack'; } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 3444f9f14..4abf1c6e0 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -150,7 +150,7 @@ public function applications(Request $request) 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], @@ -318,7 +318,7 @@ public function create_public_application(Request $request) 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], @@ -483,7 +483,7 @@ public function create_private_gh_app_application(Request $request) 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], @@ -644,7 +644,7 @@ public function create_private_deploy_key_application(Request $request) 'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'], 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], @@ -2318,7 +2318,7 @@ public function delete_by_uuid(Request $request) 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e30af5cc7..33905ce59 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -121,6 +121,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_nixpacks_args; + private $env_railpack_args; + private $docker_compose; private $docker_compose_base64; @@ -476,8 +478,12 @@ private function decide_what_to_do() $this->deploy_dockerfile_buildpack(); } elseif ($this->application->build_pack === 'static') { $this->deploy_static_buildpack(); - } else { + } elseif ($this->application->build_pack === 'nixpacks') { $this->deploy_nixpacks_buildpack(); + } elseif ($this->application->build_pack === 'railpack') { + $this->deploy_railpack_buildpack(); + } else { + throw new \RuntimeException("Unsupported build pack: {$this->application->build_pack}"); } $this->post_deployment(); } @@ -921,6 +927,37 @@ private function deploy_nixpacks_buildpack() $this->rolling_update(); } + private function deploy_railpack_buildpack() + { + if ($this->use_build_server) { + $this->server = $this->build_server; + } + $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}."); + $this->prepare_builder_image(); + $this->check_git_if_build_needed(); + $this->generate_image_names(); + if (! $this->force_rebuild) { + $this->check_image_locally_or_remotely(); + if ($this->should_skip_build()) { + return; + } + } + $this->clone_repository(); + $this->cleanup_git(); + $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + + $this->generate_build_env_variables(); + $this->build_railpack_image(); + + // Save runtime environment variables AFTER the build + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); + $this->rolling_update(); + } + private function deploy_static_buildpack() { if ($this->use_build_server) { @@ -1943,7 +1980,11 @@ private function deploy_pull_request() if ($this->application->build_pack === 'dockerfile') { $this->add_build_env_variables_to_dockerfile(); } - $this->build_image(); + if ($this->application->build_pack === 'railpack') { + $this->build_railpack_image(); + } else { + $this->build_image(); + } // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); @@ -2376,6 +2417,137 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } + private function generate_railpack_env_variables(): void + { + $this->env_railpack_args = collect([]); + if ($this->pull_request_id === 0) { + foreach ($this->application->railpack_environment_variables as $env) { + if (! is_null($env->real_value) && $env->real_value !== '') { + $this->env_railpack_args->push("--env {$env->key}={$env->real_value}"); + } + } + } else { + foreach ($this->application->railpack_environment_variables_preview as $env) { + if (! is_null($env->real_value) && $env->real_value !== '') { + $this->env_railpack_args->push("--env {$env->key}={$env->real_value}"); + } + } + } + + // Note: COOLIFY_* vars are NOT passed to railpack prepare because railpack treats + // all --env vars as secrets that must be provided during docker buildx build. + // COOLIFY_* vars are informational and available at runtime via .env file. + + $this->env_railpack_args = $this->env_railpack_args->implode(' '); + } + + private function build_railpack_image(): void + { + $this->generate_railpack_env_variables(); + + // Step 1: Generate build plan with railpack prepare + $prepare_command = 'railpack prepare'; + + if ($this->env_railpack_args) { + $prepare_command .= " {$this->env_railpack_args}"; + } + if ($this->application->build_command) { + $prepare_command .= " --build-cmd \"{$this->application->build_command}\""; + } + if ($this->application->start_command) { + $prepare_command .= " --start-cmd \"{$this->application->start_command}\""; + } + if ($this->application->install_command) { + $prepare_command .= " --env RAILPACK_INSTALL_CMD=\"{$this->application->install_command}\""; + } + + $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}"; + + $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.'); + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true], + ); + + // Step 2: Build image using docker buildx with railpack frontend. + // Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder. + $this->application_deployment_queue->addLogEntry('Building docker image with Railpack.'); + $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.'); + + $image_name = $this->application->settings->is_static + ? $this->build_image_name + : $this->production_image_name; + + if ($this->application->settings->is_static && $this->application->static_image) { + $this->pull_latest_image($this->application->static_image); + } + + $cache_args = ''; + if ($this->force_rebuild) { + $cache_args = '--no-cache'; + } else { + $cache_args = "--build-arg cache-key='{$this->application->uuid}'"; + } + + $build_command = 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' + .' && docker buildx build --builder coolify-railpack' + ." {$this->addHosts} --network host" + ." --build-arg BUILDKIT_SYNTAX='ghcr.io/railwayapp/railpack-frontend'" + ." {$cache_args}" + .' -f /artifacts/railpack-plan.json' + .' --progress plain' + .' --load' + ." -t {$image_name}" + ." {$this->workdir}"; + + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), + 'hidden' => true, + ] + ); + + // Step 3: If static, copy built assets into nginx image + if ($this->application->settings->is_static) { + $publishDir = trim($this->application->publish_directory, '/'); + $publishDir = $publishDir ? "/{$publishDir}" : ''; + $dockerfile = base64_encode("FROM {$this->application->static_image} +WORKDIR /usr/share/nginx/html/ +LABEL coolify.deploymentId={$this->deployment_uuid} +COPY --from={$this->build_image_name} /app{$publishDir} . +COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); + + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + $nginx_config = $this->application->settings->is_spa + ? base64_encode(defaultNginxConfiguration('spa')) + : base64_encode(defaultNginxConfiguration()); + } + + $static_build = $this->dockerBuildkitSupported + ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}" + : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; + + $base64_static_build = base64_encode($static_build); + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + ); + } + } + private function generate_coolify_env_variables(bool $forBuildTime = false): Collection { $coolify_envs = collect([]); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index ca1daef72..bc24c3944 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -598,7 +598,7 @@ public function updatedBuildPack() // Sync property to model before checking/modifying $this->syncData(toModel: true); - if ($this->buildPack !== 'nixpacks') { + if ($this->buildPack !== 'nixpacks' && $this->buildPack !== 'railpack') { $this->isStatic = false; $this->application->settings->is_static = false; $this->application->settings->save(); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 61ae0e151..c208e2cd2 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -62,7 +62,7 @@ class GithubPrivateRepository extends Component protected int $page = 1; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; @@ -82,7 +82,7 @@ public function updatedSelectedRepositoryId(): void public function updatedBuildPack() { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; $this->port = 3000; } elseif ($this->build_pack === 'static') { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index e46ad7d78..f312a9dc0 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -45,7 +45,7 @@ class GithubPrivateRepositoryDeployKey extends Component public string $branch; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; @@ -95,7 +95,7 @@ public function mount() public function updatedBuildPack() { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; $this->port = 3000; } elseif ($this->build_pack === 'static') { diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 3df31a6a3..eb4ce7b84 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -57,7 +57,7 @@ class PublicGitRepository extends Component public string $git_repository; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; @@ -99,7 +99,7 @@ public function mount() public function updatedBuildPack() { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; $this->port = 3000; } elseif ($this->build_pack === 'static') { diff --git a/app/Models/Application.php b/app/Models/Application.php index 4cc2dcf74..b97201faa 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -39,7 +39,7 @@ 'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'], 'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'], - 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose']], + 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']], 'static_image' => ['type' => 'string', 'description' => 'Static image used when static site is deployed.'], 'install_command' => ['type' => 'string', 'description' => 'Install command.'], 'build_command' => ['type' => 'string', 'description' => 'Build command.'], @@ -854,7 +854,8 @@ public function runtime_environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) - ->where('key', 'not like', 'NIXPACKS_%'); + ->where('key', 'not like', 'NIXPACKS_%') + ->where('key', 'not like', 'RAILPACK_%'); } public function nixpacks_environment_variables() @@ -864,6 +865,13 @@ public function nixpacks_environment_variables() ->where('key', 'like', 'NIXPACKS_%'); } + public function railpack_environment_variables() + { + return $this->morphMany(EnvironmentVariable::class, 'resourceable') + ->where('is_preview', false) + ->where('key', 'like', 'RAILPACK_%'); + } + public function environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') @@ -882,7 +890,8 @@ public function runtime_environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) - ->where('key', 'not like', 'NIXPACKS_%'); + ->where('key', 'not like', 'NIXPACKS_%') + ->where('key', 'not like', 'RAILPACK_%'); } public function nixpacks_environment_variables_preview() @@ -892,6 +901,13 @@ public function nixpacks_environment_variables_preview() ->where('key', 'like', 'NIXPACKS_%'); } + public function railpack_environment_variables_preview() + { + return $this->morphMany(EnvironmentVariable::class, 'resourceable') + ->where('is_preview', true) + ->where('key', 'like', 'RAILPACK_%'); + } + public function scheduled_tasks(): HasMany { return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc'); @@ -1011,7 +1027,7 @@ public function deploymentType() public function could_set_build_commands(): bool { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { return true; } diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 14879eb96..ebe667437 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -11,6 +11,10 @@ ARG DOCKER_BUILDX_VERSION=0.25.0 ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases ARG NIXPACKS_VERSION=1.41.0 +# https://github.com/railwayapp/railpack/releases +ARG RAILPACK_VERSION=0.21.0 +# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt) +ARG MISE_VERSION=2026.3.12 # https://github.com/minio/mc/releases ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z @@ -25,17 +29,32 @@ ARG DOCKER_COMPOSE_VERSION ARG DOCKER_BUILDX_VERSION ARG PACK_VERSION ARG NIXPACKS_VERSION +ARG RAILPACK_VERSION +ARG MISE_VERSION USER root WORKDIR /artifacts RUN apk add --no-cache bash curl git git-lfs openssh-client tar tini RUN mkdir -p ~/.docker/cli-plugins + +# Install mise (musl build) at the path railpack expects (/tmp/railpack/mise/mise-VERSION). +# Railpack hardcodes a glibc mise download that fails on Alpine, so we pre-place a musl binary. +RUN mkdir -p /tmp/railpack/mise && \ + if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \ + curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64-musl.tar.gz" | tar xz && \ + mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \ + elif [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \ + curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-arm64-musl.tar.gz" | tar xz && \ + mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \ + fi + RUN if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \ curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx && \ curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose && \ (curl -sSL https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \ (curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \ curl -sSL https://nixpacks.com/install.sh | bash && \ + curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \ chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \ ;fi @@ -45,6 +64,7 @@ RUN if [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \ (curl -sSL https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \ (curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux-arm64.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \ curl -sSL https://nixpacks.com/install.sh | bash && \ + curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \ chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \ ;fi diff --git a/openapi.json b/openapi.json index d119176a1..a23a9df40 100644 --- a/openapi.json +++ b/openapi.json @@ -111,6 +111,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -564,6 +565,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -1009,6 +1011,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -1434,6 +1437,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -2442,6 +2446,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -11509,6 +11514,7 @@ "description": "Build pack.", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" diff --git a/openapi.yaml b/openapi.yaml index 7064be28a..e3168d131 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -81,7 +81,7 @@ paths: description: 'The git branch.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' ports_exposes: type: string @@ -371,7 +371,7 @@ paths: description: 'The destination UUID.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' name: type: string @@ -655,7 +655,7 @@ paths: description: 'The destination UUID.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' name: type: string @@ -923,7 +923,7 @@ paths: description: 'The Dockerfile content.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' ports_exposes: type: string @@ -1556,7 +1556,7 @@ paths: description: 'The destination UUID.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' name: type: string @@ -7256,6 +7256,7 @@ components: description: 'Build pack.' enum: - nixpacks + - railpack - static - dockerfile - dockercompose diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index e27eda8b6..639be8f5d 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -31,6 +31,7 @@
+ @@ -218,16 +219,16 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" id="customDockerRunOptions" label="Custom Docker Options" x-bind:disabled="!canUpdate" /> @else @if ($application->could_set_build_commands()) - @if ($buildPack === 'nixpacks') + @if ($buildPack === 'nixpacks' || $buildPack === 'railpack')
- - -
-
Nixpacks will detect the required configuration +
{{ $buildPack === 'railpack' ? 'Railpack' : 'Nixpacks' }} will detect the required configuration automatically. Framework Specific Docs diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 9eb9baea8..3c3313643 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -51,6 +51,7 @@ class="loading loading-xs dark:text-warning loading-spinner">
+ diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index 129c508a9..2d68b1900 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -77,6 +77,7 @@ @endforeach + diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 02489719a..03fc71a5d 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -41,6 +41,7 @@ helper="You can select other branches after configuration is done." /> @endif + diff --git a/tests/Feature/ApplicationBuildpackCleanupTest.php b/tests/Feature/ApplicationBuildpackCleanupTest.php index b6b535a76..857410920 100644 --- a/tests/Feature/ApplicationBuildpackCleanupTest.php +++ b/tests/Feature/ApplicationBuildpackCleanupTest.php @@ -117,6 +117,52 @@ expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1); }); + test('model clears dockerfile fields when build_pack changes from dockerfile to railpack', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockerfile', + 'dockerfile' => 'FROM node:18', + 'dockerfile_location' => '/Dockerfile', + 'dockerfile_target_build' => 'production', + 'custom_healthcheck_found' => true, + ]); + + $application->build_pack = 'railpack'; + $application->save(); + $application->refresh(); + + expect($application->build_pack)->toBe('railpack'); + expect($application->dockerfile)->toBeNull(); + expect($application->dockerfile_location)->toBeNull(); + expect($application->dockerfile_target_build)->toBeNull(); + expect($application->custom_healthcheck_found)->toBeFalse(); + }); + + test('model clears dockercompose fields when build_pack changes from dockercompose to railpack', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => '{"app": "example.com"}', + 'docker_compose_raw' => 'version: "3.8"\nservices:\n app:\n image: nginx', + ]); + + $application->build_pack = 'railpack'; + $application->save(); + $application->refresh(); + + expect($application->build_pack)->toBe('railpack'); + expect($application->docker_compose_domains)->toBeNull(); + expect($application->docker_compose_raw)->toBeNull(); + }); + test('model does not clear dockerfile fields when switching to dockerfile', function () { $team = Team::factory()->create(); $project = Project::factory()->create(['team_id' => $team->id]); diff --git a/tests/Feature/ApplicationRailpackTest.php b/tests/Feature/ApplicationRailpackTest.php new file mode 100644 index 000000000..f3e49cc21 --- /dev/null +++ b/tests/Feature/ApplicationRailpackTest.php @@ -0,0 +1,168 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + expect($application->could_set_build_commands())->toBeTrue(); + }); + + test('could_set_build_commands returns true for nixpacks', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'nixpacks', + ]); + + expect($application->could_set_build_commands())->toBeTrue(); + }); + + test('could_set_build_commands returns false for dockerfile', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockerfile', + ]); + + expect($application->could_set_build_commands())->toBeFalse(); + }); + + test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'REGULAR_VAR', + 'value' => 'value', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '18', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + $railpackVars = $application->railpack_environment_variables; + expect($railpackVars)->toHaveCount(1); + expect($railpackVars->first()->key)->toBe('RAILPACK_NODE_VERSION'); + }); + + test('runtime_environment_variables excludes RAILPACK_ and NIXPACKS_ prefixed vars', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '18', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + $runtimeVars = $application->runtime_environment_variables; + expect($runtimeVars)->toHaveCount(1); + expect($runtimeVars->first()->key)->toBe('APP_ENV'); + }); + + test('railpack_environment_variables_preview returns only RAILPACK_ prefixed preview vars', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'RAILPACK_BUILD_CMD', + 'value' => 'npm run build', + 'is_buildtime' => true, + 'is_preview' => true, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'REGULAR_VAR', + 'value' => 'value', + 'is_buildtime' => false, + 'is_preview' => true, + ]); + + $previewVars = $application->railpack_environment_variables_preview; + expect($previewVars)->toHaveCount(1); + expect($previewVars->first()->key)->toBe('RAILPACK_BUILD_CMD'); + }); +}); diff --git a/tests/Feature/BuildpackSwitchCleanupTest.php b/tests/Feature/BuildpackSwitchCleanupTest.php index b040f9a8f..babd940cb 100644 --- a/tests/Feature/BuildpackSwitchCleanupTest.php +++ b/tests/Feature/BuildpackSwitchCleanupTest.php @@ -111,6 +111,29 @@ expect($application->dockerfile)->toBeNull(); }); + test('clears dockerfile fields when switching from dockerfile to railpack', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'build_pack' => 'dockerfile', + 'dockerfile' => 'FROM node:18', + 'dockerfile_location' => '/Dockerfile', + 'dockerfile_target_build' => 'production', + 'custom_healthcheck_found' => true, + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->set('buildPack', 'railpack') + ->call('updatedBuildPack'); + + $application->refresh(); + expect($application->build_pack)->toBe('railpack'); + expect($application->dockerfile)->toBeNull(); + expect($application->dockerfile_location)->toBeNull(); + expect($application->dockerfile_target_build)->toBeNull(); + expect($application->custom_healthcheck_found)->toBeFalse(); + }); + test('clears dockerfile fields when switching from dockerfile to dockercompose', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, From cddbaf581fd319d9266f31a97194c04ea166fe1e Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 23 Mar 2026 19:02:10 +0000 Subject: [PATCH 007/235] refactor(railpack): extract static image build, fix port logic, bump to v0.22.0 Extract build_railpack_static_image() into its own method, prevent port override when is_static is set, bump Railpack to 0.22.0, and improve test setup with beforeEach and correct polymorphic env var fields. --- app/Jobs/ApplicationDeploymentJob.php | 53 ++++++++++--------- .../Project/New/GithubPrivateRepository.php | 4 +- .../New/GithubPrivateRepositoryDeployKey.php | 4 +- .../Project/New/PublicGitRepository.php | 4 +- docker/coolify-helper/Dockerfile | 2 +- .../ApplicationBuildpackCleanupTest.php | 50 ++++++++++++++--- tests/Feature/ApplicationRailpackTest.php | 38 ++++--------- 7 files changed, 93 insertions(+), 62 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 33905ce59..460a7bf3d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -483,7 +483,7 @@ private function decide_what_to_do() } elseif ($this->application->build_pack === 'railpack') { $this->deploy_railpack_buildpack(); } else { - throw new \RuntimeException("Unsupported build pack: {$this->application->build_pack}"); + throw new DeploymentException("Unsupported build pack: {$this->application->build_pack}"); } $this->post_deployment(); } @@ -2517,35 +2517,40 @@ private function build_railpack_image(): void // Step 3: If static, copy built assets into nginx image if ($this->application->settings->is_static) { - $publishDir = trim($this->application->publish_directory, '/'); - $publishDir = $publishDir ? "/{$publishDir}" : ''; - $dockerfile = base64_encode("FROM {$this->application->static_image} + $this->build_railpack_static_image(); + } + } + + private function build_railpack_static_image(): void + { + $publishDir = trim($this->application->publish_directory, '/'); + $publishDir = $publishDir ? "/{$publishDir}" : ''; + $dockerfile = base64_encode("FROM {$this->application->static_image} WORKDIR /usr/share/nginx/html/ LABEL coolify.deploymentId={$this->deployment_uuid} COPY --from={$this->build_image_name} /app{$publishDir} . COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); - if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { - $nginx_config = base64_encode($this->application->custom_nginx_configuration); - } else { - $nginx_config = $this->application->settings->is_spa - ? base64_encode(defaultNginxConfiguration('spa')) - : base64_encode(defaultNginxConfiguration()); - } - - $static_build = $this->dockerBuildkitSupported - ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}" - : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; - - $base64_static_build = base64_encode($static_build); - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")], - [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")], - [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true], - [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true], - [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true], - ); + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + $nginx_config = $this->application->settings->is_spa + ? base64_encode(defaultNginxConfiguration('spa')) + : base64_encode(defaultNginxConfiguration()); } + + $static_build = $this->dockerBuildkitSupported + ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}" + : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; + + $base64_static_build = base64_encode($static_build); + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + ); } private function generate_coolify_env_variables(bool $forBuildTime = false): Collection diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index c208e2cd2..63240620b 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -84,7 +84,9 @@ public function updatedBuildPack() { if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; - $this->port = 3000; + if (! $this->is_static) { + $this->port = 3000; + } } elseif ($this->build_pack === 'static') { $this->show_is_static = false; $this->is_static = false; diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index f312a9dc0..92d388234 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -97,7 +97,9 @@ public function updatedBuildPack() { if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; - $this->port = 3000; + if (! $this->is_static) { + $this->port = 3000; + } } elseif ($this->build_pack === 'static') { $this->show_is_static = false; $this->is_static = false; diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index eb4ce7b84..dd7a682d1 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -101,7 +101,9 @@ public function updatedBuildPack() { if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; - $this->port = 3000; + if (! $this->isStatic) { + $this->port = 3000; + } } elseif ($this->build_pack === 'static') { $this->show_is_static = false; $this->isStatic = false; diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index ebe667437..1f4ca8788 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -12,7 +12,7 @@ ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases ARG NIXPACKS_VERSION=1.41.0 # https://github.com/railwayapp/railpack/releases -ARG RAILPACK_VERSION=0.21.0 +ARG RAILPACK_VERSION=0.22.0 # https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt) ARG MISE_VERSION=2026.3.12 # https://github.com/minio/mc/releases diff --git a/tests/Feature/ApplicationBuildpackCleanupTest.php b/tests/Feature/ApplicationBuildpackCleanupTest.php index 857410920..0dc0a8303 100644 --- a/tests/Feature/ApplicationBuildpackCleanupTest.php +++ b/tests/Feature/ApplicationBuildpackCleanupTest.php @@ -78,26 +78,29 @@ // Add environment variables that should be deleted EnvironmentVariable::create([ - 'application_id' => $application->id, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, 'key' => 'SERVICE_FQDN_APP', 'value' => 'app.example.com', - 'is_build_time' => false, + 'is_buildtime' => false, 'is_preview' => false, ]); EnvironmentVariable::create([ - 'application_id' => $application->id, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, 'key' => 'SERVICE_URL_APP', 'value' => 'http://app.example.com', - 'is_build_time' => false, + 'is_buildtime' => false, 'is_preview' => false, ]); EnvironmentVariable::create([ - 'application_id' => $application->id, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, 'key' => 'REGULAR_VAR', 'value' => 'should_remain', - 'is_build_time' => false, + 'is_buildtime' => false, 'is_preview' => false, ]); @@ -154,6 +157,34 @@ 'docker_compose_raw' => 'version: "3.8"\nservices:\n app:\n image: nginx', ]); + // Add environment variables that should be deleted + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'SERVICE_FQDN_APP', + 'value' => 'app.example.com', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'SERVICE_URL_APP', + 'value' => 'http://app.example.com', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'REGULAR_VAR', + 'value' => 'should_remain', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + $application->build_pack = 'railpack'; $application->save(); $application->refresh(); @@ -161,6 +192,13 @@ expect($application->build_pack)->toBe('railpack'); expect($application->docker_compose_domains)->toBeNull(); expect($application->docker_compose_raw)->toBeNull(); + + // Verify SERVICE_FQDN_* and SERVICE_URL_* were deleted + expect($application->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0); + expect($application->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0); + + // Verify regular variables remain + expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1); }); test('model does not clear dockerfile fields when switching to dockerfile', function () { diff --git a/tests/Feature/ApplicationRailpackTest.php b/tests/Feature/ApplicationRailpackTest.php index f3e49cc21..59e8a82e0 100644 --- a/tests/Feature/ApplicationRailpackTest.php +++ b/tests/Feature/ApplicationRailpackTest.php @@ -10,13 +10,15 @@ uses(RefreshDatabase::class); describe('Application Railpack Support', function () { - test('could_set_build_commands returns true for railpack', function () { + beforeEach(function () { $team = Team::factory()->create(); $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + }); + test('could_set_build_commands returns true for railpack', function () { $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); @@ -24,12 +26,8 @@ }); test('could_set_build_commands returns true for nixpacks', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'nixpacks', ]); @@ -37,12 +35,8 @@ }); test('could_set_build_commands returns false for dockerfile', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'dockerfile', ]); @@ -50,12 +44,8 @@ }); test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); @@ -92,12 +82,8 @@ }); test('runtime_environment_variables excludes RAILPACK_ and NIXPACKS_ prefixed vars', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); @@ -134,12 +120,8 @@ }); test('railpack_environment_variables_preview returns only RAILPACK_ prefixed preview vars', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); From ff0de0bc31c80245d310c9d39bd6caa653e00914 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:59:29 +0100 Subject: [PATCH 008/235] fix(docker): add docker buildx prune for coolify-railpack builder --- app/Actions/Server/CleanupDocker.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 0d9ca0153..135623b1f 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -51,6 +51,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', $imagePruneCmd, 'docker builder prune -af', + 'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true', "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", From 7dd648e549aa9e86299cbd32dc13e26b17a9dfd9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:59:45 +0100 Subject: [PATCH 009/235] feat(seeders): add railpack-static example application seed data Add ApplicationSeeder entry for railpack-static example with railpack build pack and corresponding application settings configuration. --- database/seeders/ApplicationSeeder.php | 16 ++++++++++++++++ database/seeders/ApplicationSettingsSeeder.php | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 2a0273e0f..edb28c377 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -145,5 +145,21 @@ public function run(): void 'source_id' => 1, 'source_type' => GitlabApp::class, ]); + Application::create([ + 'uuid' => 'railpack-static', + 'name' => 'Railpack Static Example', + 'fqdn' => 'http://railpack-static.127.0.0.1.sslip.io', + 'repository_project_id' => 603035348, + 'git_repository' => 'coollabsio/coolify-examples', + 'git_branch' => 'v4.x', + 'base_directory' => '/static', + 'build_pack' => 'railpack', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GithubApp::class, + ]); } } diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php index 87236df8a..e8be0ba70 100644 --- a/database/seeders/ApplicationSettingsSeeder.php +++ b/database/seeders/ApplicationSettingsSeeder.php @@ -22,5 +22,12 @@ public function run(): void $gitlabPublic->settings->is_static = true; $gitlabPublic->settings->save(); } + + $railpackStatic = Application::where('uuid', 'railpack-static')->first(); + if ($railpackStatic) { + $railpackStatic->load(['settings']); + $railpackStatic->settings->is_static = true; + $railpackStatic->settings->save(); + } } } From 4afcbbb2096df7db98663b2aeb93f315b85431a1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:09:24 +0100 Subject: [PATCH 010/235] fix(deployment): properly escape shell arguments in railpack prepare command --- app/Jobs/ApplicationDeploymentJob.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 13511957f..f4ec4abda 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2454,13 +2454,13 @@ private function build_railpack_image(): void $prepare_command .= " {$this->env_railpack_args}"; } if ($this->application->build_command) { - $prepare_command .= " --build-cmd \"{$this->application->build_command}\""; + $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); } if ($this->application->start_command) { - $prepare_command .= " --start-cmd \"{$this->application->start_command}\""; + $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); } if ($this->application->install_command) { - $prepare_command .= " --env RAILPACK_INSTALL_CMD=\"{$this->application->install_command}\""; + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); } $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}"; From 9c8e5645b42703e2b56c31dd6c9a29c8d3a90d6b Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:20:41 +0530 Subject: [PATCH 011/235] feat(application): make ports_exposes optional for portless apps --- .../Api/ApplicationsController.php | 16 ++++----- app/Jobs/ApplicationDeploymentJob.php | 33 +++++++++++-------- app/Livewire/Project/Application/General.php | 3 +- app/Models/Application.php | 2 +- ...exposes_nullable_in_applications_table.php | 22 +++++++++++++ .../project/application/general.blade.php | 9 ++++- 6 files changed, 60 insertions(+), 25 deletions(-) create mode 100644 database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 66f6a1ef8..6b57d8f5f 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -144,7 +144,7 @@ public function applications(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -309,7 +309,7 @@ public function create_public_application(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -474,7 +474,7 @@ public function create_private_gh_app_application(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -776,7 +776,7 @@ public function create_dockerfile_application(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -1114,7 +1114,7 @@ private function create_application(Request $request, $type) 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1307,7 +1307,7 @@ private function create_application(Request $request, $type) 'git_repository' => 'string|required', 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', 'github_app_uuid' => 'string|required', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', @@ -1534,7 +1534,7 @@ private function create_application(Request $request, $type) 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', 'private_key_uuid' => 'string|required', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', @@ -1835,7 +1835,7 @@ private function create_application(Request $request, $type) $validationRules = [ 'docker_registry_image_name' => 'string|required', 'docker_registry_image_tag' => 'string', - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validator = customApiValidator($request->all(), $validationRules); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 785e8c8e3..41eb81453 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1282,7 +1282,7 @@ private function generate_runtime_environment_variables() // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { - if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) { + if ($this->application->environment_variables->where('key', 'PORT')->isEmpty() && ! empty($ports)) { $envs->push("PORT={$ports[0]}"); } } @@ -2585,7 +2585,7 @@ private function generate_compose_file() 'image' => $this->production_image_name, 'container_name' => $this->container_name, 'restart' => RESTART_MODE, - 'expose' => $ports, + ...(!empty($ports) ? ['expose' => $ports] : []), 'networks' => [ $this->destination->network => [ 'aliases' => array_merge( @@ -2617,16 +2617,19 @@ private function generate_compose_file() // If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used // If healthcheck is disabled, no healthcheck will be added if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) { - $docker_compose['services'][$this->container_name]['healthcheck'] = [ - 'test' => [ - 'CMD-SHELL', - $this->generate_healthcheck_commands(), - ], - 'interval' => $this->application->health_check_interval.'s', - 'timeout' => $this->application->health_check_timeout.'s', - 'retries' => $this->application->health_check_retries, - 'start_period' => $this->application->health_check_start_period.'s', - ]; + $healthcheck_command = $this->generate_healthcheck_commands(); + if ($healthcheck_command !== null) { + $docker_compose['services'][$this->container_name]['healthcheck'] = [ + 'test' => [ + 'CMD-SHELL', + $healthcheck_command, + ], + 'interval' => $this->application->health_check_interval.'s', + 'timeout' => $this->application->health_check_timeout.'s', + 'retries' => $this->application->health_check_retries, + 'start_period' => $this->application->health_check_start_period.'s', + ]; + } } if (! is_null($this->application->limits_cpuset)) { @@ -2836,7 +2839,11 @@ private function generate_healthcheck_commands() // HTTP type healthcheck (default) if (! $this->application->health_check_port) { - $health_check_port = (int) $this->application->ports_exposes_array[0]; + if (! empty($this->application->ports_exposes_array)) { + $health_check_port = (int) $this->application->ports_exposes_array[0]; + } else { + return null; + } } else { $health_check_port = (int) $this->application->health_check_port; } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 5c186af70..885c9dbac 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -153,7 +153,7 @@ protected function rules(): array 'staticImage' => 'required', 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), 'publishDirectory' => ValidationPatterns::directoryPathRules(), - 'portsExposes' => 'required', + 'portsExposes' => 'nullable', 'portsMappings' => 'nullable', 'customNetworkAliases' => 'nullable', 'dockerfile' => 'nullable', @@ -208,7 +208,6 @@ protected function messages(): array 'buildPack.required' => 'The Build Pack field is required.', 'staticImage.required' => 'The Static Image field is required.', 'baseDirectory.required' => 'The Base Directory field is required.', - 'portsExposes.required' => 'The Exposed Ports field is required.', 'isStatic.required' => 'The Static setting is required.', 'isStatic.boolean' => 'The Static setting must be true or false.', 'isSpa.required' => 'The SPA setting is required.', diff --git a/app/Models/Application.php b/app/Models/Application.php index 4cc2dcf74..75b81920d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2147,7 +2147,7 @@ public function setConfig($config) 'config.build_pack' => 'required|string', 'config.base_directory' => 'required|string', 'config.publish_directory' => 'required|string', - 'config.ports_exposes' => 'required|string', + 'config.ports_exposes' => 'nullable|string', 'config.settings.is_static' => 'required|boolean', ]); if ($deepValidator->fails()) { diff --git a/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php b/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php new file mode 100644 index 000000000..ac7b5cb55 --- /dev/null +++ b/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php @@ -0,0 +1,22 @@ +string('ports_exposes')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->string('ports_exposes')->nullable(false)->default('')->change(); + }); + } +}; diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index d743e346e..9539f47dc 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -492,6 +492,13 @@ class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blu
@endif @endif + @if (empty($portsExposes) || $portsExposes === '0') + + This application does not expose any ports and will not be reachable through the proxy or your domains. + This behavior is normal for background workers, bots, or scheduled tasks. + If your application needs to handle HTTP traffic, please specify the port(s) it listens on. + + @endif
@if ($isStatic || $buildPack === 'static') @else - @endif From 8c4865215b9ba90f0d37c7d2e81b351e8974001e Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:26:50 +0530 Subject: [PATCH 012/235] feat(ui): show info callout only when domain is set without exposed ports --- resources/views/livewire/project/application/general.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 9539f47dc..91e462b46 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -492,7 +492,7 @@ class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blu
@endif @endif - @if (empty($portsExposes) || $portsExposes === '0') + @if ((empty($portsExposes) || $portsExposes === '0') && !empty($fqdn)) This application does not expose any ports and will not be reachable through the proxy or your domains. This behavior is normal for background workers, bots, or scheduled tasks. From 886d01405f5da60c62e4fedd7d0c8d212caf1202 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:48:10 +0530 Subject: [PATCH 013/235] feat(applications): add configurable restart loop limit --- app/Actions/Docker/GetContainersStatus.php | 17 +-- app/Livewire/Project/Application/Advanced.php | 19 +++ app/Models/Application.php | 1 + .../Application/RestartLimitReached.php | 140 ++++++++++++++++++ ...rt_count_to_applications_and_databases.php | 22 +++ ...pplication-restart-limit-reached.blade.php | 7 + .../project/application/advanced.blade.php | 8 + 7 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 app/Notifications/Application/RestartLimitReached.php create mode 100644 database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php create mode 100644 resources/views/emails/application-restart-limit-reached.blade.php diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 5966876c6..cfac83583 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -2,6 +2,7 @@ namespace App\Actions\Docker; +use App\Actions\Application\StopApplication; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Shared\ComplexStatusCheck; @@ -9,6 +10,7 @@ use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceDatabase; +use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached; use App\Services\ContainerStatusAggregator; use App\Traits\CalculatesExcludedStatus; use Illuminate\Support\Arr; @@ -475,16 +477,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti 'last_restart_type' => 'crash', ]); - // Send notification - $containerName = $application->name; - $projectUuid = data_get($application, 'environment.project.uuid'); - $environmentName = data_get($application, 'environment.name'); - $applicationUuid = data_get($application, 'uuid'); - - if ($projectUuid && $applicationUuid && $environmentName) { - $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid; - } else { - $url = null; + // Check if restart limit has been reached + $maxAllowedRestarts = $application->max_restart_count ?? 0; + if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) { + StopApplication::dispatch($application); + $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); } } diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index cf7ef3e0b..9954c3422 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -82,6 +82,9 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isConnectToDockerNetworkEnabled = false; + #[Validate(['integer', 'min:0'])] + public int $maxRestartCount = 10; + public function mount() { try { @@ -144,6 +147,7 @@ public function syncData(bool $toModel = false) $this->disableBuildCache = $this->application->settings->disable_build_cache; $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; + $this->maxRestartCount = $this->application->max_restart_count ?? 10; } } @@ -252,6 +256,21 @@ public function saveCustomName() } } + public function saveMaxRestartCount() + { + try { + $this->authorize('update', $this->application); + $this->validate([ + 'maxRestartCount' => 'integer|min:0', + ]); + $this->application->max_restart_count = $this->maxRestartCount; + $this->application->save(); + $this->dispatch('success', 'Max restart count saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.project.application.advanced'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 4cc2dcf74..85a04041b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -125,6 +125,7 @@ class Application extends BaseModel protected $casts = [ 'http_basic_auth_password' => 'encrypted', 'restart_count' => 'integer', + 'max_restart_count' => 'integer', 'last_restart_at' => 'datetime', ]; diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php new file mode 100644 index 000000000..8709e7cd3 --- /dev/null +++ b/app/Notifications/Application/RestartLimitReached.php @@ -0,0 +1,140 @@ +onQueue('high'); + $this->resource_name = data_get($resource, 'name'); + $this->project_uuid = data_get($resource, 'environment.project.uuid'); + $this->environment_uuid = data_get($resource, 'environment.uuid'); + $this->environment_name = data_get($resource, 'environment.name'); + $this->fqdn = data_get($resource, 'fqdn', null); + $this->restart_count = $resource->restart_count; + $this->max_restart_count = $resource->max_restart_count; + if (str($this->fqdn)->explode(',')->count() > 1) { + $this->fqdn = str($this->fqdn)->explode(',')->first(); + } + $this->resource_url = base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->resource->uuid}"; + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('status_change'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: {$this->resource_name} stopped - restart limit reached ({$this->restart_count}/{$this->max_restart_count})"); + $mail->view('emails.application-restart-limit-reached', [ + 'name' => $this->resource_name, + 'fqdn' => $this->fqdn, + 'resource_url' => $this->resource_url, + 'restart_count' => $this->restart_count, + 'max_restart_count' => $this->max_restart_count, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + return new DiscordMessage( + title: ':warning: Restart limit reached', + description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Application in Coolify]({$this->resource_url})", + color: DiscordMessage::errorColor(), + isCritical: true, + ); + } + + public function toTelegram(): array + { + $message = "Coolify: {$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})."; + + return [ + 'message' => $message, + 'buttons' => [ + [ + 'text' => 'Open Application in Coolify', + 'url' => $this->resource_url, + ], + ], + ]; + } + + public function toPushover(): PushoverMessage + { + $message = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})."; + + return new PushoverMessage( + title: 'Restart limit reached', + level: 'error', + message: $message, + buttons: [ + [ + 'text' => 'Open Application in Coolify', + 'url' => $this->resource_url, + ], + ], + ); + } + + public function toSlack(): SlackMessage + { + $title = 'Restart limit reached'; + $description = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})"; + + $description .= "\n\n*Project:* ".data_get($this->resource, 'environment.project.name'); + $description .= "\n*Environment:* {$this->environment_name}"; + $description .= "\n*Application URL:* {$this->resource_url}"; + + return new SlackMessage( + title: $title, + description: $description, + color: SlackMessage::errorColor() + ); + } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => 'Restart limit reached', + 'event' => 'restart_limit_reached', + 'application_name' => $this->resource_name, + 'application_uuid' => $this->resource->uuid, + 'restart_count' => $this->restart_count, + 'max_restart_count' => $this->max_restart_count, + 'url' => $this->resource_url, + 'project' => data_get($this->resource, 'environment.project.name'), + 'environment' => $this->environment_name, + 'fqdn' => $this->fqdn, + ]; + } +} diff --git a/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php b/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php new file mode 100644 index 000000000..578959c9a --- /dev/null +++ b/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php @@ -0,0 +1,22 @@ +integer('max_restart_count')->default(10)->after('restart_count'); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $blueprint) { + $blueprint->dropColumn('max_restart_count'); + }); + } +}; diff --git a/resources/views/emails/application-restart-limit-reached.blade.php b/resources/views/emails/application-restart-limit-reached.blade.php new file mode 100644 index 000000000..e5fa01c00 --- /dev/null +++ b/resources/views/emails/application-restart-limit-reached.blade.php @@ -0,0 +1,7 @@ + +{{ $name }} has been automatically stopped after {{ $restart_count }} crash restarts (limit: {{ $max_restart_count }}). + +The application appears to be in a crash loop. Please investigate the issue and redeploy when ready. + +[Check what is going on]({{ $resource_url }}). + diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 907500dfa..92020334b 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -75,6 +75,14 @@ helper="By default, you do not reach the Coolify defined networks.
Starting a docker compose based resource will have an internal network.
If you connect to a Coolify defined network, you maybe need to use different internal DNS names to connect to a resource.

For more information, check this." canGate="update" :canResource="$application" /> @endif +

Restart Limit

+
+ + Save +

Logs

From 3d9c0b7b50c68efc03e277123dfacfc198237bd7 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:35:49 +0530 Subject: [PATCH 014/235] refactor(migration): align migration name with actual schema change --- ...> 2026_03_27_000000_add_max_restart_count_to_applications.php} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename database/migrations/{2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php => 2026_03_27_000000_add_max_restart_count_to_applications.php} (100%) diff --git a/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php b/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications.php similarity index 100% rename from database/migrations/2026_03_27_000000_add_max_restart_count_to_applications_and_databases.php rename to database/migrations/2026_03_27_000000_add_max_restart_count_to_applications.php From 18508e91495e2f5662ecbf918d82e104aaee4845 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:13:26 +0200 Subject: [PATCH 015/235] fix(railpack): pass build and start commands via --env instead of dedicated flags Replace --build-cmd and --start-cmd with --env RAILPACK_BUILD_CMD and --env RAILPACK_START_CMD to align with how install_command is already passed, matching the expected railpack CLI interface. --- app/Jobs/ApplicationDeploymentJob.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 26cef35d7..e9b4667ab 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2498,10 +2498,10 @@ private function build_railpack_image(): void $prepare_command .= " {$this->env_railpack_args}"; } if ($this->application->build_command) { - $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_BUILD_CMD={$this->application->build_command}"); } if ($this->application->start_command) { - $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_START_CMD={$this->application->start_command}"); } if ($this->application->install_command) { $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); From 0649a424b8468ff8be371643e7ed8d7a5ff539b4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:48:17 +0200 Subject: [PATCH 016/235] fix(buildpack): revert default build pack to nixpacks and reorder selector Change default build_pack from railpack back to nixpacks in all new application flows (GithubPrivateRepository, GithubPrivateRepositoryDeployKey, PublicGitRepository) and reorder the build pack dropdown so Nixpacks appears before Railpack across all relevant views. Add feature tests covering the nixpacks default and selector ordering. --- .../Project/New/GithubPrivateRepository.php | 2 +- .../New/GithubPrivateRepositoryDeployKey.php | 2 +- .../Project/New/PublicGitRepository.php | 2 +- .../project/application/general.blade.php | 2 +- ...ub-private-repository-deploy-key.blade.php | 2 +- .../new/github-private-repository.blade.php | 2 +- .../new/public-git-repository.blade.php | 2 +- ...pplicationGeneralBuildpackSelectorTest.php | 68 +++++++++++++++++++ .../NewApplicationBuildpackDefaultsTest.php | 43 ++++++++++++ 9 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/ApplicationGeneralBuildpackSelectorTest.php create mode 100644 tests/Feature/NewApplicationBuildpackDefaultsTest.php diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index b9db7373f..be7daddd7 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -63,7 +63,7 @@ class GithubPrivateRepository extends Component protected int $page = 1; - public $build_pack = 'railpack'; + public $build_pack = 'nixpacks'; public bool $show_is_static = true; diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 1acf5bc18..e81139792 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -46,7 +46,7 @@ class GithubPrivateRepositoryDeployKey extends Component public string $branch; - public $build_pack = 'railpack'; + public $build_pack = 'nixpacks'; public bool $show_is_static = true; diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 8213f3cd0..fb24ba284 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -58,7 +58,7 @@ class PublicGitRepository extends Component public string $git_repository; - public $build_pack = 'railpack'; + public $build_pack = 'nixpacks'; public bool $show_is_static = true; diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 87465c5d3..2aab1ab92 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -31,8 +31,8 @@
- + diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 3c3313643..ca3c977a7 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -51,8 +51,8 @@ class="loading loading-xs dark:text-warning loading-spinner">
- + diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index 31dbed038..acbff15a6 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -82,8 +82,8 @@ @endforeach - + diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 03fc71a5d..1df5cf907 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -41,8 +41,8 @@ helper="You can select other branches after configuration is done." /> @endif - + diff --git a/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php new file mode 100644 index 000000000..9b4c4c00d --- /dev/null +++ b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php @@ -0,0 +1,68 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + InstanceSettings::unguarded(function () { + InstanceSettings::updateOrCreate(['id' => 0], []); + }); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + 'team_id' => $this->team->id, + ]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']); +}); + +test('existing application buildpack selector lists nixpacks before railpack', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'nixpacks', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSeeInOrder([ + '', + '', + ], false); +}); diff --git a/tests/Feature/NewApplicationBuildpackDefaultsTest.php b/tests/Feature/NewApplicationBuildpackDefaultsTest.php new file mode 100644 index 000000000..49c1ee7b2 --- /dev/null +++ b/tests/Feature/NewApplicationBuildpackDefaultsTest.php @@ -0,0 +1,43 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +describe('new application buildpack defaults', function () { + test('github app repository flow defaults to nixpacks', function () { + Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) + ->assertSet('build_pack', 'nixpacks'); + }); + + test('deploy key repository flow defaults to nixpacks', function () { + Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key']) + ->assertSet('build_pack', 'nixpacks'); + }); + + test('public repository flow defaults to nixpacks and lists railpack second', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->assertSet('build_pack', 'nixpacks'); + }); + + test('public repository flow keeps railpack available after branch lookup', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('branchFound', true) + ->assertSeeInOrder(['Nixpacks', 'Railpack']); + }); +}); From d7e1b7ec375c8f4d0700e845161c5f24ac51b8be Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:45:42 +0200 Subject: [PATCH 017/235] feat(railpack): add config merging, beta badge, and nodejs seeder example - Implement railpack.json + generated config deep merging logic in ApplicationDeploymentJob with JSON validation and assoc array checks - Label Railpack as "Beta" in all build pack selectors and show a visible beta badge when railpack is selected in new-app forms - Add railpack-nodejs Fastify example to ApplicationSeeder - Add ApplicationSeederTest and ApplicationDeploymentRailpackConfigTest covering config merge behavior and seeder correctness --- app/Jobs/ApplicationDeploymentJob.php | 169 ++++++++++++++- database/seeders/ApplicationSeeder.php | 16 ++ .../project/application/general.blade.php | 10 +- ...ub-private-repository-deploy-key.blade.php | 10 +- .../new/github-private-repository.blade.php | 10 +- .../new/public-git-repository.blade.php | 10 +- ...pplicationGeneralBuildpackSelectorTest.php | 20 +- tests/Feature/ApplicationSeederTest.php | 51 +++++ .../NewApplicationBuildpackDefaultsTest.php | 10 +- ...pplicationDeploymentRailpackConfigTest.php | 195 ++++++++++++++++++ 10 files changed, 482 insertions(+), 19 deletions(-) create mode 100644 tests/Feature/ApplicationSeederTest.php create mode 100644 tests/Unit/ApplicationDeploymentRailpackConfigTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e9b4667ab..d5c6b1c80 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -33,6 +33,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Sleep; use Illuminate\Support\Str; +use JsonException; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Throwable; @@ -48,6 +49,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json'; + private const RAILPACK_REPOSITORY_CONFIG_PATH = 'railpack.json'; + + private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; + public $tries = 1; public $timeout = 3600; @@ -2487,28 +2492,170 @@ private function generate_railpack_env_variables(): void $this->env_railpack_args = $this->env_railpack_args->implode(' '); } - private function build_railpack_image(): void + private function decode_railpack_config(string $config, string $source): array { - $this->generate_railpack_env_variables(); + try { + $decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception); + } - // Step 1: Generate build plan with railpack prepare + if (! is_array($decoded)) { + throw new DeploymentException("Invalid {$source}: expected a JSON object."); + } + + return $decoded; + } + + private function is_assoc_array(array $value): bool + { + if ($value === []) { + return false; + } + + return array_keys($value) !== range(0, count($value) - 1); + } + + private function merge_railpack_config(array $base, array $overrides): array + { + foreach ($overrides as $key => $value) { + if ( + array_key_exists($key, $base) + && is_array($base[$key]) + && is_array($value) + && $this->is_assoc_array($base[$key]) + && $this->is_assoc_array($value) + ) { + $base[$key] = $this->merge_railpack_config($base[$key], $value); + } else { + $base[$key] = $value; + } + } + + return $base; + } + + private function railpack_config_overrides(): array + { + return []; + } + + private function railpack_prepare_environment_variables(): Collection + { + $variables = collect([]); + + if ($this->application->install_command) { + $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); + } + + if ($this->application->build_command) { + $variables->put('RAILPACK_BUILD_CMD', $this->application->build_command); + } + + if ($this->application->start_command) { + $variables->put('RAILPACK_START_CMD', $this->application->start_command); + } + + return $variables; + } + + private function generated_railpack_config_relative_path(): string + { + return self::RAILPACK_GENERATED_CONFIG_PATH; + } + + private function generated_railpack_config_absolute_path(): string + { + return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH; + } + + private function generate_railpack_config_file(): ?string + { + $repositoryConfig = []; + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"), + 'hidden' => true, + 'save' => 'railpack_config_exists', + ]); + + if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH), + 'hidden' => true, + 'save' => 'railpack_repository_config', + ]); + + $repositoryConfig = $this->decode_railpack_config( + $this->saved_outputs->get('railpack_repository_config', ''), + 'repository railpack.json' + ); + } + + $overrides = $this->railpack_config_overrides(); + if ($repositoryConfig === [] && $overrides === []) { + return null; + } + + $mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides); + if (! array_key_exists('$schema', $mergedConfig)) { + $mergedConfig['$schema'] = 'https://schema.railpack.com'; + } + + try { + $encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception); + } + + $configPath = $this->generated_railpack_config_absolute_path(); + $encodedConfig = base64_encode($encodedConfig); + + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"), + 'hidden' => true, + ] + ); + + return $this->generated_railpack_config_relative_path(); + } + + private function railpack_prepare_command(?string $configFilePath = null): string + { $prepare_command = 'railpack prepare'; + $prepareEnvironmentVariables = $this->railpack_prepare_environment_variables() + ->map(fn ($value, $key) => "{$key}=".escapeShellValue($value)) + ->implode(' '); + + if ($prepareEnvironmentVariables !== '') { + $prepare_command = "{$prepareEnvironmentVariables} {$prepare_command}"; + } if ($this->env_railpack_args) { $prepare_command .= " {$this->env_railpack_args}"; } - if ($this->application->build_command) { - $prepare_command .= ' --env '.escapeShellValue("RAILPACK_BUILD_CMD={$this->application->build_command}"); - } - if ($this->application->start_command) { - $prepare_command .= ' --env '.escapeShellValue("RAILPACK_START_CMD={$this->application->start_command}"); - } - if ($this->application->install_command) { - $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); + + if ($configFilePath) { + $prepare_command .= ' --config-file '.escapeShellValue($configFilePath); } $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}"; + return $prepare_command; + } + + private function build_railpack_image(): void + { + $this->generate_railpack_env_variables(); + $railpackConfigPath = $this->generate_railpack_config_file(); + + // Step 1: Generate build plan with railpack prepare + $prepare_command = $this->railpack_prepare_command($railpackConfigPath); + $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.'); $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true], diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index edb28c377..212bcce79 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -47,6 +47,22 @@ public function run(): void 'source_id' => 1, 'source_type' => GithubApp::class, ]); + Application::create([ + 'uuid' => 'railpack-nodejs', + 'name' => 'Railpack NodeJS Fastify Example', + 'fqdn' => 'http://railpack-nodejs.127.0.0.1.sslip.io', + 'repository_project_id' => 603035348, + 'git_repository' => 'coollabsio/coolify-examples', + 'git_branch' => 'v4.x', + 'base_directory' => '/nodejs', + 'build_pack' => 'railpack', + 'ports_exposes' => '3000', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GithubApp::class, + ]); Application::create([ 'uuid' => 'dockerfile', 'name' => 'Dockerfile Example', diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 2aab1ab92..382f05278 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -32,7 +32,7 @@ - + @@ -236,11 +236,15 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
-
{{ $buildPack === 'railpack' ? 'Railpack' : 'Nixpacks' }} will detect the required configuration - automatically. + @if ($buildPack === 'nixpacks') +
+ + Nixpacks + will detect the required configuration automatically. Framework Specific Docs
+ @endif @endif @endif diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index ca3c977a7..249ded1f7 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -52,7 +52,7 @@ class="loading loading-xs dark:text-warning loading-spinner"> - + @@ -61,6 +61,14 @@ class="loading loading-xs dark:text-warning loading-spinner"> @endif
+ @if ($build_pack === 'railpack') +
+ + Beta + +
+ @endif @if ($build_pack === 'dockercompose')
- + @@ -93,6 +93,14 @@ helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." /> @endif
+ @if ($build_pack === 'railpack') +
+ + Beta + +
+ @endif @if ($build_pack === 'dockercompose')
- + @@ -52,6 +52,14 @@ helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." /> @endif
+ @if ($build_pack === 'railpack') +
+ + Beta + +
+ @endif @if ($build_pack === 'dockercompose')
Nixpacks', - '', + '', ], false); }); + +test('existing application shows railpack beta badge in build helper copy', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'railpack', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSee('Railpack') + ->assertSee('Beta'); +}); diff --git a/tests/Feature/ApplicationSeederTest.php b/tests/Feature/ApplicationSeederTest.php new file mode 100644 index 000000000..ac39ea4a7 --- /dev/null +++ b/tests/Feature/ApplicationSeederTest.php @@ -0,0 +1,51 @@ +seed([ + UserSeeder::class, + TeamSeeder::class, + PrivateKeySeeder::class, + ServerSeeder::class, + ProjectSeeder::class, + StandaloneDockerSeeder::class, + GithubAppSeeder::class, + ApplicationSeeder::class, + ]); + + $nixpacksExample = Application::where('uuid', 'nodejs')->first(); + $railpackExample = Application::where('uuid', 'railpack-nodejs')->first(); + + expect($nixpacksExample) + ->not->toBeNull() + ->and($nixpacksExample->name)->toBe('NodeJS Fastify Example') + ->and($nixpacksExample->build_pack)->toBe('nixpacks') + ->and($nixpacksExample->base_directory)->toBe('/nodejs') + ->and($nixpacksExample->ports_exposes)->toBe('3000'); + + expect($railpackExample) + ->not->toBeNull() + ->and($railpackExample->name)->toBe('Railpack NodeJS Fastify Example') + ->and($railpackExample->fqdn)->toBe('http://railpack-nodejs.127.0.0.1.sslip.io') + ->and($railpackExample->repository_project_id)->toBe(603035348) + ->and($railpackExample->git_repository)->toBe('coollabsio/coolify-examples') + ->and($railpackExample->git_branch)->toBe('v4.x') + ->and($railpackExample->base_directory)->toBe('/nodejs') + ->and($railpackExample->build_pack)->toBe('railpack') + ->and($railpackExample->ports_exposes)->toBe('3000') + ->and($railpackExample->environment_id)->toBe(1) + ->and($railpackExample->destination_id)->toBe(0) + ->and($railpackExample->source_id)->toBe(1); +}); diff --git a/tests/Feature/NewApplicationBuildpackDefaultsTest.php b/tests/Feature/NewApplicationBuildpackDefaultsTest.php index 49c1ee7b2..f1bcdcb65 100644 --- a/tests/Feature/NewApplicationBuildpackDefaultsTest.php +++ b/tests/Feature/NewApplicationBuildpackDefaultsTest.php @@ -38,6 +38,14 @@ test('public repository flow keeps railpack available after branch lookup', function () { Livewire::test(PublicGitRepository::class, ['type' => 'public']) ->set('branchFound', true) - ->assertSeeInOrder(['Nixpacks', 'Railpack']); + ->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']) + ->assertSee('Beta'); + }); + + test('deploy key repository flow shows railpack beta label in build pack selector', function () { + Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key']) + ->set('current_step', 'repository') + ->assertSee('Railpack (Beta)') + ->assertSee('Beta'); }); }); diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php new file mode 100644 index 000000000..5cd238b99 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -0,0 +1,195 @@ +recordedCommands[] = $commands; + } +} + +function makeRailpackDeploymentJob(array $applicationAttributes = [], array $savedOutputs = []): array +{ + $job = new TestableRailpackDeploymentJob; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + $application = new Application($applicationAttributes); + + foreach ([ + 'application' => $application, + 'workdir' => '/artifacts/test-app', + 'deployment_uuid' => 'deployment-uuid', + 'saved_outputs' => new Collection($savedOutputs), + 'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'", + ] as $property => $value) { + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($job, $value); + } + + return [$job, $reflection]; +} + +function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $method, array $arguments = []): mixed +{ + $reflectionMethod = $reflection->getMethod($method); + $reflectionMethod->setAccessible(true); + + return $reflectionMethod->invokeArgs($job, $arguments); +} + +it('deep merges repository railpack config with coolify overrides', function () { + $repositoryConfigJson = json_encode([ + '$schema' => 'https://schema.railpack.com', + 'packages' => [ + 'node' => '20', + ], + 'steps' => [ + 'build' => [ + 'inputs' => [['step' => 'install']], + 'commands' => ['npm run build'], + ], + ], + 'deploy' => [ + 'variables' => [ + 'NODE_ENV' => 'production', + ], + 'startCommand' => 'node index.js', + ], + ], JSON_THROW_ON_ERROR); + + [$job, $reflection] = makeRailpackDeploymentJob( + [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build:prod', + 'start_command' => 'node server.js', + ], + [ + 'railpack_config_exists' => 'exists', + 'railpack_repository_config' => $repositoryConfigJson, + ], + ); + + $repositoryConfig = invokeRailpackMethod( + $job, + $reflection, + 'decode_railpack_config', + [$repositoryConfigJson, 'repository railpack.json'], + ); + $overrides = [ + 'deploy' => [ + 'variables' => [ + 'APP_ENV' => 'production', + ], + ], + 'packages' => [ + 'python' => '3.13', + ], + ]; + $generatedConfig = invokeRailpackMethod($job, $reflection, 'merge_railpack_config', [$repositoryConfig, $overrides]); + + expect($generatedConfig)->toMatchArray([ + '$schema' => 'https://schema.railpack.com', + 'packages' => [ + 'node' => '20', + 'python' => '3.13', + ], + 'steps' => [ + 'build' => [ + 'inputs' => [['step' => 'install']], + 'commands' => ['npm run build'], + ], + ], + 'deploy' => [ + 'variables' => [ + 'NODE_ENV' => 'production', + 'APP_ENV' => 'production', + ], + 'startCommand' => 'node index.js', + ], + ]); +}); + +it('writes a generated railpack config file when repository config exists', function () { + [$job, $reflection] = makeRailpackDeploymentJob( + ['build_command' => 'npm run build'], + [ + 'railpack_config_exists' => 'exists', + 'railpack_repository_config' => json_encode([ + '$schema' => 'https://schema.railpack.com', + 'steps' => [ + 'build' => [ + 'commands' => ['npm run build'], + ], + ], + ], JSON_THROW_ON_ERROR), + ], + ); + + $configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'); + + expect($configPath)->toBe('.coolify/railpack.generated.json'); + expect($job->recordedCommands)->toHaveCount(3); +}); + +it('does not generate a railpack config file for command overrides alone', function () { + [$job, $reflection] = makeRailpackDeploymentJob([ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + 'start_command' => 'node server.js', + ]); + + $configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'); + + expect($configPath)->toBeNull(); + expect($job->recordedCommands)->toHaveCount(1); +}); + +it('fails fast when repository railpack config is invalid json', function () { + [$job, $reflection] = makeRailpackDeploymentJob( + ['build_command' => 'npm run build'], + [ + 'railpack_config_exists' => 'exists', + 'railpack_repository_config' => '{"steps":{"build":', + ], + ); + + expect(fn () => invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file')) + ->toThrow(DeploymentException::class, 'Invalid repository railpack.json'); +}); + +it('builds railpack prepare command using process env vars for command overrides', function () { + [$job, $reflection] = makeRailpackDeploymentJob( + [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + 'start_command' => 'node server.js', + ], + ); + + $command = invokeRailpackMethod( + $job, + $reflection, + 'railpack_prepare_command', + ['.coolify/railpack.generated.json'], + ); + + expect($command)->toContain("railpack prepare --env 'RAILPACK_NODE_VERSION=22'"); + expect($command)->toContain('RAILPACK_INSTALL_CMD='.escapeshellarg('npm ci')); + expect($command)->toContain('RAILPACK_BUILD_CMD='.escapeshellarg('npm run build')); + expect($command)->toContain('RAILPACK_START_CMD='.escapeshellarg('node server.js')); + expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json')); + expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app'); + expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD="); + expect($command)->not->toContain("--env 'RAILPACK_START_CMD="); + expect($command)->not->toContain("--env 'RAILPACK_INSTALL_CMD="); +}); From 3c51b1aaccee3fdd906c33f9f5527f3fded49e6b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:01:52 +0200 Subject: [PATCH 018/235] fix(railpack): pass command overrides through supported prepare/build args Use Railpack's install env handling and dedicated CLI flags for build/start overrides, and forward install commands into docker build secrets so image builds stay aligned with prepare-time configuration. Update the railpack config test to cover the new command format. --- app/Jobs/ApplicationDeploymentJob.php | 47 +++++++++---------- ...pplicationDeploymentRailpackConfigTest.php | 14 +++--- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index d5c6b1c80..40f917601 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2540,25 +2540,6 @@ private function railpack_config_overrides(): array return []; } - private function railpack_prepare_environment_variables(): Collection - { - $variables = collect([]); - - if ($this->application->install_command) { - $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); - } - - if ($this->application->build_command) { - $variables->put('RAILPACK_BUILD_CMD', $this->application->build_command); - } - - if ($this->application->start_command) { - $variables->put('RAILPACK_START_CMD', $this->application->start_command); - } - - return $variables; - } - private function generated_railpack_config_relative_path(): string { return self::RAILPACK_GENERATED_CONFIG_PATH; @@ -2627,12 +2608,17 @@ private function generate_railpack_config_file(): ?string private function railpack_prepare_command(?string $configFilePath = null): string { $prepare_command = 'railpack prepare'; - $prepareEnvironmentVariables = $this->railpack_prepare_environment_variables() - ->map(fn ($value, $key) => "{$key}=".escapeShellValue($value)) - ->implode(' '); - if ($prepareEnvironmentVariables !== '') { - $prepare_command = "{$prepareEnvironmentVariables} {$prepare_command}"; + if ($this->application->install_command) { + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); + } + + if ($this->application->build_command) { + $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); + } + + if ($this->application->start_command) { + $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); } if ($this->env_railpack_args) { @@ -2681,11 +2667,22 @@ private function build_railpack_image(): void $cache_args = "--build-arg cache-key='{$this->application->uuid}'"; } + $installCommandEnv = ''; + $installCommandSecret = ''; + if ($this->application->install_command) { + $installCommandEnv = 'env RAILPACK_INSTALL_CMD='.escapeShellValue($this->application->install_command).' '; + $installCommandSecret = ' --secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'; + $cache_args .= ' --build-arg secrets-hash='.$this->generate_secrets_hash(collect([ + 'RAILPACK_INSTALL_CMD' => $this->application->install_command, + ])); + } + $build_command = 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' - .' && docker buildx build --builder coolify-railpack' + ." && {$installCommandEnv}docker buildx build --builder coolify-railpack" ." {$this->addHosts} --network host" ." --build-arg BUILDKIT_SYNTAX='ghcr.io/railwayapp/railpack-frontend'" ." {$cache_args}" + ."{$installCommandSecret}" .' -f /artifacts/railpack-plan.json' .' --progress plain' .' --load' diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 5cd238b99..63ad618ae 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -167,7 +167,7 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ ->toThrow(DeploymentException::class, 'Invalid repository railpack.json'); }); -it('builds railpack prepare command using process env vars for command overrides', function () { +it('builds railpack prepare command using railpack env for install and cli flags for build/start overrides', function () { [$job, $reflection] = makeRailpackDeploymentJob( [ 'install_command' => 'npm ci', @@ -183,13 +183,15 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ ['.coolify/railpack.generated.json'], ); - expect($command)->toContain("railpack prepare --env 'RAILPACK_NODE_VERSION=22'"); - expect($command)->toContain('RAILPACK_INSTALL_CMD='.escapeshellarg('npm ci')); - expect($command)->toContain('RAILPACK_BUILD_CMD='.escapeshellarg('npm run build')); - expect($command)->toContain('RAILPACK_START_CMD='.escapeshellarg('node server.js')); + expect($command)->toContain('railpack prepare'); + expect($command)->toContain('--env '.escapeshellarg('RAILPACK_INSTALL_CMD=npm ci')); + expect($command)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); + expect($command)->toContain('--build-cmd '.escapeshellarg('npm run build')); + expect($command)->toContain('--start-cmd '.escapeshellarg('node server.js')); expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json')); expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app'); expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD="); expect($command)->not->toContain("--env 'RAILPACK_START_CMD="); - expect($command)->not->toContain("--env 'RAILPACK_INSTALL_CMD="); + expect($command)->not->toContain('RAILPACK_BUILD_CMD='); + expect($command)->not->toContain('RAILPACK_START_CMD='); }); From 6b1b1b14f295c73f4d90587809bb4b81ce6a3ddd Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:50:01 +0530 Subject: [PATCH 019/235] feat(ui): move Sentinel to dedicated tab with sidebar navigation and logs page --- app/Livewire/Server/Charts.php | 24 +++ app/Livewire/Server/Sentinel.php | 19 +- app/Livewire/Server/Sentinel/Logs.php | 31 ++++ app/Livewire/Server/Sentinel/Show.php | 28 +++ .../server/sidebar-sentinel.blade.php | 10 ++ .../views/components/server/sidebar.blade.php | 5 - .../views/livewire/server/charts.blade.php | 25 ++- .../views/livewire/server/navbar.blade.php | 26 ++- .../views/livewire/server/sentinel.blade.php | 170 +++++++----------- .../livewire/server/sentinel/logs.blade.php | 13 ++ .../livewire/server/sentinel/show.blade.php | 16 ++ routes/web.php | 6 +- 12 files changed, 242 insertions(+), 131 deletions(-) create mode 100644 app/Livewire/Server/Sentinel/Logs.php create mode 100644 app/Livewire/Server/Sentinel/Show.php create mode 100644 resources/views/components/server/sidebar-sentinel.blade.php create mode 100644 resources/views/livewire/server/sentinel/logs.blade.php create mode 100644 resources/views/livewire/server/sentinel/show.blade.php diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index d0db87f57..c09c11b60 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -2,11 +2,15 @@ namespace App\Livewire\Server; +use App\Actions\Server\StartSentinel; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Charts extends Component { + use AuthorizesRequests; + public Server $server; public $chartId = 'server'; @@ -28,6 +32,26 @@ public function mount(string $server_uuid) } } + public function toggleMetrics() + { + try { + $this->authorize('update', $this->server); + $this->server->settings->is_metrics_enabled = ! $this->server->settings->is_metrics_enabled; + $this->server->settings->save(); + $this->server->refresh(); + + if ($this->server->isMetricsEnabled()) { + StartSentinel::run($this->server, true); + $this->dispatch('success', 'Metrics enabled. Restarting Sentinel.'); + } else { + $this->server->restartSentinel(); + $this->dispatch('success', 'Metrics disabled. Restarting Sentinel.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function pollData() { if ($this->poll || $this->interval <= 10) { diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index a4b35891b..f90af5a16 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -15,8 +15,6 @@ class Sentinel extends Component public Server $server; - public array $parameters = []; - public bool $isMetricsEnabled; #[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])] @@ -51,15 +49,9 @@ public function getListeners() ]; } - public function mount(string $server_uuid) + public function mount() { - try { - $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); - $this->parameters = get_route_parameters(); - $this->syncData(); - } catch (\Throwable) { - return redirect()->route('server.index'); - } + $this->syncData(); } public function syncData(bool $toModel = false) @@ -110,20 +102,21 @@ public function restartSentinel() } } - public function updatedIsSentinelEnabled($value) + public function toggleSentinel() { try { $this->authorize('manageSentinel', $this->server); - if ($value === true) { + if (! $this->isSentinelEnabled) { if ($this->server->isBuildServer()) { - $this->isSentinelEnabled = false; $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); return; } + $this->isSentinelEnabled = true; $customImage = isDev() ? $this->sentinelCustomDockerImage : null; StartSentinel::run($this->server, true, null, $customImage); } else { + $this->isSentinelEnabled = false; $this->isMetricsEnabled = false; $this->isSentinelDebugEnabled = false; StopSentinel::dispatch($this->server); diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php new file mode 100644 index 000000000..513776a6f --- /dev/null +++ b/app/Livewire/Server/Sentinel/Logs.php @@ -0,0 +1,31 @@ +parameters = get_route_parameters(); + try { + $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->first(); + if (is_null($this->server)) { + return redirect()->route('server.index'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.sentinel.logs'); + } +} diff --git a/app/Livewire/Server/Sentinel/Show.php b/app/Livewire/Server/Sentinel/Show.php new file mode 100644 index 000000000..4bfc4c398 --- /dev/null +++ b/app/Livewire/Server/Sentinel/Show.php @@ -0,0 +1,28 @@ +parameters = get_route_parameters(); + try { + $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.sentinel.show'); + } +} diff --git a/resources/views/components/server/sidebar-sentinel.blade.php b/resources/views/components/server/sidebar-sentinel.blade.php new file mode 100644 index 000000000..8249c9413 --- /dev/null +++ b/resources/views/components/server/sidebar-sentinel.blade.php @@ -0,0 +1,10 @@ + diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 2d7649fab..82ec985e3 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -6,11 +6,6 @@ href="{{ route('server.advanced', ['server_uuid' => $server->uuid]) }}">Advanced @endif - @if ($server->isFunctional() && !$server->isSwarm() && !$server->isBuildServer()) - Sentinel - - @endif Private Key diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 51953ab9a..0acb79b93 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -6,7 +6,18 @@
-

Metrics

+
+

Metrics

+ @if ($server->isMetricsEnabled()) + + Disable Metrics + + @elseif ($server->isSentinelEnabled()) + + Enable Metrics + + @endif +
Basic metrics for your server.
@if ($server->isMetricsEnabled())
@@ -288,8 +299,16 @@
@else -
Metrics are disabled for this server. Enable them in Sentinel settings.
+ @if ($server->isSentinelEnabled()) + + Metrics are disabled for this server. Click "Enable Metrics" above to start collecting metrics. + + @else + + Metrics require Sentinel to be enabled. + Please enable Sentinel first. + + @endif @endif
diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index 4e53cd80e..bf55ca7f6 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -58,6 +58,17 @@ class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
@endif + @if ($server->isSentinelEnabled()) +
+
+ @if ($server->isSentinelLive()) + + @else + + @endif +
+
+ @endif
{{ data_get($server, 'name') }}