From 8a0b37c85128ee756e24e58e5e29d0cc48bbf081 Mon Sep 17 00:00:00 2001 From: "Mgs. M. Rizqi Fadhlurrahman" Date: Fri, 31 Oct 2025 08:45:42 +0700 Subject: [PATCH 01/20] chore: update Nixpacks version to 1.41.0 --- docker/coolify-helper/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 212703798..14879eb96 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -10,7 +10,7 @@ ARG DOCKER_BUILDX_VERSION=0.25.0 # https://github.com/buildpacks/pack/releases ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases -ARG NIXPACKS_VERSION=1.40.0 +ARG NIXPACKS_VERSION=1.41.0 # https://github.com/minio/mc/releases ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z From ac2a199c7b90fdf40739be8753f6dba9f706e408 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:31:09 +0100 Subject: [PATCH 02/20] chore: update coolify version to 4.0.0-beta.442 This update increments the version of coolify to 4.0.0-beta.442 to reflect the latest changes and improvements. Additionally, the nightly version has been updated to 4.0.0-beta.443 to ensure consistency across the project. Keeping version numbers up to date is essential for tracking releases and ensuring that users are aware of the latest features and fixes. --- config/constants.php | 2 +- other/nightly/versions.json | 4 ++-- versions.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/constants.php b/config/constants.php index fd2adb860..39e1753fe 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.441', + 'version' => '4.0.0-beta.442', 'helper_version' => '1.0.11', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 5d070a6bb..a83b4c8ce 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.441" + "version": "4.0.0-beta.442" }, "nightly": { - "version": "4.0.0-beta.442" + "version": "4.0.0-beta.443" }, "helper": { "version": "1.0.11" diff --git a/versions.json b/versions.json index 5d070a6bb..a83b4c8ce 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.441" + "version": "4.0.0-beta.442" }, "nightly": { - "version": "4.0.0-beta.442" + "version": "4.0.0-beta.443" }, "helper": { "version": "1.0.11" From fed01ab1a22d46aa708af10f92f42f7fb2d65aac Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:31:53 +0100 Subject: [PATCH 03/20] fix: update releases URL to use correct domain The releases URL in the configuration was updated to use the correct domain 'coolify.io' instead of 'coollabs.io'. This change ensures that the application points to the right resource for fetching release information, which is crucial for maintaining accurate and up-to-date deployment processes. --- config/constants.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/constants.php b/config/constants.php index 39e1753fe..25581f4ad 100644 --- a/config/constants.php +++ b/config/constants.php @@ -12,7 +12,7 @@ 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'), 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), - 'releases_url' => 'https://cdn.coollabs.io/coolify/releases.json', + 'releases_url' => 'https://cdn.coolify.io/releases.json', ], 'urls' => [ From 0865ecd3dbe96350a315c48c0280d9b4f1193b72 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:42:12 +0100 Subject: [PATCH 04/20] refactor: move RestoreDatabase command to Cloud namespace This change organizes the command within the appropriate Cloud namespace, improving code structure and maintainability. By grouping related commands together, it enhances clarity for future developers and helps in locating files more efficiently. --- app/Console/Commands/Cloud/RestoreDatabase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Console/Commands/Cloud/RestoreDatabase.php b/app/Console/Commands/Cloud/RestoreDatabase.php index 6c60d1c6c..7c6c0d4c6 100644 --- a/app/Console/Commands/Cloud/RestoreDatabase.php +++ b/app/Console/Commands/Cloud/RestoreDatabase.php @@ -1,6 +1,6 @@ Date: Wed, 5 Nov 2025 14:54:13 +0100 Subject: [PATCH 05/20] refactor: rename sync function and improve error handling The function previously named syncGitHubReleases has been renamed to syncReleasesToGitHubRepo for clarity, as it now focuses on syncing releases directly to the GitHub repository instead of the CDN. Additionally, error handling has been enhanced to provide more informative messages during the cloning, branching, and committing processes. This refactor aims to improve the maintainability of the code and ensure better feedback in case of failures. --- app/Console/Commands/SyncBunny.php | 122 +++++++++++++++++++++-------- 1 file changed, 90 insertions(+), 32 deletions(-) diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index b0cd24715..1a76b33d1 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -26,9 +26,9 @@ class SyncBunny extends Command protected $description = 'Sync files to BunnyCDN'; /** - * Fetch GitHub releases and sync to CDN + * Fetch GitHub releases and sync to GitHub repository */ - private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn) + private function syncReleasesToGitHubRepo(): bool { $this->info('Fetching releases from GitHub...'); try { @@ -37,33 +37,95 @@ private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny 'per_page' => 30, // Fetch more releases for better changelog ]); - if ($response->successful()) { - $releases = $response->json(); - - // Save releases to a temporary file - $releases_file = "$parent_dir/releases.json"; - file_put_contents($releases_file, json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - - // Upload to CDN - Http::pool(fn (Pool $pool) => [ - $pool->storage(fileName: $releases_file)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/releases.json"), - $pool->purge("$bunny_cdn/coolify/releases.json"), - ]); - - // Clean up temporary file - unlink($releases_file); - - $this->info('releases.json uploaded & purged...'); - $this->info('Total releases synced: '.count($releases)); - - return true; - } else { + if (! $response->successful()) { $this->error('Failed to fetch releases from GitHub: '.$response->status()); return false; } + + $releases = $response->json(); + $timestamp = time(); + $tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp; + $branchName = 'update-releases-'.$timestamp; + + // Clone the repository + $this->info('Cloning coolify-cdn repository...'); + exec("gh repo clone coollabsio/coolify-cdn $tmpDir 2>&1", $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to clone repository: '.implode("\n", $output)); + + return false; + } + + // Create feature branch + $this->info('Creating feature branch...'); + exec("cd $tmpDir && git checkout -b $branchName 2>&1", $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to create branch: '.implode("\n", $output)); + exec("rm -rf $tmpDir"); + + return false; + } + + // Write releases.json + $this->info('Writing releases.json...'); + $releasesPath = "$tmpDir/json/releases.json"; + file_put_contents($releasesPath, json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + // Stage and commit + $this->info('Committing changes...'); + exec("cd $tmpDir && git add json/releases.json 2>&1", $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to stage changes: '.implode("\n", $output)); + exec("rm -rf $tmpDir"); + + return false; + } + + $commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s'); + exec("cd $tmpDir && git commit -m '$commitMessage' 2>&1", $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to commit changes: '.implode("\n", $output)); + exec("rm -rf $tmpDir"); + + return false; + } + + // Push to remote + $this->info('Pushing branch to remote...'); + exec("cd $tmpDir && git push origin $branchName 2>&1", $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to push branch: '.implode("\n", $output)); + exec("rm -rf $tmpDir"); + + return false; + } + + // Create pull request + $this->info('Creating pull request...'); + $prTitle = 'Update releases.json - '.date('Y-m-d H:i:s'); + $prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API'; + $prCommand = "gh pr create --repo coollabsio/coolify-cdn --title '$prTitle' --body '$prBody' --base main --head $branchName 2>&1"; + exec($prCommand, $output, $returnCode); + + // Clean up + exec("rm -rf $tmpDir"); + + if ($returnCode !== 0) { + $this->error('Failed to create PR: '.implode("\n", $output)); + + return false; + } + + $this->info('Pull request created successfully!'); + if (! empty($output)) { + $this->info('PR Output: '.implode("\n", $output)); + } + $this->info('Total releases synced: '.count($releases)); + + return true; } catch (\Throwable $e) { - $this->error('Error fetching releases: '.$e->getMessage()); + $this->error('Error syncing releases: '.$e->getMessage()); return false; } @@ -174,11 +236,7 @@ public function handle() return; } - // First sync GitHub releases - $this->info('Syncing GitHub releases first...'); - $this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn); - - // Then sync versions.json + // Sync versions.json to BunnyCDN Http::pool(fn (Pool $pool) => [ $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), @@ -187,14 +245,14 @@ public function handle() return; } elseif ($only_github_releases) { - $this->info('About to sync GitHub releases to BunnyCDN.'); + $this->info('About to sync GitHub releases to GitHub repository.'); $confirmed = confirm('Are you sure you want to sync GitHub releases?'); if (! $confirmed) { return; } - // Use the reusable function - $this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn); + // Sync releases to GitHub repository + $this->syncReleasesToGitHubRepo(); return; } From 239e28630f9490661af7957f2c8be1cec0b3e3a9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 08:43:43 +0100 Subject: [PATCH 06/20] style: update background colors to use gray-50 for consistency in auth views --- resources/css/app.css | 2 +- resources/views/auth/login.blade.php | 4 +- resources/views/auth/register.blade.php | 19 +- resources/views/auth/reset-password.blade.php | 20 +- .../views/auth/two-factor-challenge.blade.php | 2 +- resources/views/layouts/base.blade.php | 426 +++++++++--------- 6 files changed, 241 insertions(+), 232 deletions(-) diff --git a/resources/css/app.css b/resources/css/app.css index fa1e61cb2..70759e542 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -82,7 +82,7 @@ @keyframes lds-heart { */ html, body { - @apply w-full min-h-full bg-neutral-50 dark:bg-base dark:text-neutral-400; + @apply w-full min-h-full bg-gray-50 dark:bg-base dark:text-neutral-400; } body { diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index f85dc268e..ede49117a 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -61,7 +61,7 @@ class="text-sm dark:text-neutral-400 hover:text-coollabs dark:hover:text-warning
- + Don't have an account?
@@ -82,7 +82,7 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d
- or + or continue with
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 3db943726..cdfa52a98 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -33,7 +33,8 @@ function getOldOrLocal($key, $localValue)

Root User Setup

-

This user will be the root user with full admin access.

+

This user will be the root user with full + admin access.

@@ -58,13 +59,16 @@ function getOldOrLocal($key, $localValue) -
+

- Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol. + Your password should be min 8 characters long and contain at least one uppercase letter, + one lowercase letter, one number, and one symbol.

- + Create Account @@ -74,17 +78,18 @@ function getOldOrLocal($key, $localValue)
- + Already have an account?
- + {{ __('auth.already_registered') }} - + \ No newline at end of file diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php index a4a07ebd6..3e0c237b4 100644 --- a/resources/views/auth/reset-password.blade.php +++ b/resources/views/auth/reset-password.blade.php @@ -47,16 +47,19 @@ label="{{ __('input.email') }}" /> - + -
+

- Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol. + Your password should be min 8 characters long and contain at least one uppercase letter, + one lowercase letter, one number, and one symbol.

- + {{ __('auth.reset_password') }} @@ -66,17 +69,18 @@
- + Remember your password?
- + Back to Login - + \ No newline at end of file diff --git a/resources/views/auth/two-factor-challenge.blade.php b/resources/views/auth/two-factor-challenge.blade.php index d4531cbe8..05dbcc90c 100644 --- a/resources/views/auth/two-factor-challenge.blade.php +++ b/resources/views/auth/two-factor-challenge.blade.php @@ -120,7 +120,7 @@ class="mt-2 text-sm dark:text-neutral-400 hover:text-black dark:hover:text-white
- + Need help?
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index c577f7248..7bb366cd4 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -2,7 +2,7 @@ - + }, timeout); + return; + } else { + window.location.reload(); + } + }) + window.Livewire.on('info', (message) => { + if (typeof message === 'string') { + window.toast('Info', { + type: 'info', + description: message, + }) + return; + } + if (message.length == 1) { + window.toast('Info', { + type: 'info', + description: message[0], + }) + } else if (message.length == 2) { + window.toast(message[0], { + type: 'info', + description: message[1], + }) + } + }) + window.Livewire.on('error', (message) => { + if (typeof message === 'string') { + window.toast('Error', { + type: 'danger', + description: message, + }) + return; + } + if (message.length == 1) { + window.toast('Error', { + type: 'danger', + description: message[0], + }) + } else if (message.length == 2) { + window.toast(message[0], { + type: 'danger', + description: message[1], + }) + } + }) + window.Livewire.on('warning', (message) => { + if (typeof message === 'string') { + window.toast('Warning', { + type: 'warning', + description: message, + }) + return; + } + if (message.length == 1) { + window.toast('Warning', { + type: 'warning', + description: message[0], + }) + } else if (message.length == 2) { + window.toast(message[0], { + type: 'warning', + description: message[1], + }) + } + }) + window.Livewire.on('success', (message) => { + if (typeof message === 'string') { + window.toast('Success', { + type: 'success', + description: message, + }) + return; + } + if (message.length == 1) { + window.toast('Success', { + type: 'success', + description: message[0], + }) + } else if (message.length == 2) { + window.toast(message[0], { + type: 'success', + description: message[1], + }) + } + }) + }); + + @show - + \ No newline at end of file From dbf7957795b60020663759ac0ba9172375a3a58f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 08:54:35 +0100 Subject: [PATCH 07/20] fix: inserting ARG statements in Dockerfile after FROM instructions --- app/Jobs/ApplicationDeploymentJob.php | 55 +++++++++++++++++++++------ 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index a240a759a..1dfcaaafc 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3226,6 +3226,20 @@ private function generate_secrets_hash($variables) return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key); } + protected function findFromInstructionLines($dockerfile): array + { + $fromLines = []; + foreach ($dockerfile as $index => $line) { + $trimmedLine = trim($line); + // Check if line starts with FROM (case-insensitive) + if (preg_match('/^FROM\s+/i', $trimmedLine)) { + $fromLines[] = $index; + } + } + + return $fromLines; + } + private function add_build_env_variables_to_dockerfile() { if ($this->dockerBuildkitSupported) { @@ -3238,6 +3252,18 @@ private function add_build_env_variables_to_dockerfile() 'ignore_errors' => true, ]); $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); + + // Find all FROM instruction positions + $fromLines = $this->findFromInstructionLines($dockerfile); + + // If no FROM instructions found, skip ARG insertion + if (empty($fromLines)) { + return; + } + + // Collect all ARG statements to insert + $argsToInsert = collect(); + if ($this->pull_request_id === 0) { // Only add environment variables that are available during build $envs = $this->application->environment_variables() @@ -3246,9 +3272,9 @@ private function add_build_env_variables_to_dockerfile() ->get(); foreach ($envs as $env) { if (data_get($env, 'is_multiline') === true) { - $dockerfile->splice(1, 0, ["ARG {$env->key}"]); + $argsToInsert->push("ARG {$env->key}"); } else { - $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); + $argsToInsert->push("ARG {$env->key}={$env->real_value}"); } } // Add Coolify variables as ARGs @@ -3258,9 +3284,7 @@ private function add_build_env_variables_to_dockerfile() ->map(function ($var) { return "ARG {$var}"; }); - foreach ($coolify_vars as $arg) { - $dockerfile->splice(1, 0, [$arg]); - } + $argsToInsert = $argsToInsert->merge($coolify_vars); } } else { // Only add preview environment variables that are available during build @@ -3270,9 +3294,9 @@ private function add_build_env_variables_to_dockerfile() ->get(); foreach ($envs as $env) { if (data_get($env, 'is_multiline') === true) { - $dockerfile->splice(1, 0, ["ARG {$env->key}"]); + $argsToInsert->push("ARG {$env->key}"); } else { - $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); + $argsToInsert->push("ARG {$env->key}={$env->real_value}"); } } // Add Coolify variables as ARGs @@ -3282,15 +3306,24 @@ private function add_build_env_variables_to_dockerfile() ->map(function ($var) { return "ARG {$var}"; }); - foreach ($coolify_vars as $arg) { - $dockerfile->splice(1, 0, [$arg]); - } + $argsToInsert = $argsToInsert->merge($coolify_vars); } } + // Add secrets hash if we have environment variables if ($envs->isNotEmpty()) { $secrets_hash = $this->generate_secrets_hash($envs); - $dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]); + $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); + } + + // Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers) + if ($argsToInsert->isNotEmpty()) { + foreach (array_reverse($fromLines) as $fromLineIndex) { + // Insert all ARGs after this FROM instruction + foreach ($argsToInsert->reverse() as $arg) { + $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); + } + } } $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); From 4968e9fa2bf622df15da5292534f9709a5d64338 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 08:54:40 +0100 Subject: [PATCH 08/20] test: add unit tests for Dockerfile ARG insertion logic --- tests/Unit/DockerfileArgInsertionTest.php | 218 ++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 tests/Unit/DockerfileArgInsertionTest.php diff --git a/tests/Unit/DockerfileArgInsertionTest.php b/tests/Unit/DockerfileArgInsertionTest.php new file mode 100644 index 000000000..593f09145 --- /dev/null +++ b/tests/Unit/DockerfileArgInsertionTest.php @@ -0,0 +1,218 @@ +makePartial(); + + $dockerfile = collect([ + 'FROM node:16', + 'WORKDIR /app', + 'COPY . .', + ]); + + $result = $job->findFromInstructionLines($dockerfile); + + expect($result)->toBe([0]); +}); + +it('finds FROM instructions with comments before', function () { + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $dockerfile = collect([ + '# Build stage', + '# Another comment', + 'FROM node:16', + 'WORKDIR /app', + ]); + + $result = $job->findFromInstructionLines($dockerfile); + + expect($result)->toBe([2]); +}); + +it('finds multiple FROM instructions in multi-stage dockerfile', function () { + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $dockerfile = collect([ + 'FROM node:16 AS builder', + 'WORKDIR /app', + 'RUN npm install', + '', + 'FROM nginx:alpine', + 'COPY --from=builder /app/dist /usr/share/nginx/html', + ]); + + $result = $job->findFromInstructionLines($dockerfile); + + expect($result)->toBe([0, 4]); +}); + +it('handles FROM with different cases', function () { + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $dockerfile = collect([ + 'from node:16', + 'From nginx:alpine', + 'FROM alpine:latest', + ]); + + $result = $job->findFromInstructionLines($dockerfile); + + expect($result)->toBe([0, 1, 2]); +}); + +it('returns empty array when no FROM instructions found', function () { + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $dockerfile = collect([ + '# Just comments', + 'WORKDIR /app', + 'RUN npm install', + ]); + + $result = $job->findFromInstructionLines($dockerfile); + + expect($result)->toBe([]); +}); + +it('inserts ARGs after FROM in simple dockerfile', function () { + $dockerfile = collect([ + 'FROM node:16', + 'WORKDIR /app', + 'COPY . .', + ]); + + $fromLines = [0]; + $argsToInsert = collect(['ARG MY_VAR=value', 'ARG ANOTHER_VAR']); + + foreach (array_reverse($fromLines) as $fromLineIndex) { + foreach ($argsToInsert->reverse() as $arg) { + $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); + } + } + + expect($dockerfile[0])->toBe('FROM node:16'); + expect($dockerfile[1])->toBe('ARG MY_VAR=value'); + expect($dockerfile[2])->toBe('ARG ANOTHER_VAR'); + expect($dockerfile[3])->toBe('WORKDIR /app'); +}); + +it('inserts ARGs after each FROM in multi-stage dockerfile', function () { + $dockerfile = collect([ + 'FROM node:16 AS builder', + 'WORKDIR /app', + '', + 'FROM nginx:alpine', + 'COPY --from=builder /app/dist /usr/share/nginx/html', + ]); + + $fromLines = [0, 3]; + $argsToInsert = collect(['ARG MY_VAR=value']); + + foreach (array_reverse($fromLines) as $fromLineIndex) { + foreach ($argsToInsert->reverse() as $arg) { + $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); + } + } + + // First stage + expect($dockerfile[0])->toBe('FROM node:16 AS builder'); + expect($dockerfile[1])->toBe('ARG MY_VAR=value'); + expect($dockerfile[2])->toBe('WORKDIR /app'); + + // Second stage (index shifted by +1 due to inserted ARG) + expect($dockerfile[4])->toBe('FROM nginx:alpine'); + expect($dockerfile[5])->toBe('ARG MY_VAR=value'); +}); + +it('inserts ARGs after FROM when comments precede FROM', function () { + $dockerfile = collect([ + '# Build stage comment', + 'FROM node:16', + 'WORKDIR /app', + ]); + + $fromLines = [1]; + $argsToInsert = collect(['ARG MY_VAR=value']); + + foreach (array_reverse($fromLines) as $fromLineIndex) { + foreach ($argsToInsert->reverse() as $arg) { + $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); + } + } + + expect($dockerfile[0])->toBe('# Build stage comment'); + expect($dockerfile[1])->toBe('FROM node:16'); + expect($dockerfile[2])->toBe('ARG MY_VAR=value'); + expect($dockerfile[3])->toBe('WORKDIR /app'); +}); + +it('handles real-world nuxt multi-stage dockerfile with comments', function () { + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $dockerfile = collect([ + '# Build Stage 1', + '', + 'FROM node:22-alpine AS build', + 'WORKDIR /app', + '', + 'RUN corepack enable', + '', + '# Copy package.json and your lockfile, here we add pnpm-lock.yaml for illustration', + 'COPY package.json pnpm-lock.yaml .npmrc ./', + '', + '# Install dependencies', + 'RUN pnpm i', + '', + '# Copy the entire project', + 'COPY . ./', + '', + '# Build the project', + 'RUN pnpm run build', + '', + '# Build Stage 2', + '', + 'FROM node:22-alpine', + 'WORKDIR /app', + '', + '# Only `.output` folder is needed from the build stage', + 'COPY --from=build /app/.output/ ./', + '', + '# Change the port and host', + 'ENV PORT=80', + 'ENV HOST=0.0.0.0', + '', + 'EXPOSE 80', + '', + 'CMD ["node", "/app/server/index.mjs"]', + ]); + + // Find FROM instructions + $fromLines = $job->findFromInstructionLines($dockerfile); + + expect($fromLines)->toBe([2, 21]); + + // Simulate ARG insertion + $argsToInsert = collect(['ARG BUILD_VAR=production']); + + foreach (array_reverse($fromLines) as $fromLineIndex) { + foreach ($argsToInsert->reverse() as $arg) { + $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); + } + } + + // Verify first stage + expect($dockerfile[2])->toBe('FROM node:22-alpine AS build'); + expect($dockerfile[3])->toBe('ARG BUILD_VAR=production'); + expect($dockerfile[4])->toBe('WORKDIR /app'); + + // Verify second stage (index shifted by +1 due to first ARG insertion) + expect($dockerfile[22])->toBe('FROM node:22-alpine'); + expect($dockerfile[23])->toBe('ARG BUILD_VAR=production'); + expect($dockerfile[24])->toBe('WORKDIR /app'); +}); From df3dd84dfcfa117870060cee3ed87e474433fa1e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 09:03:17 +0100 Subject: [PATCH 09/20] rebranded gcool to jean --- gcool.json => jean.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename gcool.json => jean.json (100%) diff --git a/gcool.json b/jean.json similarity index 100% rename from gcool.json rename to jean.json From d21ab6e11b6df37d22031f3ef7311a5d9c7761fc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 09:04:45 +0100 Subject: [PATCH 10/20] fixed jean.json --- jean.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jean.json b/jean.json index 629d8569a..0be55f89b 100644 --- a/jean.json +++ b/jean.json @@ -1,6 +1,6 @@ { "scripts": { - "onWorktreeCreate": "cp $GCOOL_ROOT_PATH/.env .", + "onWorktreeCreate": "cp $JEAN_ROOT_PATH/.env .", "run": "docker rm -f coolify coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite && spin up" } } From 88aa24057b9441ca8cf243d2e6837219a56865c8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 09:21:41 +0100 Subject: [PATCH 11/20] fix: update environment variable mapping in deployment job --- app/Jobs/ApplicationDeploymentJob.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index a240a759a..08a6aa9cc 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3289,7 +3289,10 @@ private function add_build_env_variables_to_dockerfile() } if ($envs->isNotEmpty()) { - $secrets_hash = $this->generate_secrets_hash($envs); + $envs_mapped = $envs->mapWithKeys(function ($env) { + return [$env->key => $env->real_value]; + }); + $secrets_hash = $this->generate_secrets_hash($envs_mapped); $dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]); } From 1ab5dbca208e7c94258cde84b5fe54b80a6fbb18 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:30:03 +0100 Subject: [PATCH 12/20] fix: preserve empty strings and remove empty sections in docker-compose - Preserve empty string environment variables instead of converting to null Empty strings and null have different semantics in Docker Compose: * Empty string (VAR: ""): Variable is set to "" in container (e.g., HTTP_PROXY="" means "no proxy") * Null (VAR: null): Variable is unset/removed from container environment - Remove empty top-level sections (volumes, configs, secrets) from generated compose files These sections now only appear when they contain actual content, following Docker Compose best practices - Add safety check for missing volumes in validateComposeFile to prevent iteration errors - Add comprehensive unit tests for both fixes Fixes #7126 --- bootstrap/helpers/docker.php | 3 + bootstrap/helpers/parsers.php | 52 ++++- ...ckerComposeEmptyStringPreservationTest.php | 188 +++++++++++++++++ ...DockerComposeEmptyTopLevelSectionsTest.php | 194 ++++++++++++++++++ 4 files changed, 427 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/DockerComposeEmptyStringPreservationTest.php create mode 100644 tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index d6c9b5bdf..5bccb50f1 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1073,6 +1073,9 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable } $yaml_compose = Yaml::parse($compose); foreach ($yaml_compose['services'] as $service_name => $service) { + if (! isset($service['volumes'])) { + continue; + } foreach ($service['volumes'] as $volume_name => $volume) { if (data_get($volume, 'type') === 'bind' && data_get($volume, 'content')) { unset($yaml_compose['services'][$service_name]['volumes'][$volume_name]['content']); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 01ae50f6b..beb643d7d 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1164,13 +1164,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $environment = $environment->filter(function ($value, $key) { return ! str($key)->startsWith('SERVICE_FQDN_'); })->map(function ($value, $key) use ($resource) { - // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used + // Preserve empty strings; only override if database value exists and is non-empty + // This is important because empty strings and null have different semantics in Docker: + // - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy") + // - Null: Variable is unset/removed from container environment if (str($value)->isEmpty()) { - if ($resource->environment_variables()->where('key', $key)->exists()) { - $value = $resource->environment_variables()->where('key', $key)->first()->value; - } else { - $value = null; + $dbEnv = $resource->environment_variables()->where('key', $key)->first(); + // Only use database override if it exists AND has a non-empty value + if ($dbEnv && str($dbEnv->value)->isNotEmpty()) { + $value = $dbEnv->value; } + // Keep empty string as-is (don't convert to null) } return $value; @@ -1299,6 +1303,18 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int return array_search($key, $customOrder); }); + // Remove empty top-level sections (volumes, networks, configs, secrets) + // Keep only non-empty sections to match Docker Compose best practices + $topLevel = $topLevel->filter(function ($value, $key) { + // Always keep 'services' section + if ($key === 'services') { + return true; + } + + // Keep section only if it has content + return $value instanceof Collection ? $value->isNotEmpty() : ! empty($value); + }); + $cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2); $resource->docker_compose = $cleanedCompose; @@ -2122,13 +2138,17 @@ function serviceParser(Service $resource): Collection $environment = $environment->filter(function ($value, $key) { return ! str($key)->startsWith('SERVICE_FQDN_'); })->map(function ($value, $key) use ($resource) { - // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used + // Preserve empty strings; only override if database value exists and is non-empty + // This is important because empty strings and null have different semantics in Docker: + // - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy") + // - Null: Variable is unset/removed from container environment if (str($value)->isEmpty()) { - if ($resource->environment_variables()->where('key', $key)->exists()) { - $value = $resource->environment_variables()->where('key', $key)->first()->value; - } else { - $value = null; + $dbEnv = $resource->environment_variables()->where('key', $key)->first(); + // Only use database override if it exists AND has a non-empty value + if ($dbEnv && str($dbEnv->value)->isNotEmpty()) { + $value = $dbEnv->value; } + // Keep empty string as-is (don't convert to null) } return $value; @@ -2251,6 +2271,18 @@ function serviceParser(Service $resource): Collection return array_search($key, $customOrder); }); + // Remove empty top-level sections (volumes, networks, configs, secrets) + // Keep only non-empty sections to match Docker Compose best practices + $topLevel = $topLevel->filter(function ($value, $key) { + // Always keep 'services' section + if ($key === 'services') { + return true; + } + + // Keep section only if it has content + return $value instanceof Collection ? $value->isNotEmpty() : ! empty($value); + }); + $cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2); $resource->docker_compose = $cleanedCompose; diff --git a/tests/Unit/DockerComposeEmptyStringPreservationTest.php b/tests/Unit/DockerComposeEmptyStringPreservationTest.php new file mode 100644 index 000000000..71f59ce81 --- /dev/null +++ b/tests/Unit/DockerComposeEmptyStringPreservationTest.php @@ -0,0 +1,188 @@ +toBeTrue('applicationParser function should exist'); + + // The code should NOT unconditionally set $value = null for empty strings + // Instead, it should preserve empty strings when no database override exists + + // Check for the pattern where we only override with database values when they're non-empty + // We're checking the fix is in place by looking for the logic pattern + $pattern1 = str_contains($parsersFile, 'if (str($value)->isEmpty())'); + expect($pattern1)->toBeTrue('Empty string check should exist'); +}); + +it('ensures parsers.php preserves empty strings in service parser', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Find the serviceParser function's environment mapping logic + $hasServiceParser = str_contains($parsersFile, 'function serviceParser('); + expect($hasServiceParser)->toBeTrue('serviceParser function should exist'); + + // The code should NOT unconditionally set $value = null for empty strings + // Same check as above for service parser + $pattern1 = str_contains($parsersFile, 'if (str($value)->isEmpty())'); + expect($pattern1)->toBeTrue('Empty string check should exist'); +}); + +it('verifies YAML parsing preserves empty strings correctly', function () { + // Test that Symfony YAML parser handles empty strings as we expect + $yamlWithEmptyString = <<<'YAML' +environment: + HTTP_PROXY: "" + HTTPS_PROXY: '' + NO_PROXY: "localhost" +YAML; + + $parsed = Yaml::parse($yamlWithEmptyString); + + // Empty strings should remain as empty strings, not null + expect($parsed['environment']['HTTP_PROXY'])->toBe(''); + expect($parsed['environment']['HTTPS_PROXY'])->toBe(''); + expect($parsed['environment']['NO_PROXY'])->toBe('localhost'); +}); + +it('verifies YAML parsing handles null values correctly', function () { + // Test that null values are preserved as null + $yamlWithNull = <<<'YAML' +environment: + HTTP_PROXY: null + HTTPS_PROXY: + NO_PROXY: "localhost" +YAML; + + $parsed = Yaml::parse($yamlWithNull); + + // Null should remain null + expect($parsed['environment']['HTTP_PROXY'])->toBeNull(); + expect($parsed['environment']['HTTPS_PROXY'])->toBeNull(); + expect($parsed['environment']['NO_PROXY'])->toBe('localhost'); +}); + +it('verifies YAML serialization preserves empty strings', function () { + // Test that empty strings serialize back correctly + $data = [ + 'environment' => [ + 'HTTP_PROXY' => '', + 'HTTPS_PROXY' => '', + 'NO_PROXY' => 'localhost', + ], + ]; + + $yaml = Yaml::dump($data, 10, 2); + + // Empty strings should be serialized with quotes + expect($yaml)->toContain("HTTP_PROXY: ''"); + expect($yaml)->toContain("HTTPS_PROXY: ''"); + expect($yaml)->toContain('NO_PROXY: localhost'); + + // Should NOT contain "null" + expect($yaml)->not->toContain('HTTP_PROXY: null'); +}); + +it('verifies YAML serialization handles null values', function () { + // Test that null values serialize as null + $data = [ + 'environment' => [ + 'HTTP_PROXY' => null, + 'HTTPS_PROXY' => null, + 'NO_PROXY' => 'localhost', + ], + ]; + + $yaml = Yaml::dump($data, 10, 2); + + // Null should be serialized as "null" + expect($yaml)->toContain('HTTP_PROXY: null'); + expect($yaml)->toContain('HTTPS_PROXY: null'); + expect($yaml)->toContain('NO_PROXY: localhost'); + + // Should NOT contain empty quotes for null values + expect($yaml)->not->toContain("HTTP_PROXY: ''"); +}); + +it('verifies empty string round-trip through YAML', function () { + // Test full round-trip: empty string -> YAML -> parse -> serialize -> parse + $original = [ + 'environment' => [ + 'HTTP_PROXY' => '', + 'NO_PROXY' => 'localhost', + ], + ]; + + // Serialize to YAML + $yaml1 = Yaml::dump($original, 10, 2); + + // Parse back + $parsed1 = Yaml::parse($yaml1); + + // Verify empty string is preserved + expect($parsed1['environment']['HTTP_PROXY'])->toBe(''); + expect($parsed1['environment']['NO_PROXY'])->toBe('localhost'); + + // Serialize again + $yaml2 = Yaml::dump($parsed1, 10, 2); + + // Parse again + $parsed2 = Yaml::parse($yaml2); + + // Should still be empty string, not null + expect($parsed2['environment']['HTTP_PROXY'])->toBe(''); + expect($parsed2['environment']['NO_PROXY'])->toBe('localhost'); + + // Both YAML representations should be equivalent + expect($yaml1)->toBe($yaml2); +}); + +it('verifies str()->isEmpty() behavior with empty strings and null', function () { + // Test Laravel's str()->isEmpty() helper behavior + + // Empty string should be considered empty + expect(str('')->isEmpty())->toBeTrue(); + + // Null should be considered empty + expect(str(null)->isEmpty())->toBeTrue(); + + // String with content should not be empty + expect(str('value')->isEmpty())->toBeFalse(); + + // This confirms that we need additional logic to distinguish + // between empty string ('') and null, since both are "isEmpty" +}); + +it('verifies the distinction between empty string and null in PHP', function () { + // Document PHP's behavior for empty strings vs null + + $emptyString = ''; + $nullValue = null; + + // They are different values + expect($emptyString === $nullValue)->toBeFalse(); + + // Empty string is not null + expect($emptyString === '')->toBeTrue(); + expect($nullValue === null)->toBeTrue(); + + // isset() treats them differently + $arrayWithEmpty = ['key' => '']; + $arrayWithNull = ['key' => null]; + + expect(isset($arrayWithEmpty['key']))->toBeTrue(); + expect(isset($arrayWithNull['key']))->toBeFalse(); +}); diff --git a/tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php b/tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php new file mode 100644 index 000000000..bfd674053 --- /dev/null +++ b/tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php @@ -0,0 +1,194 @@ +toContain('Remove empty top-level sections') + ->toContain('->filter(function ($value, $key)'); +}); + +it('verifies YAML dump produces empty objects for empty arrays', function () { + // Demonstrate the problem: empty arrays serialize as empty objects + $data = [ + 'services' => ['web' => ['image' => 'nginx']], + 'volumes' => [], + 'configs' => [], + 'secrets' => [], + ]; + + $yaml = Yaml::dump($data, 10, 2); + + // Empty arrays become empty objects in YAML + expect($yaml)->toContain('volumes: { }'); + expect($yaml)->toContain('configs: { }'); + expect($yaml)->toContain('secrets: { }'); +}); + +it('verifies YAML dump omits keys that are not present', function () { + // Demonstrate the solution: omit empty keys entirely + $data = [ + 'services' => ['web' => ['image' => 'nginx']], + // Don't include volumes, configs, secrets at all + ]; + + $yaml = Yaml::dump($data, 10, 2); + + // Keys that don't exist are not in the output + expect($yaml)->not->toContain('volumes:'); + expect($yaml)->not->toContain('configs:'); + expect($yaml)->not->toContain('secrets:'); + expect($yaml)->toContain('services:'); +}); + +it('verifies collection filter removes empty items', function () { + // Test Laravel Collection filter behavior + $collection = collect([ + 'services' => collect(['web' => ['image' => 'nginx']]), + 'volumes' => collect([]), + 'networks' => collect(['coolify' => ['external' => true]]), + 'configs' => collect([]), + 'secrets' => collect([]), + ]); + + $filtered = $collection->filter(function ($value, $key) { + // Always keep services + if ($key === 'services') { + return true; + } + + // Keep only non-empty collections + return $value->isNotEmpty(); + }); + + // Should have services and networks (non-empty) + expect($filtered)->toHaveKey('services'); + expect($filtered)->toHaveKey('networks'); + + // Should NOT have volumes, configs, secrets (empty) + expect($filtered)->not->toHaveKey('volumes'); + expect($filtered)->not->toHaveKey('configs'); + expect($filtered)->not->toHaveKey('secrets'); +}); + +it('verifies filtered collections serialize cleanly to YAML', function () { + // Full test: filter then serialize + $collection = collect([ + 'services' => collect(['web' => ['image' => 'nginx']]), + 'volumes' => collect([]), + 'networks' => collect(['coolify' => ['external' => true]]), + 'configs' => collect([]), + 'secrets' => collect([]), + ]); + + $filtered = $collection->filter(function ($value, $key) { + if ($key === 'services') { + return true; + } + + return $value->isNotEmpty(); + }); + + $yaml = Yaml::dump($filtered->toArray(), 10, 2); + + // Should have services and networks + expect($yaml)->toContain('services:'); + expect($yaml)->toContain('networks:'); + + // Should NOT have empty sections + expect($yaml)->not->toContain('volumes:'); + expect($yaml)->not->toContain('configs:'); + expect($yaml)->not->toContain('secrets:'); +}); + +it('ensures services section is always kept even if empty', function () { + // Services should never be filtered out + $collection = collect([ + 'services' => collect([]), + 'volumes' => collect([]), + ]); + + $filtered = $collection->filter(function ($value, $key) { + if ($key === 'services') { + return true; // Always keep + } + + return $value->isNotEmpty(); + }); + + // Services should be present + expect($filtered)->toHaveKey('services'); + + // Volumes should be removed + expect($filtered)->not->toHaveKey('volumes'); +}); + +it('verifies non-empty sections are preserved', function () { + // Non-empty sections should remain + $collection = collect([ + 'services' => collect(['web' => ['image' => 'nginx']]), + 'volumes' => collect(['data' => ['driver' => 'local']]), + 'networks' => collect(['coolify' => ['external' => true]]), + 'configs' => collect(['app_config' => ['file' => './config']]), + 'secrets' => collect(['db_password' => ['file' => './secret']]), + ]); + + $filtered = $collection->filter(function ($value, $key) { + if ($key === 'services') { + return true; + } + + return $value->isNotEmpty(); + }); + + // All sections should be present (none are empty) + expect($filtered)->toHaveKey('services'); + expect($filtered)->toHaveKey('volumes'); + expect($filtered)->toHaveKey('networks'); + expect($filtered)->toHaveKey('configs'); + expect($filtered)->toHaveKey('secrets'); + + // Count should be 5 (all original keys) + expect($filtered->count())->toBe(5); +}); + +it('verifies mixed empty and non-empty sections', function () { + // Mixed scenario: some empty, some not + $collection = collect([ + 'services' => collect(['web' => ['image' => 'nginx']]), + 'volumes' => collect([]), // Empty + 'networks' => collect(['coolify' => ['external' => true]]), // Not empty + 'configs' => collect([]), // Empty + 'secrets' => collect(['db_password' => ['file' => './secret']]), // Not empty + ]); + + $filtered = $collection->filter(function ($value, $key) { + if ($key === 'services') { + return true; + } + + return $value->isNotEmpty(); + }); + + // Should have: services, networks, secrets + expect($filtered)->toHaveKey('services'); + expect($filtered)->toHaveKey('networks'); + expect($filtered)->toHaveKey('secrets'); + + // Should NOT have: volumes, configs + expect($filtered)->not->toHaveKey('volumes'); + expect($filtered)->not->toHaveKey('configs'); + + // Count should be 3 + expect($filtered->count())->toBe(3); +}); From f89c5d2b21687b36bcac27f294ea9adeb92cbcd0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:41:04 +0100 Subject: [PATCH 13/20] fix: enhance onWorktreeCreate script to include directory creation and settings copy --- jean.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jean.json b/jean.json index 0be55f89b..c625e08c0 100644 --- a/jean.json +++ b/jean.json @@ -1,6 +1,6 @@ { "scripts": { - "onWorktreeCreate": "cp $JEAN_ROOT_PATH/.env .", + "onWorktreeCreate": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json", "run": "docker rm -f coolify coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite && spin up" } } From bcd225bd22d494f7160185afe7ac2aa5eeb7af77 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:30:39 +0100 Subject: [PATCH 14/20] feat: Implement required port validation for service applications - Added `requiredPort` property to `ServiceApplicationView` to track the required port for services. - Introduced modal confirmation for removing required ports, including methods to confirm or cancel the action. - Enhanced `Service` model with `getRequiredPort` and `requiresPort` methods to retrieve port information from service templates. - Implemented `extractPortFromUrl` method in `ServiceApplication` to extract port from FQDN URLs. - Updated frontend views to display warnings when required ports are missing from domains. - Created unit tests for service port validation and extraction logic, ensuring correct behavior for various scenarios. - Added feature tests for Livewire component handling of domain submissions with required ports. --- app/Livewire/Project/Service/EditDomain.php | 55 ++++++ .../Service/ServiceApplicationView.php | 55 ++++++ app/Models/Service.php | 25 +++ app/Models/ServiceApplication.php | 47 +++++ bootstrap/helpers/parsers.php | 39 ++-- .../project/service/edit-domain.blade.php | 67 ++++++- .../service-application-view.blade.php | 71 ++++++- .../Feature/DatabaseBackupCreationApiTest.php | 2 - .../Service/EditDomainPortValidationTest.php | 154 ++++++++++++++++ ...ckerComposeEmptyStringPreservationTest.php | 128 ++++++++++++- tests/Unit/Policies/PrivateKeyPolicyTest.php | 1 - .../Unit/ServicePortSpecificVariablesTest.php | 174 ++++++++++++++++++ tests/Unit/ServiceRequiredPortTest.php | 153 +++++++++++++++ 13 files changed, 938 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/Service/EditDomainPortValidationTest.php create mode 100644 tests/Unit/ServicePortSpecificVariablesTest.php create mode 100644 tests/Unit/ServiceRequiredPortTest.php diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 371c860ca..a9a7de878 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -22,6 +22,12 @@ class EditDomain extends Component public $forceSaveDomains = false; + public $showPortWarningModal = false; + + public $forceRemovePort = false; + + public $requiredPort = null; + #[Validate(['nullable'])] public ?string $fqdn = null; @@ -33,6 +39,7 @@ public function mount() { $this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId); $this->authorize('view', $this->application); + $this->requiredPort = $this->application->service->getRequiredPort(); $this->syncData(); } @@ -58,6 +65,19 @@ public function confirmDomainUsage() $this->submit(); } + public function confirmRemovePort() + { + $this->forceRemovePort = true; + $this->showPortWarningModal = false; + $this->submit(); + } + + public function cancelRemovePort() + { + $this->showPortWarningModal = false; + $this->syncData(); // Reset to original FQDN + } + public function submit() { try { @@ -91,6 +111,41 @@ public function submit() $this->forceSaveDomains = false; } + // Check for required port + if (! $this->forceRemovePort) { + $service = $this->application->service; + $requiredPort = $service->getRequiredPort(); + + if ($requiredPort !== null) { + // Check if all FQDNs have a port + $fqdns = str($this->fqdn)->trim()->explode(','); + $missingPort = false; + + foreach ($fqdns as $fqdn) { + $fqdn = trim($fqdn); + if (empty($fqdn)) { + continue; + } + + $port = ServiceApplication::extractPortFromUrl($fqdn); + if ($port === null) { + $missingPort = true; + break; + } + } + + if ($missingPort) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + + return; + } + } + } else { + // Reset the force flag after using it + $this->forceRemovePort = false; + } + $this->validate(); $this->application->save(); $this->application->refresh(); diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 09392ab09..1d8d8b247 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -30,6 +30,12 @@ class ServiceApplicationView extends Component public $forceSaveDomains = false; + public $showPortWarningModal = false; + + public $forceRemovePort = false; + + public $requiredPort = null; + #[Validate(['nullable'])] public ?string $humanName = null; @@ -129,12 +135,26 @@ public function mount() try { $this->parameters = get_route_parameters(); $this->authorize('view', $this->application); + $this->requiredPort = $this->application->service->getRequiredPort(); $this->syncData(); } catch (\Throwable $e) { return handleError($e, $this); } } + public function confirmRemovePort() + { + $this->forceRemovePort = true; + $this->showPortWarningModal = false; + $this->submit(); + } + + public function cancelRemovePort() + { + $this->showPortWarningModal = false; + $this->syncData(); // Reset to original FQDN + } + public function syncData(bool $toModel = false): void { if ($toModel) { @@ -246,6 +266,41 @@ public function submit() $this->forceSaveDomains = false; } + // Check for required port + if (! $this->forceRemovePort) { + $service = $this->application->service; + $requiredPort = $service->getRequiredPort(); + + if ($requiredPort !== null) { + // Check if all FQDNs have a port + $fqdns = str($this->fqdn)->trim()->explode(','); + $missingPort = false; + + foreach ($fqdns as $fqdn) { + $fqdn = trim($fqdn); + if (empty($fqdn)) { + continue; + } + + $port = ServiceApplication::extractPortFromUrl($fqdn); + if ($port === null) { + $missingPort = true; + break; + } + } + + if ($missingPort) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + + return; + } + } + } else { + // Reset the force flag after using it + $this->forceRemovePort = false; + } + $this->validate(); $this->application->save(); $this->application->refresh(); diff --git a/app/Models/Service.php b/app/Models/Service.php index c4b8623e0..12d3d6a11 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1184,6 +1184,31 @@ public function documentation() return data_get($service, 'documentation', config('constants.urls.docs')); } + /** + * Get the required port for this service from the template definition. + */ + public function getRequiredPort(): ?int + { + try { + $services = get_service_templates(); + $serviceName = str($this->name)->beforeLast('-')->value(); + $service = data_get($services, $serviceName, []); + $port = data_get($service, 'port'); + + return $port ? (int) $port : null; + } catch (\Throwable) { + return null; + } + } + + /** + * Check if this service requires a port to function correctly. + */ + public function requiresPort(): bool + { + return $this->getRequiredPort() !== null; + } + public function applications() { return $this->hasMany(ServiceApplication::class); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 5cafc9042..49bd56206 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -118,6 +118,53 @@ public function fqdns(): Attribute ); } + /** + * Extract port number from a given FQDN URL. + * Returns null if no port is specified. + */ + public static function extractPortFromUrl(string $url): ?int + { + try { + // Ensure URL has a scheme for proper parsing + if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) { + $url = 'http://'.$url; + } + + $parsed = parse_url($url); + $port = $parsed['port'] ?? null; + + return $port ? (int) $port : null; + } catch (\Throwable) { + return null; + } + } + + /** + * Check if all FQDNs have a port specified. + */ + public function allFqdnsHavePort(): bool + { + if (is_null($this->fqdn) || $this->fqdn === '') { + return false; + } + + $fqdns = explode(',', $this->fqdn); + + foreach ($fqdns as $fqdn) { + $fqdn = trim($fqdn); + if (empty($fqdn)) { + continue; + } + + $port = self::extractPortFromUrl($fqdn); + if ($port === null) { + return false; + } + } + + return true; + } + public function getFilesFromServer(bool $isInit = false) { getFilesystemVolumesFromServer($this, $isInit); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index beb643d7d..1deec45d7 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1164,17 +1164,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $environment = $environment->filter(function ($value, $key) { return ! str($key)->startsWith('SERVICE_FQDN_'); })->map(function ($value, $key) use ($resource) { - // Preserve empty strings; only override if database value exists and is non-empty - // This is important because empty strings and null have different semantics in Docker: + // Preserve empty strings and null values with correct Docker Compose semantics: // - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy") - // - Null: Variable is unset/removed from container environment - if (str($value)->isEmpty()) { + // - Null: Variable is unset/removed from container environment (may inherit from host) + if ($value === null) { + // User explicitly wants variable unset - respect that + // NEVER override from database - null means "inherit from environment" + // Keep as null (will be excluded from container environment) + } elseif ($value === '') { + // Empty string - allow database override for backward compatibility $dbEnv = $resource->environment_variables()->where('key', $key)->first(); // Only use database override if it exists AND has a non-empty value if ($dbEnv && str($dbEnv->value)->isNotEmpty()) { $value = $dbEnv->value; } - // Keep empty string as-is (don't convert to null) + // Otherwise keep empty string as-is } return $value; @@ -1605,21 +1609,22 @@ function serviceParser(Service $resource): Collection ]); } if (substr_count(str($key)->value(), '_') === 3) { - $newKey = str($key)->beforeLast('_'); + // For port-specific variables (e.g., SERVICE_FQDN_UMAMI_3000), + // keep the port suffix in the key and use the URL with port $resource->environment_variables()->updateOrCreate([ - 'key' => $newKey->value(), + 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $fqdn, + 'value' => $fqdnWithPort, 'is_preview' => false, ]); $resource->environment_variables()->updateOrCreate([ - 'key' => $newKey->value(), + 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $url, + 'value' => $urlWithPort, 'is_preview' => false, ]); } @@ -2138,17 +2143,21 @@ function serviceParser(Service $resource): Collection $environment = $environment->filter(function ($value, $key) { return ! str($key)->startsWith('SERVICE_FQDN_'); })->map(function ($value, $key) use ($resource) { - // Preserve empty strings; only override if database value exists and is non-empty - // This is important because empty strings and null have different semantics in Docker: + // Preserve empty strings and null values with correct Docker Compose semantics: // - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy") - // - Null: Variable is unset/removed from container environment - if (str($value)->isEmpty()) { + // - Null: Variable is unset/removed from container environment (may inherit from host) + if ($value === null) { + // User explicitly wants variable unset - respect that + // NEVER override from database - null means "inherit from environment" + // Keep as null (will be excluded from container environment) + } elseif ($value === '') { + // Empty string - allow database override for backward compatibility $dbEnv = $resource->environment_variables()->where('key', $key)->first(); // Only use database override if it exists AND has a non-empty value if ($dbEnv && str($dbEnv->value)->isNotEmpty()) { $value = $dbEnv->value; } - // Keep empty string as-is (don't convert to null) + // Otherwise keep empty string as-is } return $value; diff --git a/resources/views/livewire/project/service/edit-domain.blade.php b/resources/views/livewire/project/service/edit-domain.blade.php index a126eca5b..0691146f6 100644 --- a/resources/views/livewire/project/service/edit-domain.blade.php +++ b/resources/views/livewire/project/service/edit-domain.blade.php @@ -1,7 +1,13 @@
-
Note: If a service has a defined port, do not delete it.
If you want to use your custom - domain, you can add it with a port.
+ @if($requiredPort) + + This service requires port {{ $requiredPort }} to function correctly. All domains must include this port number (or any other port if you know what you're doing). +

+ Example: http://app.coolify.io:{{ $requiredPort }} +
+ @endif + @@ -18,4 +24,61 @@ + + @if ($showPortWarningModal) +
+ +
+ @endif
diff --git a/resources/views/livewire/project/service/service-application-view.blade.php b/resources/views/livewire/project/service/service-application-view.blade.php index b95dc6540..5fb4a62d0 100644 --- a/resources/views/livewire/project/service/service-application-view.blade.php +++ b/resources/views/livewire/project/service/service-application-view.blade.php @@ -22,6 +22,14 @@ @endcan
+ @if($requiredPort && !$application->serviceType()?->contains(str($application->image)->before(':'))) + + This service requires port {{ $requiredPort }} to function correctly. All domains must include this port number (or any other port if you know what you're doing). +

+ Example: http://app.coolify.io:{{ $requiredPort }} +
+ @endif +
@@ -68,9 +76,9 @@
-
    @@ -81,4 +89,61 @@
+ + @if ($showPortWarningModal) +
+ +
+ @endif
diff --git a/tests/Feature/DatabaseBackupCreationApiTest.php b/tests/Feature/DatabaseBackupCreationApiTest.php index 16a65dff2..893141de3 100644 --- a/tests/Feature/DatabaseBackupCreationApiTest.php +++ b/tests/Feature/DatabaseBackupCreationApiTest.php @@ -1,7 +1,5 @@ user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + + // Create server + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + + // Create standalone docker destination + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + ]); + + // Create project and environment + $this->project = Project::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + // Create service with a name that maps to a template with required port + $this->service = Service::factory()->create([ + 'name' => 'supabase-test123', + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + // Create service application + $this->serviceApplication = ServiceApplication::factory()->create([ + 'service_id' => $this->service->id, + 'fqdn' => 'http://example.com:8000', + ]); + + // Mock get_service_templates to return a service with required port + if (! function_exists('get_service_templates_mock')) { + function get_service_templates_mock() + { + return collect([ + 'supabase' => [ + 'name' => 'Supabase', + 'port' => '8000', + 'documentation' => 'https://supabase.com', + ], + ]); + } + } +}); + +it('loads the EditDomain component with required port', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->assertSet('requiredPort', 8000) + ->assertSet('fqdn', 'http://example.com:8000') + ->assertOk(); +}); + +it('shows warning modal when trying to remove required port', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com') // Remove port + ->call('submit') + ->assertSet('showPortWarningModal', true) + ->assertSet('requiredPort', 8000); +}); + +it('allows port removal when user confirms', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com') // Remove port + ->call('submit') + ->assertSet('showPortWarningModal', true) + ->call('confirmRemovePort') + ->assertSet('showPortWarningModal', false); + + // Verify the FQDN was updated in database + $this->serviceApplication->refresh(); + expect($this->serviceApplication->fqdn)->toBe('http://example.com'); +}); + +it('cancels port removal when user cancels', function () { + $originalFqdn = $this->serviceApplication->fqdn; + + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com') // Remove port + ->call('submit') + ->assertSet('showPortWarningModal', true) + ->call('cancelRemovePort') + ->assertSet('showPortWarningModal', false) + ->assertSet('fqdn', $originalFqdn); // Should revert to original +}); + +it('allows saving when port is changed to different port', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com:3000') // Change to different port + ->call('submit') + ->assertSet('showPortWarningModal', false); // Should not show warning + + // Verify the FQDN was updated + $this->serviceApplication->refresh(); + expect($this->serviceApplication->fqdn)->toBe('http://example.com:3000'); +}); + +it('allows saving when all domains have ports (multiple domains)', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com:8000,https://app.example.com:8080') + ->call('submit') + ->assertSet('showPortWarningModal', false); // Should not show warning +}); + +it('shows warning when at least one domain is missing port (multiple domains)', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com:8000,https://app.example.com') // Second domain missing port + ->call('submit') + ->assertSet('showPortWarningModal', true); +}); + +it('does not show warning for services without required port', function () { + // Create a service without required port (e.g., cloudflared) + $serviceWithoutPort = Service::factory()->create([ + 'name' => 'cloudflared-test456', + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $appWithoutPort = ServiceApplication::factory()->create([ + 'service_id' => $serviceWithoutPort->id, + 'fqdn' => 'http://example.com', + ]); + + Livewire::test(EditDomain::class, ['applicationId' => $appWithoutPort->id]) + ->set('fqdn', 'http://example.com') // No port + ->call('submit') + ->assertSet('showPortWarningModal', false); // Should not show warning +}); diff --git a/tests/Unit/DockerComposeEmptyStringPreservationTest.php b/tests/Unit/DockerComposeEmptyStringPreservationTest.php index 71f59ce81..df654f2ea 100644 --- a/tests/Unit/DockerComposeEmptyStringPreservationTest.php +++ b/tests/Unit/DockerComposeEmptyStringPreservationTest.php @@ -19,13 +19,13 @@ $hasApplicationParser = str_contains($parsersFile, 'function applicationParser('); expect($hasApplicationParser)->toBeTrue('applicationParser function should exist'); - // The code should NOT unconditionally set $value = null for empty strings - // Instead, it should preserve empty strings when no database override exists + // The code should distinguish between null and empty string + // Check for the pattern where we explicitly check for null vs empty string + $hasNullCheck = str_contains($parsersFile, 'if ($value === null)'); + $hasEmptyStringCheck = str_contains($parsersFile, "} elseif (\$value === '') {"); - // Check for the pattern where we only override with database values when they're non-empty - // We're checking the fix is in place by looking for the logic pattern - $pattern1 = str_contains($parsersFile, 'if (str($value)->isEmpty())'); - expect($pattern1)->toBeTrue('Empty string check should exist'); + expect($hasNullCheck)->toBeTrue('Should have explicit null check'); + expect($hasEmptyStringCheck)->toBeTrue('Should have explicit empty string check'); }); it('ensures parsers.php preserves empty strings in service parser', function () { @@ -35,10 +35,13 @@ $hasServiceParser = str_contains($parsersFile, 'function serviceParser('); expect($hasServiceParser)->toBeTrue('serviceParser function should exist'); - // The code should NOT unconditionally set $value = null for empty strings - // Same check as above for service parser - $pattern1 = str_contains($parsersFile, 'if (str($value)->isEmpty())'); - expect($pattern1)->toBeTrue('Empty string check should exist'); + // The code should distinguish between null and empty string + // Same check as application parser + $hasNullCheck = str_contains($parsersFile, 'if ($value === null)'); + $hasEmptyStringCheck = str_contains($parsersFile, "} elseif (\$value === '') {"); + + expect($hasNullCheck)->toBeTrue('Should have explicit null check'); + expect($hasEmptyStringCheck)->toBeTrue('Should have explicit empty string check'); }); it('verifies YAML parsing preserves empty strings correctly', function () { @@ -186,3 +189,108 @@ expect(isset($arrayWithEmpty['key']))->toBeTrue(); expect(isset($arrayWithNull['key']))->toBeFalse(); }); + +it('verifies YAML null syntax options all produce PHP null', function () { + // Test all three ways to write null in YAML + $yamlWithNullSyntax = <<<'YAML' +environment: + VAR_NO_VALUE: + VAR_EXPLICIT_NULL: null + VAR_TILDE: ~ + VAR_EMPTY_STRING: "" +YAML; + + $parsed = Yaml::parse($yamlWithNullSyntax); + + // All three null syntaxes should produce PHP null + expect($parsed['environment']['VAR_NO_VALUE'])->toBeNull(); + expect($parsed['environment']['VAR_EXPLICIT_NULL'])->toBeNull(); + expect($parsed['environment']['VAR_TILDE'])->toBeNull(); + + // Empty string should remain empty string + expect($parsed['environment']['VAR_EMPTY_STRING'])->toBe(''); +}); + +it('verifies null round-trip through YAML', function () { + // Test full round-trip: null -> YAML -> parse -> serialize -> parse + $original = [ + 'environment' => [ + 'NULL_VAR' => null, + 'EMPTY_VAR' => '', + 'VALUE_VAR' => 'localhost', + ], + ]; + + // Serialize to YAML + $yaml1 = Yaml::dump($original, 10, 2); + + // Parse back + $parsed1 = Yaml::parse($yaml1); + + // Verify types are preserved + expect($parsed1['environment']['NULL_VAR'])->toBeNull(); + expect($parsed1['environment']['EMPTY_VAR'])->toBe(''); + expect($parsed1['environment']['VALUE_VAR'])->toBe('localhost'); + + // Serialize again + $yaml2 = Yaml::dump($parsed1, 10, 2); + + // Parse again + $parsed2 = Yaml::parse($yaml2); + + // Should still have correct types + expect($parsed2['environment']['NULL_VAR'])->toBeNull(); + expect($parsed2['environment']['EMPTY_VAR'])->toBe(''); + expect($parsed2['environment']['VALUE_VAR'])->toBe('localhost'); + + // Both YAML representations should be equivalent + expect($yaml1)->toBe($yaml2); +}); + +it('verifies null vs empty string behavior difference', function () { + // Document the critical difference between null and empty string + + // Null in YAML + $yamlNull = "VAR: null\n"; + $parsedNull = Yaml::parse($yamlNull); + expect($parsedNull['VAR'])->toBeNull(); + + // Empty string in YAML + $yamlEmpty = "VAR: \"\"\n"; + $parsedEmpty = Yaml::parse($yamlEmpty); + expect($parsedEmpty['VAR'])->toBe(''); + + // They should NOT be equal + expect($parsedNull['VAR'] === $parsedEmpty['VAR'])->toBeFalse(); + + // Verify type differences + expect(is_null($parsedNull['VAR']))->toBeTrue(); + expect(is_string($parsedEmpty['VAR']))->toBeTrue(); +}); + +it('verifies parser logic distinguishes null from empty string', function () { + // Test the exact === comparison behavior + $nullValue = null; + $emptyString = ''; + + // PHP strict comparison + expect($nullValue === null)->toBeTrue(); + expect($emptyString === '')->toBeTrue(); + expect($nullValue === $emptyString)->toBeFalse(); + + // This is what the parser should use for correct behavior + if ($nullValue === null) { + $nullHandled = true; + } else { + $nullHandled = false; + } + + if ($emptyString === '') { + $emptyHandled = true; + } else { + $emptyHandled = false; + } + + expect($nullHandled)->toBeTrue(); + expect($emptyHandled)->toBeTrue(); +}); diff --git a/tests/Unit/Policies/PrivateKeyPolicyTest.php b/tests/Unit/Policies/PrivateKeyPolicyTest.php index dd0037403..6844d92f7 100644 --- a/tests/Unit/Policies/PrivateKeyPolicyTest.php +++ b/tests/Unit/Policies/PrivateKeyPolicyTest.php @@ -1,6 +1,5 @@ toBeTrue('Should have comment about port-specific variables'); + expect($usesFqdnWithPort)->toBeTrue('Should use $fqdnWithPort for port variables'); + expect($usesUrlWithPort)->toBeTrue('Should use $urlWithPort for port variables'); +}); + +it('verifies SERVICE_URL variable naming convention', function () { + // Test the naming convention for port-specific variables + + // Base variable (no port): SERVICE_URL_UMAMI + $baseKey = 'SERVICE_URL_UMAMI'; + expect(substr_count($baseKey, '_'))->toBe(2); + + // Port-specific variable: SERVICE_URL_UMAMI_3000 + $portKey = 'SERVICE_URL_UMAMI_3000'; + expect(substr_count($portKey, '_'))->toBe(3); + + // Extract service name + $serviceName = str($portKey)->after('SERVICE_URL_')->beforeLast('_')->lower()->value(); + expect($serviceName)->toBe('umami'); + + // Extract port + $port = str($portKey)->afterLast('_')->value(); + expect($port)->toBe('3000'); +}); + +it('verifies SERVICE_FQDN variable naming convention', function () { + // Test the naming convention for port-specific FQDN variables + + // Base variable (no port): SERVICE_FQDN_POSTGRES + $baseKey = 'SERVICE_FQDN_POSTGRES'; + expect(substr_count($baseKey, '_'))->toBe(2); + + // Port-specific variable: SERVICE_FQDN_POSTGRES_5432 + $portKey = 'SERVICE_FQDN_POSTGRES_5432'; + expect(substr_count($portKey, '_'))->toBe(3); + + // Extract service name + $serviceName = str($portKey)->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value(); + expect($serviceName)->toBe('postgres'); + + // Extract port + $port = str($portKey)->afterLast('_')->value(); + expect($port)->toBe('5432'); +}); + +it('verifies URL with port format', function () { + // Test that URLs with ports are formatted correctly + $baseUrl = 'http://umami-abc123.domain.com'; + $port = '3000'; + + $urlWithPort = "$baseUrl:$port"; + + expect($urlWithPort)->toBe('http://umami-abc123.domain.com:3000'); + expect($urlWithPort)->toContain(':3000'); +}); + +it('verifies FQDN with port format', function () { + // Test that FQDNs with ports are formatted correctly + $baseFqdn = 'postgres-xyz789.domain.com'; + $port = '5432'; + + $fqdnWithPort = "$baseFqdn:$port"; + + expect($fqdnWithPort)->toBe('postgres-xyz789.domain.com:5432'); + expect($fqdnWithPort)->toContain(':5432'); +}); + +it('verifies port extraction from variable name', function () { + // Test extracting port from various variable names + $tests = [ + 'SERVICE_URL_APP_3000' => '3000', + 'SERVICE_URL_API_8080' => '8080', + 'SERVICE_FQDN_DB_5432' => '5432', + 'SERVICE_FQDN_REDIS_6379' => '6379', + ]; + + foreach ($tests as $varName => $expectedPort) { + $port = str($varName)->afterLast('_')->value(); + expect($port)->toBe($expectedPort, "Port extraction failed for $varName"); + } +}); + +it('verifies service name extraction with port suffix', function () { + // Test extracting service name when port is present + $tests = [ + 'SERVICE_URL_APP_3000' => 'app', + 'SERVICE_URL_MY_API_8080' => 'my_api', + 'SERVICE_FQDN_DB_5432' => 'db', + 'SERVICE_FQDN_REDIS_CACHE_6379' => 'redis_cache', + ]; + + foreach ($tests as $varName => $expectedService) { + if (str($varName)->startsWith('SERVICE_URL_')) { + $serviceName = str($varName)->after('SERVICE_URL_')->beforeLast('_')->lower()->value(); + } else { + $serviceName = str($varName)->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value(); + } + expect($serviceName)->toBe($expectedService, "Service name extraction failed for $varName"); + } +}); + +it('verifies distinction between base and port-specific variables', function () { + // Test that base and port-specific variables are different + $baseUrl = 'SERVICE_URL_UMAMI'; + $portUrl = 'SERVICE_URL_UMAMI_3000'; + + expect($baseUrl)->not->toBe($portUrl); + expect(substr_count($baseUrl, '_'))->toBe(2); + expect(substr_count($portUrl, '_'))->toBe(3); + + // Port-specific should contain port number + expect(str($portUrl)->contains('_3000'))->toBeTrue(); + expect(str($baseUrl)->contains('_3000'))->toBeFalse(); +}); + +it('verifies multiple port variables for same service', function () { + // Test that a service can have multiple port-specific variables + $service = 'api'; + $ports = ['3000', '8080', '9090']; + + foreach ($ports as $port) { + $varName = "SERVICE_URL_API_$port"; + + // Should have 3 underscores + expect(substr_count($varName, '_'))->toBe(3); + + // Should extract correct service name + $serviceName = str($varName)->after('SERVICE_URL_')->beforeLast('_')->lower()->value(); + expect($serviceName)->toBe('api'); + + // Should extract correct port + $extractedPort = str($varName)->afterLast('_')->value(); + expect($extractedPort)->toBe($port); + } +}); + +it('verifies common port numbers are handled correctly', function () { + // Test common port numbers used in applications + $commonPorts = [ + '80' => 'HTTP', + '443' => 'HTTPS', + '3000' => 'Node.js/React', + '5432' => 'PostgreSQL', + '6379' => 'Redis', + '8080' => 'Alternative HTTP', + '9000' => 'PHP-FPM', + ]; + + foreach ($commonPorts as $port => $description) { + $varName = "SERVICE_URL_APP_$port"; + + expect(substr_count($varName, '_'))->toBe(3, "Failed for $description port $port"); + + $extractedPort = str($varName)->afterLast('_')->value(); + expect($extractedPort)->toBe((string) $port, "Port extraction failed for $description"); + } +}); diff --git a/tests/Unit/ServiceRequiredPortTest.php b/tests/Unit/ServiceRequiredPortTest.php new file mode 100644 index 000000000..70bf2bca2 --- /dev/null +++ b/tests/Unit/ServiceRequiredPortTest.php @@ -0,0 +1,153 @@ + [ + 'name' => 'Supabase', + 'port' => '8000', + ], + 'umami' => [ + 'name' => 'Umami', + 'port' => '3000', + ], + ]); + + $service = Mockery::mock(Service::class)->makePartial(); + $service->name = 'supabase-xyz123'; + + // Mock the get_service_templates function to return our mock data + $service->shouldReceive('getRequiredPort')->andReturn(8000); + + expect($service->getRequiredPort())->toBe(8000); +}); + +it('returns null for service without required port', function () { + $service = Mockery::mock(Service::class)->makePartial(); + $service->name = 'cloudflared-xyz123'; + + // Mock to return null for services without port + $service->shouldReceive('getRequiredPort')->andReturn(null); + + expect($service->getRequiredPort())->toBeNull(); +}); + +it('requiresPort returns true when service has required port', function () { + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('getRequiredPort')->andReturn(8000); + $service->shouldReceive('requiresPort')->andReturnUsing(function () use ($service) { + return $service->getRequiredPort() !== null; + }); + + expect($service->requiresPort())->toBeTrue(); +}); + +it('requiresPort returns false when service has no required port', function () { + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('getRequiredPort')->andReturn(null); + $service->shouldReceive('requiresPort')->andReturnUsing(function () use ($service) { + return $service->getRequiredPort() !== null; + }); + + expect($service->requiresPort())->toBeFalse(); +}); + +it('extracts port from URL with http scheme', function () { + $url = 'http://example.com:3000'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBe(3000); +}); + +it('extracts port from URL with https scheme', function () { + $url = 'https://example.com:8080'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBe(8080); +}); + +it('extracts port from URL without scheme', function () { + $url = 'example.com:5000'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBe(5000); +}); + +it('returns null for URL without port', function () { + $url = 'http://example.com'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBeNull(); +}); + +it('returns null for URL without port and without scheme', function () { + $url = 'example.com'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBeNull(); +}); + +it('handles invalid URLs gracefully', function () { + $url = 'not-a-valid-url:::'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBeNull(); +}); + +it('checks if all FQDNs have port - single FQDN with port', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = 'http://example.com:3000'; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeTrue(); +}); + +it('checks if all FQDNs have port - single FQDN without port', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = 'http://example.com'; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeFalse(); +}); + +it('checks if all FQDNs have port - multiple FQDNs all with ports', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = 'http://example.com:3000,https://example.org:8080'; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeTrue(); +}); + +it('checks if all FQDNs have port - multiple FQDNs one without port', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = 'http://example.com:3000,https://example.org'; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeFalse(); +}); + +it('checks if all FQDNs have port - empty FQDN', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = ''; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeFalse(); +}); + +it('checks if all FQDNs have port - null FQDN', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = null; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeFalse(); +}); From 2768805996dbf7f7374d4c759bbfefd4ec73972c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:33:42 +0100 Subject: [PATCH 15/20] fix: update helper_version to 1.0.12 in constants configuration --- config/constants.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/constants.php b/config/constants.php index 25581f4ad..02a1eaae6 100644 --- a/config/constants.php +++ b/config/constants.php @@ -3,7 +3,7 @@ return [ 'coolify' => [ 'version' => '4.0.0-beta.442', - 'helper_version' => '1.0.11', + 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), From 24bcce3f9bd0f19c4c4aac2012b5d1cdbda3532c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:36:34 +0100 Subject: [PATCH 16/20] Update app/Console/Commands/SyncBunny.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/Console/Commands/SyncBunny.php | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 1a76b33d1..64e91fa0a 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -82,8 +82,26 @@ private function syncReleasesToGitHubRepo(): bool return false; } + $this->info('Checking for changes...'); + $statusOutput = []; + exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + if (empty(array_filter($statusOutput))) { + $this->info('Releases are already up to date. No changes to commit.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return true; + } + $commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s'); - exec("cd $tmpDir && git commit -m '$commitMessage' 2>&1", $output, $returnCode); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to commit changes: '.implode("\n", $output)); exec("rm -rf $tmpDir"); From 2d64cdad7c3bfbb27d077df42fcff2664099ce40 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:36:59 +0100 Subject: [PATCH 17/20] ci(claude): remove unused workflows --- .github/workflows/claude-code-review.yml | 79 ------------------------ .github/workflows/claude.yml | 65 ------------------- 2 files changed, 144 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml delete mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index a2c92df59..000000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - if: false - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@beta - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) - # model: "claude-opus-4-1-20250805" - - # Direct prompt for automated review (no @claude mention needed) - direct_prompt: | - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Be constructive and helpful in your feedback. - - # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR - # use_sticky_comment: true - - # Optional: Customize review based on file types - # direct_prompt: | - # Review this PR focusing on: - # - For TypeScript files: Type safety and proper interface usage - # - For API endpoints: Security, input validation, and error handling - # - For React components: Performance, accessibility, and best practices - # - For tests: Coverage, edge cases, and test quality - - # Optional: Different prompts for different authors - # direct_prompt: | - # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && - # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || - # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} - - # Optional: Add specific tools for running tests or linting - # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" - - # Optional: Skip review for certain conditions - # if: | - # !contains(github.event.pull_request.title, '[skip-review]') && - # !contains(github.event.pull_request.title, '[WIP]') - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 9daf0e90e..000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'Claude') || - (github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'Claude') || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) - # model: "claude-opus-4-1-20250805" - - # Optional: Customize the trigger phrase (default: @claude) - # trigger_phrase: "/claude" - - # Optional: Trigger when specific user is assigned to an issue - # assignee_trigger: "claude-bot" - - # Optional: Allow Claude to run specific commands - # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" - - # Optional: Add custom instructions for Claude to customize its behavior for your project - # custom_instructions: | - # Follow our coding standards - # Ensure all new code has tests - # Use TypeScript for new files - - # Optional: Custom environment variables for Claude - # claude_env: | - # NODE_ENV: test From 6557514954ac36ebd95f5eb704acee887ee9e61f Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:40:54 +0100 Subject: [PATCH 18/20] ci(workflows): improve security and update actions - set top-level explicit permissions for each GitHub Actions workflow for improved security and deduplication of permissions. - add `persist-credentials: false` to actions/checkout for improved security - see https://github.com/actions/checkout#checkout-v4 - update actions/checkout from v4 to v5 --- ...lock-closed-issues-discussions-and-prs.yml | 7 ++++- .../chore-manage-stale-issues-and-prs.yml | 4 +++ .github/workflows/chore-pr-comments.yml | 15 +++-------- ...e-remove-labels-and-assignees-on-close.yml | 4 +++ .github/workflows/cleanup-ghcr-untagged.yml | 9 +++---- .github/workflows/coolify-helper-next.yml | 26 ++++++++++--------- .github/workflows/coolify-helper.yml | 25 +++++++++--------- .../workflows/coolify-production-build.yml | 19 +++++++++----- .github/workflows/coolify-realtime-next.yml | 26 ++++++++++--------- .github/workflows/coolify-realtime.yml | 25 +++++++++--------- .github/workflows/coolify-staging-build.yml | 14 +++++----- .github/workflows/coolify-testing-host.yml | 25 +++++++++--------- .github/workflows/generate-changelog.yml | 1 + 13 files changed, 110 insertions(+), 90 deletions(-) diff --git a/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml b/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml index d00853964..365842254 100644 --- a/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml +++ b/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml @@ -4,6 +4,11 @@ on: schedule: - cron: '0 1 * * *' +permissions: + issues: write + discussions: write + pull-requests: write + jobs: lock-threads: runs-on: ubuntu-latest @@ -13,5 +18,5 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} issue-inactive-days: '30' - pr-inactive-days: '30' discussion-inactive-days: '30' + pr-inactive-days: '30' diff --git a/.github/workflows/chore-manage-stale-issues-and-prs.yml b/.github/workflows/chore-manage-stale-issues-and-prs.yml index 58a2b7d7e..d61005549 100644 --- a/.github/workflows/chore-manage-stale-issues-and-prs.yml +++ b/.github/workflows/chore-manage-stale-issues-and-prs.yml @@ -4,6 +4,10 @@ on: schedule: - cron: '0 2 * * *' +permissions: + issues: write + pull-requests: write + jobs: manage-stale: runs-on: ubuntu-latest diff --git a/.github/workflows/chore-pr-comments.yml b/.github/workflows/chore-pr-comments.yml index 8836c6632..1d94bec81 100644 --- a/.github/workflows/chore-pr-comments.yml +++ b/.github/workflows/chore-pr-comments.yml @@ -3,20 +3,13 @@ on: pull_request_target: types: - labeled + +permissions: + pull-requests: write + jobs: add-comment: runs-on: ubuntu-latest - permissions: - pull-requests: write - contents: read - actions: none - checks: none - deployments: none - issues: none - packages: none - repository-projects: none - security-events: none - statuses: none strategy: matrix: include: diff --git a/.github/workflows/chore-remove-labels-and-assignees-on-close.yml b/.github/workflows/chore-remove-labels-and-assignees-on-close.yml index 194984ddc..8ac199a08 100644 --- a/.github/workflows/chore-remove-labels-and-assignees-on-close.yml +++ b/.github/workflows/chore-remove-labels-and-assignees-on-close.yml @@ -8,6 +8,10 @@ on: pull_request_target: types: [closed] +permissions: + issues: write + pull-requests: write + jobs: remove-labels-and-assignees: runs-on: ubuntu-latest diff --git a/.github/workflows/cleanup-ghcr-untagged.yml b/.github/workflows/cleanup-ghcr-untagged.yml index 394fba68f..a86cedcb0 100644 --- a/.github/workflows/cleanup-ghcr-untagged.yml +++ b/.github/workflows/cleanup-ghcr-untagged.yml @@ -1,17 +1,14 @@ name: Cleanup Untagged GHCR Images on: - workflow_dispatch: # Manual trigger only + workflow_dispatch: -env: - GITHUB_REGISTRY: ghcr.io +permissions: + packages: write jobs: cleanup-all-packages: runs-on: ubuntu-latest - permissions: - contents: read - packages: write strategy: matrix: package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host'] diff --git a/.github/workflows/coolify-helper-next.yml b/.github/workflows/coolify-helper-next.yml index a4a2a21f6..ba8a69d28 100644 --- a/.github/workflows/coolify-helper-next.yml +++ b/.github/workflows/coolify-helper-next.yml @@ -7,6 +7,10 @@ on: - .github/workflows/coolify-helper-next.yml - docker/coolify-helper/Dockerfile +permissions: + contents: read + packages: write + env: GITHUB_REGISTRY: ghcr.io DOCKER_REGISTRY: docker.io @@ -15,11 +19,10 @@ env: jobs: amd64: runs-on: ubuntu-latest - permissions: - contents: read - packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -54,11 +57,10 @@ jobs: coolify.managed=true aarch64: runs-on: [ self-hosted, arm64 ] - permissions: - contents: read - packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -94,12 +96,12 @@ jobs: merge-manifest: runs-on: ubuntu-latest - permissions: - contents: read - packages: write needs: [ amd64, aarch64 ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@v3 - name: Login to ${{ env.GITHUB_REGISTRY }} diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index 56c3eaa17..738a3480c 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -7,6 +7,10 @@ on: - .github/workflows/coolify-helper.yml - docker/coolify-helper/Dockerfile +permissions: + contents: read + packages: write + env: GITHUB_REGISTRY: ghcr.io DOCKER_REGISTRY: docker.io @@ -15,11 +19,10 @@ env: jobs: amd64: runs-on: ubuntu-latest - permissions: - contents: read - packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -54,11 +57,10 @@ jobs: coolify.managed=true aarch64: runs-on: [ self-hosted, arm64 ] - permissions: - contents: read - packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -93,12 +95,11 @@ jobs: coolify.managed=true merge-manifest: runs-on: ubuntu-latest - permissions: - contents: read - packages: write needs: [ amd64, aarch64 ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml index cd1f002b8..b6cfd34ae 100644 --- a/.github/workflows/coolify-production-build.yml +++ b/.github/workflows/coolify-production-build.yml @@ -14,6 +14,10 @@ on: - templates/** - CHANGELOG.md +permissions: + contents: read + packages: write + env: GITHUB_REGISTRY: ghcr.io DOCKER_REGISTRY: docker.io @@ -23,7 +27,9 @@ jobs: amd64: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -58,7 +64,9 @@ jobs: aarch64: runs-on: [self-hosted, arm64] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -92,12 +100,11 @@ jobs: merge-manifest: runs-on: ubuntu-latest - permissions: - contents: read - packages: write needs: [amd64, aarch64] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/coolify-realtime-next.yml b/.github/workflows/coolify-realtime-next.yml index ad590146b..7a6071bde 100644 --- a/.github/workflows/coolify-realtime-next.yml +++ b/.github/workflows/coolify-realtime-next.yml @@ -11,6 +11,10 @@ on: - docker/coolify-realtime/package-lock.json - docker/coolify-realtime/soketi-entrypoint.sh +permissions: + contents: read + packages: write + env: GITHUB_REGISTRY: ghcr.io DOCKER_REGISTRY: docker.io @@ -19,11 +23,10 @@ env: jobs: amd64: runs-on: ubuntu-latest - permissions: - contents: read - packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -59,11 +62,11 @@ jobs: aarch64: runs-on: [ self-hosted, arm64 ] - permissions: - contents: read - packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -99,12 +102,11 @@ jobs: merge-manifest: runs-on: ubuntu-latest - permissions: - contents: read - packages: write needs: [ amd64, aarch64 ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/coolify-realtime.yml b/.github/workflows/coolify-realtime.yml index d00621cc2..1074af3ee 100644 --- a/.github/workflows/coolify-realtime.yml +++ b/.github/workflows/coolify-realtime.yml @@ -11,6 +11,10 @@ on: - docker/coolify-realtime/package-lock.json - docker/coolify-realtime/soketi-entrypoint.sh +permissions: + contents: read + packages: write + env: GITHUB_REGISTRY: ghcr.io DOCKER_REGISTRY: docker.io @@ -19,11 +23,10 @@ env: jobs: amd64: runs-on: ubuntu-latest - permissions: - contents: read - packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -59,11 +62,10 @@ jobs: aarch64: runs-on: [ self-hosted, arm64 ] - permissions: - contents: read - packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -99,12 +101,11 @@ jobs: merge-manifest: runs-on: ubuntu-latest - permissions: - contents: read - packages: write needs: [ amd64, aarch64 ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index df737c9c3..67b7b03e8 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -17,6 +17,10 @@ on: - templates/** - CHANGELOG.md +permissions: + contents: read + packages: write + env: GITHUB_REGISTRY: ghcr.io DOCKER_REGISTRY: docker.io @@ -34,11 +38,10 @@ jobs: platform: linux/aarch64 runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} - permissions: - contents: read - packages: write steps: - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Sanitize branch name for Docker tag id: sanitize @@ -82,11 +85,10 @@ jobs: merge-manifest: runs-on: ubuntu-24.04 needs: build-push - permissions: - contents: read - packages: write steps: - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Sanitize branch name for Docker tag id: sanitize diff --git a/.github/workflows/coolify-testing-host.yml b/.github/workflows/coolify-testing-host.yml index 95a228114..c4aecd85e 100644 --- a/.github/workflows/coolify-testing-host.yml +++ b/.github/workflows/coolify-testing-host.yml @@ -7,6 +7,10 @@ on: - .github/workflows/coolify-testing-host.yml - docker/testing-host/Dockerfile +permissions: + contents: read + packages: write + env: GITHUB_REGISTRY: ghcr.io DOCKER_REGISTRY: docker.io @@ -15,11 +19,10 @@ env: jobs: amd64: runs-on: ubuntu-latest - permissions: - contents: read - packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -50,11 +53,10 @@ jobs: aarch64: runs-on: [ self-hosted, arm64 ] - permissions: - contents: read - packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 @@ -85,12 +87,11 @@ jobs: merge-manifest: runs-on: ubuntu-latest - permissions: - contents: read - packages: write needs: [ amd64, aarch64 ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 935a88721..f62b41736 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -16,6 +16,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: + persist-credentials: false fetch-depth: 0 - name: Generate changelog From 4e734492e01bf379978e594a206022af377eb632 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:57:19 +0100 Subject: [PATCH 19/20] fix: escape shell arguments in syncBunny command execution --- app/Console/Commands/SyncBunny.php | 31 +++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 64e91fa0a..e634feadb 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -50,7 +50,7 @@ private function syncReleasesToGitHubRepo(): bool // Clone the repository $this->info('Cloning coolify-cdn repository...'); - exec("gh repo clone coollabsio/coolify-cdn $tmpDir 2>&1", $output, $returnCode); + exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to clone repository: '.implode("\n", $output)); @@ -59,10 +59,10 @@ private function syncReleasesToGitHubRepo(): bool // Create feature branch $this->info('Creating feature branch...'); - exec("cd $tmpDir && git checkout -b $branchName 2>&1", $output, $returnCode); + exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to create branch: '.implode("\n", $output)); - exec("rm -rf $tmpDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); return false; } @@ -70,14 +70,23 @@ private function syncReleasesToGitHubRepo(): bool // Write releases.json $this->info('Writing releases.json...'); $releasesPath = "$tmpDir/json/releases.json"; - file_put_contents($releasesPath, json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $bytesWritten = file_put_contents($releasesPath, $jsonContent); + + if ($bytesWritten === false) { + $this->error("Failed to write releases.json to: $releasesPath"); + $this->error('Possible reasons: directory does not exist, permission denied, or disk full.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } // Stage and commit $this->info('Committing changes...'); - exec("cd $tmpDir && git add json/releases.json 2>&1", $output, $returnCode); + exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to stage changes: '.implode("\n", $output)); - exec("rm -rf $tmpDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); return false; } @@ -104,17 +113,17 @@ private function syncReleasesToGitHubRepo(): bool exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to commit changes: '.implode("\n", $output)); - exec("rm -rf $tmpDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); return false; } // Push to remote $this->info('Pushing branch to remote...'); - exec("cd $tmpDir && git push origin $branchName 2>&1", $output, $returnCode); + exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to push branch: '.implode("\n", $output)); - exec("rm -rf $tmpDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); return false; } @@ -123,11 +132,11 @@ private function syncReleasesToGitHubRepo(): bool $this->info('Creating pull request...'); $prTitle = 'Update releases.json - '.date('Y-m-d H:i:s'); $prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API'; - $prCommand = "gh pr create --repo coollabsio/coolify-cdn --title '$prTitle' --body '$prBody' --base main --head $branchName 2>&1"; + $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; exec($prCommand, $output, $returnCode); // Clean up - exec("rm -rf $tmpDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); if ($returnCode !== 0) { $this->error('Failed to create PR: '.implode("\n", $output)); From f005602147bb59aad048d12ede2d1291fc9ce21d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:00:24 +0100 Subject: [PATCH 20/20] fix: remove Gozunga from the list of sponsors in README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index f159cde89..456a1268e 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,6 @@ ## Big Sponsors * [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration * [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform * [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions -* [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure * [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions * [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions * [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers