From 30e65abf1be972e52a20f8d95a9351aaa039b018 Mon Sep 17 00:00:00 2001 From: Taras Machyshyn Date: Fri, 27 Feb 2026 20:23:24 +0200 Subject: [PATCH 01/21] Added EspoCRM --- public/svgs/espocrm.svg | 82 ++++++++++++++++++++++++++++++++++ templates/compose/espocrm.yaml | 75 +++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 public/svgs/espocrm.svg create mode 100644 templates/compose/espocrm.yaml diff --git a/public/svgs/espocrm.svg b/public/svgs/espocrm.svg new file mode 100644 index 000000000..79d96f8c3 --- /dev/null +++ b/public/svgs/espocrm.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml new file mode 100644 index 000000000..d771e0f53 --- /dev/null +++ b/templates/compose/espocrm.yaml @@ -0,0 +1,75 @@ +# documentation: https://docs.espocrm.com +# slogan: EspoCRM is a free and open-source CRM platform. +# category: cms +# tags: crm, self-hosted, open-source, workflow, automation, project management +# logo: svgs/espocrm.svg +# port: 80 + +services: + espocrm: + image: espocrm/espocrm:latest + environment: + - SERVICE_URL_ESPOCRM + - ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin} + - ESPOCRM_ADMIN_PASSWORD=${ESPOCRM_ADMIN_PASSWORD:-password} + - ESPOCRM_DATABASE_PLATFORM=Mysql + - ESPOCRM_DATABASE_HOST=espocrm-db + - ESPOCRM_DATABASE_NAME=${MARIADB_DATABASE:-espocrm} + - ESPOCRM_DATABASE_USER=${SERVICE_USER_MARIADB} + - ESPOCRM_DATABASE_PASSWORD=${SERVICE_PASSWORD_MARIADB} + - ESPOCRM_SITE_URL=${SERVICE_URL_ESPOCRM} + volumes: + - espocrm:/var/www/html + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:80"] + interval: 2s + start_period: 60s + timeout: 10s + retries: 15 + depends_on: + espocrm-db: + condition: service_healthy + + espocrm-daemon: + image: espocrm/espocrm:latest + container_name: espocrm-daemon + volumes: + - espocrm:/var/www/html + restart: always + entrypoint: docker-daemon.sh + depends_on: + espocrm: + condition: service_healthy + + espocrm-websocket: + image: espocrm/espocrm:latest + container_name: espocrm-websocket + environment: + - SERVICE_URL_ESPOCRM_WEBSOCKET_8080 + - ESPOCRM_CONFIG_USE_WEB_SOCKET=true + - ESPOCRM_CONFIG_WEB_SOCKET_URL=$SERVICE_URL_ESPOCRM_WEBSOCKET + - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN=tcp://*:7777 + - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN=tcp://espocrm-websocket:7777 + volumes: + - espocrm:/var/www/html + restart: always + entrypoint: docker-websocket.sh + depends_on: + espocrm: + condition: service_healthy + + espocrm-db: + image: mariadb:latest + environment: + - MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm} + - MARIADB_USER=${SERVICE_USER_MARIADB} + - MARIADB_PASSWORD=${SERVICE_PASSWORD_MARIADB} + - MARIADB_ROOT_PASSWORD=${SERVICE_PASSWORD_ROOT} + volumes: + - espocrm-db:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 20s + start_period: 10s + timeout: 10s + retries: 3 From a2540bd23326b92f35caa797392c97a0a7c0dd51 Mon Sep 17 00:00:00 2001 From: Taras Machyshyn Date: Fri, 27 Feb 2026 20:42:20 +0200 Subject: [PATCH 02/21] Admin password --- templates/compose/espocrm.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml index d771e0f53..130562a78 100644 --- a/templates/compose/espocrm.yaml +++ b/templates/compose/espocrm.yaml @@ -11,7 +11,7 @@ services: environment: - SERVICE_URL_ESPOCRM - ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin} - - ESPOCRM_ADMIN_PASSWORD=${ESPOCRM_ADMIN_PASSWORD:-password} + - ESPOCRM_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN} - ESPOCRM_DATABASE_PLATFORM=Mysql - ESPOCRM_DATABASE_HOST=espocrm-db - ESPOCRM_DATABASE_NAME=${MARIADB_DATABASE:-espocrm} From c4279a6bcb008a05cb8934c77045e0f5a571a95d Mon Sep 17 00:00:00 2001 From: Taras Machyshyn Date: Mon, 16 Mar 2026 18:23:47 +0200 Subject: [PATCH 03/21] Define static versions --- templates/compose/espocrm.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml index 130562a78..6fec260c4 100644 --- a/templates/compose/espocrm.yaml +++ b/templates/compose/espocrm.yaml @@ -7,7 +7,7 @@ services: espocrm: - image: espocrm/espocrm:latest + image: espocrm/espocrm:9 environment: - SERVICE_URL_ESPOCRM - ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin} @@ -31,7 +31,7 @@ services: condition: service_healthy espocrm-daemon: - image: espocrm/espocrm:latest + image: espocrm/espocrm:9 container_name: espocrm-daemon volumes: - espocrm:/var/www/html @@ -42,7 +42,7 @@ services: condition: service_healthy espocrm-websocket: - image: espocrm/espocrm:latest + image: espocrm/espocrm:9 container_name: espocrm-websocket environment: - SERVICE_URL_ESPOCRM_WEBSOCKET_8080 @@ -59,7 +59,7 @@ services: condition: service_healthy espocrm-db: - image: mariadb:latest + image: mariadb:11.8 environment: - MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm} - MARIADB_USER=${SERVICE_USER_MARIADB} From 23b52487c4092bfc875a08d5d3a38610690560c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 09:53:07 +0000 Subject: [PATCH 04/21] Disable booklore service template Add `# ignore: true` to the booklore compose file so the service template generator skips it, hiding it from the UI. https://claude.ai/code/session_01Y7ZeGwqPp97oXwyLCPja9k --- templates/compose/booklore.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/booklore.yaml b/templates/compose/booklore.yaml index fddde8de0..a26e52932 100644 --- a/templates/compose/booklore.yaml +++ b/templates/compose/booklore.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://booklore.org/docs/getting-started # slogan: Booklore is an open-source library management system for your digital book collection. # tags: media, books, kobo, epub, ebook, KOreader From e37cb98c7c24e078a11cff92cd080578cd4ca08d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:56:50 +0100 Subject: [PATCH 05/21] refactor(team): make server limit methods accept optional team parameter Allow serverLimit() and serverLimitReached() to accept an optional team parameter instead of relying solely on the current session. This improves testability and makes the methods more flexible by allowing them to work without session state. Add comprehensive tests covering various scenarios including no session, team at limit, and team under limit. --- .../Controllers/Api/HetznerController.php | 3 +- app/Models/Team.php | 19 ++++--- tests/Feature/TeamServerLimitTest.php | 53 +++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/TeamServerLimitTest.php diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 2645c2df1..ed91b4475 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -586,7 +586,8 @@ public function createServer(Request $request) } // Check server limit - if (Team::serverLimitReached()) { + $team = Team::find($teamId); + if (Team::serverLimitReached($team)) { return response()->json(['message' => 'Server limit reached for your subscription.'], 400); } diff --git a/app/Models/Team.php b/app/Models/Team.php index 10b22b4e1..639d50b60 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -89,10 +89,13 @@ protected static function booted() }); } - public static function serverLimitReached() + public static function serverLimitReached(?Team $team = null) { - $serverLimit = Team::serverLimit(); - $team = currentTeam(); + $team = $team ?? currentTeam(); + if (! $team) { + return true; + } + $serverLimit = Team::serverLimit($team); $servers = $team->servers->count(); return $servers >= $serverLimit; @@ -116,12 +119,16 @@ public function serverOverflow() return false; } - public static function serverLimit() + public static function serverLimit(?Team $team = null) { - if (currentTeam()->id === 0 && isDev()) { + $team = $team ?? currentTeam(); + if (! $team) { + return 0; + } + if ($team->id === 0 && isDev()) { return 9999999; } - $team = Team::find(currentTeam()->id); + $team = Team::find($team->id); if (! $team) { return 0; } diff --git a/tests/Feature/TeamServerLimitTest.php b/tests/Feature/TeamServerLimitTest.php new file mode 100644 index 000000000..11d7f09d1 --- /dev/null +++ b/tests/Feature/TeamServerLimitTest.php @@ -0,0 +1,53 @@ +set('constants.coolify.self_hosted', true); +}); + +it('returns server limit when team is passed directly without session', function () { + $team = Team::factory()->create(); + + $limit = Team::serverLimit($team); + + // self_hosted returns 999999999999 + expect($limit)->toBe(999999999999); +}); + +it('returns 0 when no team is provided and no session exists', function () { + $limit = Team::serverLimit(); + + expect($limit)->toBe(0); +}); + +it('returns true for serverLimitReached when no team and no session', function () { + $result = Team::serverLimitReached(); + + expect($result)->toBeTrue(); +}); + +it('returns false for serverLimitReached when team has servers under limit', function () { + $team = Team::factory()->create(); + Server::factory()->create(['team_id' => $team->id]); + + $result = Team::serverLimitReached($team); + + // self_hosted has very high limit, 1 server is well under + expect($result)->toBeFalse(); +}); + +it('returns true for serverLimitReached when team has servers at limit', function () { + config()->set('constants.coolify.self_hosted', false); + + $team = Team::factory()->create(['custom_server_limit' => 1]); + Server::factory()->create(['team_id' => $team->id]); + + $result = Team::serverLimitReached($team); + + expect($result)->toBeTrue(); +}); From 988dd57cf4f30fcaee3df22f9500d13372ff791e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:03:08 +0100 Subject: [PATCH 06/21] feat(validation): make hostname validation case-insensitive and expand allowed characters - Normalize hostnames to lowercase for RFC 1123 compliance while accepting uppercase input - Expand NAME_PATTERN to allow parentheses, hash, comma, colon, and plus characters - Add fallback to random name generation when application name doesn't meet minimum requirements - Add comprehensive test coverage for validation patterns and edge cases --- app/Rules/ValidHostname.php | 9 ++- app/Support/ValidationPatterns.php | 4 +- bootstrap/helpers/shared.php | 11 +++- tests/Unit/ValidHostnameTest.php | 11 ++-- tests/Unit/ValidationPatternsTest.php | 82 +++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/ValidationPatternsTest.php diff --git a/app/Rules/ValidHostname.php b/app/Rules/ValidHostname.php index b6b2b8d32..89b68663b 100644 --- a/app/Rules/ValidHostname.php +++ b/app/Rules/ValidHostname.php @@ -62,12 +62,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Ignore errors when facades are not available (e.g., in unit tests) } - $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); return; } } + // Normalize to lowercase for validation (RFC 1123 hostnames are case-insensitive) + $hostname = strtolower($hostname); + // Additional validation: hostname should not start or end with a dot if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) { $fail('The :attribute cannot start or end with a dot.'); @@ -100,9 +103,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - // Check if label contains only valid characters (lowercase letters, digits, hyphens) + // Check if label contains only valid characters (letters, digits, hyphens) if (! preg_match('/^[a-z0-9-]+$/', $label)) { - $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); return; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index fdf2b12a6..7b8251729 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -10,7 +10,7 @@ class ValidationPatterns /** * Pattern for names excluding all dangerous characters */ - public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u'; + public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+$/u'; /** * Pattern for descriptions excluding all dangerous characters with some additional allowed characters @@ -96,7 +96,7 @@ public static function descriptionRules(bool $required = false, int $maxLength = public static function nameMessages(): array { return [ - 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &', + 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ & ( ) # , : +', 'name.min' => 'The name must be at least :min characters.', 'name.max' => 'The name may not be greater than :max characters.', ]; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index ce9ab5283..a8cffcaff 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -341,7 +341,16 @@ function generate_application_name(string $git_repository, string $git_branch, ? $repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository; - return Str::kebab("$repo_name:$git_branch-$cuid"); + $name = Str::kebab("$repo_name:$git_branch-$cuid"); + + // Strip characters not allowed by NAME_PATTERN + $name = preg_replace('/[^\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+/u', '', $name); + + if (empty($name) || mb_strlen($name) < 3) { + return generate_random_name($cuid); + } + + return $name; } /** diff --git a/tests/Unit/ValidHostnameTest.php b/tests/Unit/ValidHostnameTest.php index 859262c3e..6580a7c5d 100644 --- a/tests/Unit/ValidHostnameTest.php +++ b/tests/Unit/ValidHostnameTest.php @@ -21,6 +21,8 @@ 'subdomain' => 'web.app.example.com', 'max label length' => str_repeat('a', 63), 'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59), + 'uppercase hostname' => 'MyServer', + 'mixed case fqdn' => 'MyServer.Example.COM', ]); it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) { @@ -36,8 +38,7 @@ expect($failCalled)->toBeTrue(); expect($errorMessage)->toContain($expectedError); })->with([ - 'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'underscore' => ['my_server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], 'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'], 'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'], 'starts with dot' => ['.myserver', 'cannot start or end with a dot'], @@ -46,9 +47,9 @@ 'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'], 'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'], 'empty label' => ['my..server', 'consecutive dots'], - 'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], + 'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], + 'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], ]); it('accepts empty hostname', function () { diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php new file mode 100644 index 000000000..0da8f9a4d --- /dev/null +++ b/tests/Unit/ValidationPatternsTest.php @@ -0,0 +1,82 @@ +toBe(1); +})->with([ + 'simple name' => 'My Server', + 'name with hyphen' => 'my-server', + 'name with underscore' => 'my_server', + 'name with dot' => 'my.server', + 'name with slash' => 'my/server', + 'name with at sign' => 'user@host', + 'name with ampersand' => 'Tom & Jerry', + 'name with parentheses' => 'My Server (Production)', + 'name with hash' => 'Server #1', + 'name with comma' => 'Server, v2', + 'name with colon' => 'Server: Production', + 'name with plus' => 'C++ App', + 'unicode name' => 'Ünïcödé Sërvér', + 'unicode chinese' => '我的服务器', + 'numeric name' => '12345', + 'complex name' => 'App #3 (staging): v2.1+hotfix', +]); + +it('rejects names with dangerous characters', function (string $name) { + expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(0); +})->with([ + 'semicolon' => 'my;server', + 'pipe' => 'my|server', + 'dollar sign' => 'my$server', + 'backtick' => 'my`server', + 'backslash' => 'my\\server', + 'less than' => 'my 'my>server', + 'curly braces' => 'my{server}', + 'square brackets' => 'my[server]', + 'tilde' => 'my~server', + 'caret' => 'my^server', + 'question mark' => 'my?server', + 'percent' => 'my%server', + 'double quote' => 'my"server', + 'exclamation' => 'my!server', + 'asterisk' => 'my*server', +]); + +it('generates nameRules with correct defaults', function () { + $rules = ValidationPatterns::nameRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('min:3') + ->toContain('max:255') + ->toContain('regex:'.ValidationPatterns::NAME_PATTERN); +}); + +it('generates nullable nameRules when not required', function () { + $rules = ValidationPatterns::nameRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); + +it('generates application names that comply with NAME_PATTERN', function (string $repo, string $branch) { + $name = generate_application_name($repo, $branch, 'testcuid'); + + expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1); +})->with([ + 'normal repo' => ['owner/my-app', 'main'], + 'repo with dots' => ['repo.with.dots', 'feat/branch'], + 'repo with plus' => ['C++ App', 'main'], + 'branch with parens' => ['my-app', 'fix(auth)-login'], + 'repo with exclamation' => ['my-app!', 'main'], + 'repo with brackets' => ['app[test]', 'develop'], +]); + +it('falls back to random name when repo produces empty name', function () { + $name = generate_application_name('!!!', 'main', 'testcuid'); + + expect(mb_strlen($name))->toBeGreaterThanOrEqual(3) + ->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1); +}); From 520e048ed5b4c8d86f3b2cfc9024aaa5725a6743 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:08:57 +0100 Subject: [PATCH 07/21] refactor(team): update serverOverflow to use static serverLimit --- app/Models/Team.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Models/Team.php b/app/Models/Team.php index 639d50b60..5a7b377b6 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -112,7 +112,7 @@ public function subscriptionPastOverDue() public function serverOverflow() { - if ($this->serverLimit() < $this->servers->count()) { + if (Team::serverLimit($this) < $this->servers->count()) { return true; } From d3beeb2d000229868127400de2ac3867902414ae Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:52:41 +0100 Subject: [PATCH 08/21] fix(subscription): prevent duplicate subscriptions with updateOrCreate - Replace manual subscription create/update logic with updateOrCreate() and firstOrCreate() to eliminate race conditions - Add validation in PricingPlans to prevent subscribing if team already has active subscription - Improve error handling for missing team_id in customer.subscription.updated event - Add tests verifying subscriptions are updated rather than duplicated --- app/Jobs/StripeProcessJob.php | 51 ++++------- app/Livewire/Subscription/PricingPlans.php | 6 ++ .../Subscription/StripeProcessJobTest.php | 87 +++++++++++++++++++ 3 files changed, 111 insertions(+), 33 deletions(-) diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index f5d52f29c..3485ffe32 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -73,25 +73,15 @@ public function handle(): void // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); } - $subscription = Subscription::where('team_id', $teamId)->first(); - if ($subscription) { - // send_internal_notification('Old subscription activated for team: '.$teamId); - $subscription->update([ + Subscription::updateOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => true, 'stripe_past_due' => false, - ]); - } else { - // send_internal_notification('New subscription for team: '.$teamId); - Subscription::create([ - 'team_id' => $teamId, - 'stripe_subscription_id' => $subscriptionId, - 'stripe_customer_id' => $customerId, - 'stripe_invoice_paid' => true, - 'stripe_past_due' => false, - ]); - } + ] + ); break; case 'invoice.paid': $customerId = data_get($data, 'customer'); @@ -227,18 +217,14 @@ public function handle(): void // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); } - $subscription = Subscription::where('team_id', $teamId)->first(); - if ($subscription) { - // send_internal_notification("Subscription already exists for team: {$teamId}"); - throw new \RuntimeException("Subscription already exists for team: {$teamId}"); - } else { - Subscription::create([ - 'team_id' => $teamId, + Subscription::updateOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => false, - ]); - } + ] + ); break; case 'customer.subscription.updated': $teamId = data_get($data, 'metadata.team_id'); @@ -254,20 +240,19 @@ public function handle(): void $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); if (! $subscription) { if ($status === 'incomplete_expired') { - // send_internal_notification('Subscription incomplete expired'); throw new \RuntimeException('Subscription incomplete expired'); } - if ($teamId) { - $subscription = Subscription::create([ - 'team_id' => $teamId, + if (! $teamId) { + throw new \RuntimeException('No subscription and team id found'); + } + $subscription = Subscription::firstOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => false, - ]); - } else { - // send_internal_notification('No subscription and team id found'); - throw new \RuntimeException('No subscription and team id found'); - } + ] + ); } $cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end'); $feedback = data_get($data, 'cancellation_details.feedback'); diff --git a/app/Livewire/Subscription/PricingPlans.php b/app/Livewire/Subscription/PricingPlans.php index 6b2d3fb36..6e1b85404 100644 --- a/app/Livewire/Subscription/PricingPlans.php +++ b/app/Livewire/Subscription/PricingPlans.php @@ -11,6 +11,12 @@ class PricingPlans extends Component { public function subscribeStripe($type) { + if (currentTeam()->subscription?->stripe_invoice_paid) { + $this->dispatch('error', 'Team already has an active subscription.'); + + return; + } + Stripe::setApiKey(config('subscription.stripe_api_key')); $priceId = match ($type) { diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php index 95cff188a..0a93f858c 100644 --- a/tests/Feature/Subscription/StripeProcessJobTest.php +++ b/tests/Feature/Subscription/StripeProcessJobTest.php @@ -50,6 +50,93 @@ // Critical: stripe_invoice_paid must remain false — payment not yet confirmed expect($subscription->stripe_invoice_paid)->toBeFalsy(); }); + + test('created event updates existing subscription instead of duplicating', function () { + Queue::fake(); + + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_old', + 'stripe_customer_id' => 'cus_old', + 'stripe_invoice_paid' => true, + ]); + + $event = [ + 'type' => 'customer.subscription.created', + 'data' => [ + 'object' => [ + 'customer' => 'cus_new_123', + 'id' => 'sub_new_123', + 'metadata' => [ + 'team_id' => $this->team->id, + 'user_id' => $this->user->id, + ], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1); + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription->stripe_subscription_id)->toBe('sub_new_123'); + expect($subscription->stripe_customer_id)->toBe('cus_new_123'); + }); +}); + +describe('checkout.session.completed', function () { + test('creates subscription for new team', function () { + Queue::fake(); + + $event = [ + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'client_reference_id' => $this->user->id.':'.$this->team->id, + 'subscription' => 'sub_checkout_123', + 'customer' => 'cus_checkout_123', + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription)->not->toBeNull(); + expect($subscription->stripe_invoice_paid)->toBeTruthy(); + }); + + test('updates existing subscription instead of duplicating', function () { + Queue::fake(); + + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_old', + 'stripe_customer_id' => 'cus_old', + 'stripe_invoice_paid' => false, + ]); + + $event = [ + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'client_reference_id' => $this->user->id.':'.$this->team->id, + 'subscription' => 'sub_checkout_new', + 'customer' => 'cus_checkout_new', + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1); + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription->stripe_subscription_id)->toBe('sub_checkout_new'); + expect($subscription->stripe_invoice_paid)->toBeTruthy(); + }); }); describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () { From a980b1352f0e0c61f28c6c5ec680a902c82232d6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:48:04 +0100 Subject: [PATCH 09/21] chore(versions): bump sentinel to 0.0.21 --- other/nightly/versions.json | 2 +- versions.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 57bb21869..c2ab7a7c1 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -13,7 +13,7 @@ "version": "1.0.11" }, "sentinel": { - "version": "0.0.20" + "version": "0.0.21" } }, "traefik": { diff --git a/versions.json b/versions.json index 57bb21869..c2ab7a7c1 100644 --- a/versions.json +++ b/versions.json @@ -13,7 +13,7 @@ "version": "1.0.11" }, "sentinel": { - "version": "0.0.20" + "version": "0.0.21" } }, "traefik": { From efcd5e7dbf254c87a3f4d56f3c3a01d89a6a8c3a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:37:11 +0100 Subject: [PATCH 10/21] docs(readme): add PetroSky Cloud to sponsors --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 73af2a18c..a5aa69343 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ ### Big Sponsors * [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers * [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity * [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity +* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions * [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang * [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting * [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers From 534b8be8d0927b5ff48ccf1da3dcfae6558e3eca Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:17:05 +0100 Subject: [PATCH 11/21] refactor(docker): simplify installation and remove version pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove hardcoded Docker version constraints (27.0 → latest) - Use official Docker install script (get.docker.com) instead of rancher URLs - Simplify installation logic by removing nested version fallback checks - Consolidate OS-specific installation methods and improve Arch Linux upgrade handling --- app/Actions/Server/InstallDocker.php | 15 ++----- other/nightly/install.sh | 63 +++++++++++++--------------- scripts/install.sh | 63 +++++++++++++--------------- 3 files changed, 62 insertions(+), 79 deletions(-) diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 31e582c9b..2e08ec6ad 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -11,11 +11,8 @@ class InstallDocker { use AsAction; - private string $dockerVersion; - public function handle(Server $server) { - $this->dockerVersion = config('constants.docker.minimum_required_version'); $supported_os_type = $server->validateOS(); if (! $supported_os_type) { throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: documentation.'); @@ -118,7 +115,7 @@ public function handle(Server $server) private function getDebianDockerInstallCommand(): string { - return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (". + return 'curl -fsSL https://get.docker.com | sh || ('. '. /etc/os-release && '. 'install -m 0755 -d /etc/apt/keyrings && '. 'curl -fsSL https://download.docker.com/linux/${ID}/gpg -o /etc/apt/keyrings/docker.asc && '. @@ -131,7 +128,7 @@ private function getDebianDockerInstallCommand(): string private function getRhelDockerInstallCommand(): string { - return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (". + return 'curl -fsSL https://get.docker.com | sh || ('. 'dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && '. 'dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '. 'systemctl start docker && '. @@ -141,7 +138,7 @@ private function getRhelDockerInstallCommand(): string private function getSuseDockerInstallCommand(): string { - return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (". + return 'curl -fsSL https://get.docker.com | sh || ('. 'zypper addrepo https://download.docker.com/linux/sles/docker-ce.repo && '. 'zypper refresh && '. 'zypper install -y --no-confirm docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '. @@ -152,10 +149,6 @@ private function getSuseDockerInstallCommand(): string private function getArchDockerInstallCommand(): string { - // Use -Syu to perform full system upgrade before installing Docker - // Partial upgrades (-Sy without -u) are discouraged on Arch Linux - // as they can lead to broken dependencies and system instability - // Use --needed to skip reinstalling packages that are already up-to-date (idempotent) return 'pacman -Syu --noconfirm --needed docker docker-compose && '. 'systemctl enable docker.service && '. 'systemctl start docker.service'; @@ -163,6 +156,6 @@ private function getArchDockerInstallCommand(): string private function getGenericDockerInstallCommand(): string { - return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}"; + return 'curl -fsSL https://get.docker.com | sh'; } } diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 921ba6a92..09406118c 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S") OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') ENV_FILE="/data/coolify/source/.env" -DOCKER_VERSION="27.0" +DOCKER_VERSION="latest" # TODO: Ask for a user CURRENT_USER=$USER @@ -499,13 +499,10 @@ fi install_docker() { set +e - curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true + curl -fsSL https://get.docker.com | sh 2>&1 || true if ! [ -x "$(command -v docker)" ]; then - curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo "Automated Docker installation failed. Trying manual installation." - install_docker_manually - fi + echo "Automated Docker installation failed. Trying manual installation." + install_docker_manually fi set -e } @@ -548,16 +545,6 @@ if ! [ -x "$(command -v docker)" ]; then echo " - Docker is not installed. Installing Docker. It may take a while." getAJoke case "$OS_TYPE" in - "almalinux") - dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 - dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." - exit 1 - fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 - ;; "alpine" | "postmarketos") apk add docker docker-cli-compose >/dev/null 2>&1 rc-update add docker default >/dev/null 2>&1 @@ -569,8 +556,9 @@ if ! [ -x "$(command -v docker)" ]; then fi ;; "arch") - pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1 + pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1 systemctl enable docker.service >/dev/null 2>&1 + systemctl start docker.service >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then echo " - Failed to install Docker with pacman. Try to install it manually." echo " Please visit https://wiki.archlinux.org/title/docker for more information." @@ -581,7 +569,7 @@ if ! [ -x "$(command -v docker)" ]; then dnf install docker -y >/dev/null 2>&1 DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker} mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1 - curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 systemctl start docker >/dev/null 2>&1 systemctl enable docker >/dev/null 2>&1 @@ -591,34 +579,28 @@ if ! [ -x "$(command -v docker)" ]; then exit 1 fi ;; - "centos" | "fedora" | "rhel" | "tencentos") - if [ -x "$(command -v dnf5)" ]; then - # dnf5 is available - dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1 - else - # dnf5 is not available, use dnf - dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1 - fi + "almalinux" | "tencentos") + dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." exit 1 fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 ;; - "ubuntu" | "debian" | "raspbian") + "ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles") install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; *) install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; esac @@ -627,6 +609,19 @@ else echo " - Docker is installed." fi +# Verify minimum Docker version +MIN_DOCKER_VERSION=24 +INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1) +if [ -z "$INSTALLED_DOCKER_VERSION" ]; then + echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed." +elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then + echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer." + echo " Please upgrade Docker: https://docs.docker.com/engine/install/" + exit 1 +else + echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)." +fi + log_section "Step 4/9: Checking Docker configuration" echo "4/9 Checking Docker configuration..." diff --git a/scripts/install.sh b/scripts/install.sh index b014a3d24..2e1dab326 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S") OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') ENV_FILE="/data/coolify/source/.env" -DOCKER_VERSION="27.0" +DOCKER_VERSION="latest" # TODO: Ask for a user CURRENT_USER=$USER @@ -499,13 +499,10 @@ fi install_docker() { set +e - curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true + curl -fsSL https://get.docker.com | sh 2>&1 || true if ! [ -x "$(command -v docker)" ]; then - curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo "Automated Docker installation failed. Trying manual installation." - install_docker_manually - fi + echo "Automated Docker installation failed. Trying manual installation." + install_docker_manually fi set -e } @@ -548,16 +545,6 @@ if ! [ -x "$(command -v docker)" ]; then echo " - Docker is not installed. Installing Docker. It may take a while." getAJoke case "$OS_TYPE" in - "almalinux") - dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 - dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." - exit 1 - fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 - ;; "alpine" | "postmarketos") apk add docker docker-cli-compose >/dev/null 2>&1 rc-update add docker default >/dev/null 2>&1 @@ -569,8 +556,9 @@ if ! [ -x "$(command -v docker)" ]; then fi ;; "arch") - pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1 + pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1 systemctl enable docker.service >/dev/null 2>&1 + systemctl start docker.service >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then echo " - Failed to install Docker with pacman. Try to install it manually." echo " Please visit https://wiki.archlinux.org/title/docker for more information." @@ -581,7 +569,7 @@ if ! [ -x "$(command -v docker)" ]; then dnf install docker -y >/dev/null 2>&1 DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker} mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1 - curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 systemctl start docker >/dev/null 2>&1 systemctl enable docker >/dev/null 2>&1 @@ -591,34 +579,28 @@ if ! [ -x "$(command -v docker)" ]; then exit 1 fi ;; - "centos" | "fedora" | "rhel" | "tencentos") - if [ -x "$(command -v dnf5)" ]; then - # dnf5 is available - dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1 - else - # dnf5 is not available, use dnf - dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1 - fi + "almalinux" | "tencentos") + dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." exit 1 fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 ;; - "ubuntu" | "debian" | "raspbian") + "ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles") install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; *) install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; esac @@ -627,6 +609,19 @@ else echo " - Docker is installed." fi +# Verify minimum Docker version +MIN_DOCKER_VERSION=24 +INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1) +if [ -z "$INSTALLED_DOCKER_VERSION" ]; then + echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed." +elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then + echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer." + echo " Please upgrade Docker: https://docs.docker.com/engine/install/" + exit 1 +else + echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)." +fi + log_section "Step 4/9: Checking Docker configuration" echo "4/9 Checking Docker configuration..." From b8e52c6a45bbeb87037f8c40544e3207cef1db9c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:32:34 +0100 Subject: [PATCH 12/21] feat(proxy): validate stored config matches current proxy type Add validation in GetProxyConfiguration to detect when stored proxy config belongs to a different proxy type (e.g., Traefik config on a Caddy server) and trigger regeneration with a warning log. Clear cached proxy configuration and settings when proxy type is changed to prevent stale configs from being reused. Includes tests verifying config rejection on type mismatch and graceful fallback on invalid YAML. --- app/Actions/Proxy/GetProxyConfiguration.php | 36 +++++++++++ app/Models/Server.php | 3 + tests/Unit/ProxyConfigRecoveryTest.php | 70 ++++++++++++++++++++- 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index de44b476f..159f12252 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -2,10 +2,12 @@ namespace App\Actions\Proxy; +use App\Enums\ProxyTypes; use App\Models\Server; use App\Services\ProxyDashboardCacheService; use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; +use Symfony\Component\Yaml\Yaml; class GetProxyConfiguration { @@ -24,6 +26,17 @@ public function handle(Server $server, bool $forceRegenerate = false): string // Primary source: database $proxy_configuration = $server->proxy->get('last_saved_proxy_configuration'); + // Validate stored config matches current proxy type + if (! empty(trim($proxy_configuration ?? ''))) { + if (! $this->configMatchesProxyType($proxyType, $proxy_configuration)) { + Log::warning('Stored proxy config does not match current proxy type, will regenerate', [ + 'server_id' => $server->id, + 'proxy_type' => $proxyType, + ]); + $proxy_configuration = null; + } + } + // Backfill: existing servers may not have DB config yet — read from disk once if (empty(trim($proxy_configuration ?? ''))) { $proxy_configuration = $this->backfillFromDisk($server); @@ -55,6 +68,29 @@ public function handle(Server $server, bool $forceRegenerate = false): string return $proxy_configuration; } + /** + * Check that the stored docker-compose YAML contains the expected service + * for the server's current proxy type. Returns false if the config belongs + * to a different proxy type (e.g. Traefik config on a CADDY server). + */ + private function configMatchesProxyType(string $proxyType, string $configuration): bool + { + try { + $yaml = Yaml::parse($configuration); + $services = data_get($yaml, 'services', []); + + return match ($proxyType) { + ProxyTypes::TRAEFIK->value => isset($services['traefik']), + ProxyTypes::CADDY->value => isset($services['caddy']), + ProxyTypes::NGINX->value => isset($services['nginx']), + default => true, + }; + } catch (\Throwable $e) { + // If YAML is unparseable, don't block — let the existing flow handle it + return true; + } + } + /** * Backfill: read config from disk for servers that predate DB storage. * Stores the result in the database so future reads skip SSH entirely. diff --git a/app/Models/Server.php b/app/Models/Server.php index 527c744a5..ce877bd20 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1471,6 +1471,9 @@ public function changeProxy(string $proxyType, bool $async = true) if ($validProxyTypes->contains(str($proxyType)->lower())) { $this->proxy->set('type', str($proxyType)->upper()); $this->proxy->set('status', 'exited'); + $this->proxy->set('last_saved_proxy_configuration', null); + $this->proxy->set('last_saved_settings', null); + $this->proxy->set('last_applied_settings', null); $this->save(); if ($this->proxySet()) { if ($async) { diff --git a/tests/Unit/ProxyConfigRecoveryTest.php b/tests/Unit/ProxyConfigRecoveryTest.php index 219ec9bca..e10d899fe 100644 --- a/tests/Unit/ProxyConfigRecoveryTest.php +++ b/tests/Unit/ProxyConfigRecoveryTest.php @@ -10,20 +10,26 @@ Cache::spy(); }); -function mockServerWithDbConfig(?string $savedConfig): object +function mockServerWithDbConfig(?string $savedConfig, string $proxyType = 'TRAEFIK'): object { $proxyAttributes = Mockery::mock(SchemalessAttributes::class); $proxyAttributes->shouldReceive('get') ->with('last_saved_proxy_configuration') ->andReturn($savedConfig); + $proxyPath = match ($proxyType) { + 'CADDY' => '/data/coolify/proxy/caddy', + 'NGINX' => '/data/coolify/proxy/nginx', + default => '/data/coolify/proxy/', + }; + $server = Mockery::mock('App\Models\Server'); $server->shouldIgnoreMissing(); $server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes); $server->shouldReceive('getAttribute')->with('id')->andReturn(1); $server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server'); - $server->shouldReceive('proxyType')->andReturn('TRAEFIK'); - $server->shouldReceive('proxyPath')->andReturn('/data/coolify/proxy'); + $server->shouldReceive('proxyType')->andReturn($proxyType); + $server->shouldReceive('proxyPath')->andReturn($proxyPath); return $server; } @@ -107,3 +113,61 @@ function mockServerWithDbConfig(?string $savedConfig): object expect($result)->toBe($savedConfig); }); + +it('rejects stored Traefik config when proxy type is CADDY', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + $traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n"; + $server = mockServerWithDbConfig($traefikConfig, 'CADDY'); + + // Config type mismatch should trigger regeneration, which will try + // backfillFromDisk (instant_remote_process) then generateDefault. + // Both will fail in test env, but the warning log proves mismatch was detected. + try { + GetProxyConfiguration::run($server); + } catch (\Throwable $e) { + // Expected — regeneration requires SSH/full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type')) + ->once(); +}); + +it('rejects stored Caddy config when proxy type is TRAEFIK', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + $caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n"; + $server = mockServerWithDbConfig($caddyConfig, 'TRAEFIK'); + + try { + GetProxyConfiguration::run($server); + } catch (\Throwable $e) { + // Expected — regeneration requires SSH/full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type')) + ->once(); +}); + +it('accepts stored Caddy config when proxy type is CADDY', function () { + $caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n"; + $server = mockServerWithDbConfig($caddyConfig, 'CADDY'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($caddyConfig); +}); + +it('accepts stored config when YAML parsing fails', function () { + $invalidYaml = "this: is: not: [valid yaml: {{{}}}"; + $server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK'); + + // Invalid YAML should not block — configMatchesProxyType returns true on parse failure + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($invalidYaml); +}); From 6a14a12a58a6df09dd5d48f6e48ed71b58ac7e35 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:52:36 +0100 Subject: [PATCH 13/21] fix(parsers): preserve ${VAR} references in compose instead of resolving to DB values Do not replace self-referencing environment variables (e.g., DATABASE_URL: ${DATABASE_URL}) with saved DB values in the compose environment section. Keeping the reference intact allows Docker Compose to resolve from .env at deploy time, preventing stale values from overriding user updates that haven't been re-parsed. Fixes #9136 --- bootstrap/helpers/parsers.php | 18 +++++----- templates/service-templates-latest.json | 34 +++++++++---------- templates/service-templates.json | 34 +++++++++---------- .../ServiceParserEnvVarPreservationTest.php | 20 +++++------ 4 files changed, 54 insertions(+), 52 deletions(-) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index cd4928d63..4ca693fcb 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -990,16 +990,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } if ($key->value() === $parsedValue->value()) { // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) - // Use firstOrCreate to avoid overwriting user-saved values on redeploy - $envVar = $resource->environment_variables()->firstOrCreate([ + // Ensure the variable exists in DB for .env generation and UI display + $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'is_preview' => false, ]); - // Add the variable to the environment using the saved DB value - $environment[$key->value()] = $envVar->value; + // Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time. + // Do NOT replace with DB value: if user updates env var without re-parsing compose, + // a stale resolved value in environment: would override the correct .env value. } else { if ($value->startsWith('$')) { $isRequired = false; @@ -2341,8 +2342,8 @@ function serviceParser(Service $resource): Collection } if ($key->value() === $parsedValue->value()) { // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) - // Use firstOrCreate to avoid overwriting user-saved values on redeploy - $envVar = $resource->environment_variables()->firstOrCreate([ + // Ensure the variable exists in DB for .env generation and UI display + $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, @@ -2350,8 +2351,9 @@ function serviceParser(Service $resource): Collection 'is_preview' => false, 'comment' => $envComments[$originalKey] ?? null, ]); - // Add the variable to the environment using the saved DB value - $environment[$key->value()] = $envVar->value; + // Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time. + // Do NOT replace with DB value: if user updates env var without re-parsing compose, + // a stale resolved value in environment: would override the correct .env value. } else { if ($value->startsWith('$')) { $isRequired = false; diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index f22a2ab53..51cb39de0 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -310,23 +310,6 @@ "minversion": "0.0.0", "port": "3000" }, - "booklore": { - "documentation": "https://booklore.org/docs/getting-started?utm_source=coolify.io", - "slogan": "Booklore is an open-source library management system for your digital book collection.", - "compose": "c2VydmljZXM6CiAgYm9va2xvcmU6CiAgICBpbWFnZTogJ2Jvb2tsb3JlL2Jvb2tsb3JlOnYxLjE2LjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CT09LTE9SRV84MAogICAgICAtICdVU0VSX0lEPSR7Qk9PS0xPUkVfVVNFUl9JRDotMH0nCiAgICAgIC0gJ0dST1VQX0lEPSR7Qk9PS0xPUkVfR1JPVVBfSUQ6LTB9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdEQVRBQkFTRV9VUkw9amRiYzptYXJpYWRiOi8vbWFyaWFkYjozMzA2LyR7TUFSSUFEQl9EQVRBQkFTRTotYm9va2xvcmUtZGJ9JwogICAgICAtICdEQVRBQkFTRV9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtIEJPT0tMT1JFX1BPUlQ9ODAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jvb2tsb3JlLWRhdGE6L2FwcC9kYXRhJwogICAgICAtICdib29rbG9yZS1ib29rczovYm9va3MnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiB+L2Jvb2tsb3JlCiAgICAgICAgdGFyZ2V0OiAvYm9va2Ryb3AKICAgICAgICBpc19kaXJlY3Rvcnk6IHRydWUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtLW5vLXZlcmJvc2UgLS10cmllcz0xIC0tc3BpZGVyIGh0dHA6Ly9sb2NhbGhvc3QvbG9naW4gfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdNQVJJQURCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJST09UfScKICAgICAgLSAnTUFSSUFEQl9EQVRBQkFTRT0ke01BUklBREJfREFUQUJBU0U6LWJvb2tsb3JlLWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21hcmlhZGItZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "media", - "books", - "kobo", - "epub", - "ebook", - "koreader" - ], - "category": null, - "logo": "svgs/booklore.svg", - "minversion": "0.0.0", - "port": "80" - }, "bookstack": { "documentation": "https://www.bookstackapp.com/docs/?utm_source=coolify.io", "slogan": "BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information", @@ -1204,6 +1187,23 @@ "minversion": "0.0.0", "port": "6052" }, + "espocrm": { + "documentation": "https://docs.espocrm.com?utm_source=coolify.io", + "slogan": "EspoCRM is a free and open-source CRM platform.", + "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fVVNFUk5BTUU9JHtFU1BPQ1JNX0FETUlOX1VTRVJOQU1FOi1hZG1pbn0nCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX1BMQVRGT1JNPU15c3FsCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9IT1NUPWVzcG9jcm0tZGIKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9OQU1FPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fU0lURV9VUkw9JHtTRVJWSUNFX1VSTF9FU1BPQ1JNfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHN0YXJ0X3BlcmlvZDogNjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGFlbW9uOgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06OScKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRhZW1vbgogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItZGFlbW9uLnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS13ZWJzb2NrZXQ6CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0td2Vic29ja2V0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVAogICAgICAtICdFU1BPQ1JNX0NPTkZJR19XRUJfU09DS0VUX1pFUk9fTV9RX1NVQlNDUklCRVJfRFNOPXRjcDovLyo6Nzc3NycKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJNSVNTSU9OX0RTTj10Y3A6Ly9lc3BvY3JtLXdlYnNvY2tldDo3Nzc3JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItd2Vic29ja2V0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm0tZGI6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK", + "tags": [ + "crm", + "self-hosted", + "open-source", + "workflow", + "automation", + "project management" + ], + "category": "cms", + "logo": "svgs/espocrm.svg", + "minversion": "0.0.0", + "port": "80" + }, "evolution-api": { "documentation": "https://doc.evolution-api.com/v2/en/get-started/introduction?utm_source=coolify.io", "slogan": "Multi-platform messaging (whatsapp and more) integration API", diff --git a/templates/service-templates.json b/templates/service-templates.json index 22d0d6d8c..85445faf6 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -310,23 +310,6 @@ "minversion": "0.0.0", "port": "3000" }, - "booklore": { - "documentation": "https://booklore.org/docs/getting-started?utm_source=coolify.io", - "slogan": "Booklore is an open-source library management system for your digital book collection.", - "compose": "c2VydmljZXM6CiAgYm9va2xvcmU6CiAgICBpbWFnZTogJ2Jvb2tsb3JlL2Jvb2tsb3JlOnYxLjE2LjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQk9PS0xPUkVfODAKICAgICAgLSAnVVNFUl9JRD0ke0JPT0tMT1JFX1VTRVJfSUQ6LTB9JwogICAgICAtICdHUk9VUF9JRD0ke0JPT0tMT1JFX0dST1VQX0lEOi0wfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnREFUQUJBU0VfVVJMPWpkYmM6bWFyaWFkYjovL21hcmlhZGI6MzMwNi8ke01BUklBREJfREFUQUJBU0U6LWJvb2tsb3JlLWRifScKICAgICAgLSAnREFUQUJBU0VfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0RBVEFCQVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSBCT09LTE9SRV9QT1JUPTgwCiAgICB2b2x1bWVzOgogICAgICAtICdib29rbG9yZS1kYXRhOi9hcHAvZGF0YScKICAgICAgLSAnYm9va2xvcmUtYm9va3M6L2Jvb2tzJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogfi9ib29rbG9yZQogICAgICAgIHRhcmdldDogL2Jvb2tkcm9wCiAgICAgICAgaXNfZGlyZWN0b3J5OiB0cnVlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0L2xvZ2luIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCUk9PVH0nCiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1ib29rbG9yZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "media", - "books", - "kobo", - "epub", - "ebook", - "koreader" - ], - "category": null, - "logo": "svgs/booklore.svg", - "minversion": "0.0.0", - "port": "80" - }, "bookstack": { "documentation": "https://www.bookstackapp.com/docs/?utm_source=coolify.io", "slogan": "BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information", @@ -1204,6 +1187,23 @@ "minversion": "0.0.0", "port": "6052" }, + "espocrm": { + "documentation": "https://docs.espocrm.com?utm_source=coolify.io", + "slogan": "EspoCRM is a free and open-source CRM platform.", + "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fRVNQT0NSTQogICAgICAtICdFU1BPQ1JNX0FETUlOX1VTRVJOQU1FPSR7RVNQT0NSTV9BRE1JTl9VU0VSTkFNRTotYWRtaW59JwogICAgICAtICdFU1BPQ1JNX0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9QTEFURk9STT1NeXNxbAogICAgICAtIEVTUE9DUk1fREFUQUJBU0VfSE9TVD1lc3BvY3JtLWRiCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfTkFNRT0ke01BUklBREJfREFUQUJBU0U6LWVzcG9jcm19JwogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdFU1BPQ1JNX1NJVEVfVVJMPSR7U0VSVklDRV9GUUROX0VTUE9DUk19JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgc3RhcnRfcGVyaW9kOiA2MHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYWVtb246CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0tZGFlbW9uCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtOi92YXIvd3d3L2h0bWwnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGVudHJ5cG9pbnQ6IGRvY2tlci1kYWVtb24uc2gKICAgIGRlcGVuZHNfb246CiAgICAgIGVzcG9jcm06CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBlc3BvY3JtLXdlYnNvY2tldDoKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS13ZWJzb2NrZXQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX0ZRRE5fRVNQT0NSTV9XRUJTT0NLRVQKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJTQ1JJQkVSX0RTTj10Y3A6Ly8qOjc3NzcnCiAgICAgIC0gJ0VTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfWkVST19NX1FfU1VCTUlTU0lPTl9EU049dGNwOi8vZXNwb2NybS13ZWJzb2NrZXQ6Nzc3NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIHJlc3RhcnQ6IGFsd2F5cwogICAgZW50cnlwb2ludDogZG9ja2VyLXdlYnNvY2tldC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNQVJJQURCX0RBVEFCQVNFPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ01BUklBREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtLWRiOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCg==", + "tags": [ + "crm", + "self-hosted", + "open-source", + "workflow", + "automation", + "project management" + ], + "category": "cms", + "logo": "svgs/espocrm.svg", + "minversion": "0.0.0", + "port": "80" + }, "evolution-api": { "documentation": "https://doc.evolution-api.com/v2/en/get-started/introduction?utm_source=coolify.io", "slogan": "Multi-platform messaging (whatsapp and more) integration API", diff --git a/tests/Unit/ServiceParserEnvVarPreservationTest.php b/tests/Unit/ServiceParserEnvVarPreservationTest.php index 3f56447dc..16a5ad676 100644 --- a/tests/Unit/ServiceParserEnvVarPreservationTest.php +++ b/tests/Unit/ServiceParserEnvVarPreservationTest.php @@ -4,7 +4,7 @@ * Unit tests to verify that Docker Compose environment variables * do not overwrite user-saved values on redeploy. * - * Regression test for GitHub issue #8885. + * Regression test for GitHub issues #8885 and #9136. */ it('uses firstOrCreate for simple variable references in serviceParser to preserve user values', function () { $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); @@ -14,8 +14,8 @@ // This is the key === parsedValue branch expect($parsersFile)->toContain( "// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n". - " // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n". - ' $envVar = $resource->environment_variables()->firstOrCreate(' + " // Ensure the variable exists in DB for .env generation and UI display\n". + ' $resource->environment_variables()->firstOrCreate(' ); }); @@ -46,15 +46,15 @@ expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default'); }); -it('populates environment array with saved DB value instead of raw compose variable', function () { +it('does not replace self-referencing variable values in the environment array', function () { $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); - // After firstOrCreate, the environment should be populated with the DB value ($envVar->value) - // not the raw compose variable reference (e.g., ${DATABASE_URL}) - // This pattern should appear in both parsers for all variable reference types - expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value'); - expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;'); - expect($parsersFile)->toContain('$environment[$content] = $envVar->value;'); + // Fix for #9136: self-referencing variables (KEY=${KEY}) must NOT have their ${VAR} + // reference replaced with the DB value in the compose environment section. + // Instead, the reference should stay intact so Docker Compose resolves from .env at deploy time. + // This prevents stale values when users update env vars without re-parsing compose. + expect($parsersFile)->toContain('Keep the ${VAR} reference in compose'); + expect($parsersFile)->not->toContain('$environment[$key->value()] = $envVar->value;'); }); it('does not use updateOrCreate with value null for user-editable environment variables', function () { From bf306ffad3d876b3b1fd69c579348a7fa37e4fe8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:57:40 +0100 Subject: [PATCH 14/21] chore: bump version to 4.0.0-beta.470 --- config/constants.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/constants.php b/config/constants.php index 803a0a0bd..b0a772541 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.469', + 'version' => '4.0.0-beta.470', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.11', 'self_hosted' => env('SELF_HOSTED', true), From e6de2618f96d24fd8d59d22e8434bd26b8a7558a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:07:22 +0100 Subject: [PATCH 15/21] feat(sync): sync install.sh, docker-compose, and env files to GitHub Adds syncFilesToGitHubRepo method to handle syncing install.sh, docker-compose, and env files to the coolify-cdn repository via a feature branch and PR. Supports both nightly and production environments. --- app/Console/Commands/SyncBunny.php | 308 ++++++++++++++++++++++++++++- 1 file changed, 305 insertions(+), 3 deletions(-) diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 0a98f1dc8..9ac3371e0 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -363,6 +363,162 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b } } + /** + * Sync install.sh, docker-compose, and env files to GitHub repository via PR + */ + private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool + { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("Syncing $envLabel files to GitHub repository..."); + try { + $timestamp = time(); + $tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp; + $branchName = 'update-files-'.$timestamp; + + // Clone the repository + $this->info('Cloning coolify-cdn repository...'); + $output = []; + 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)); + + return false; + } + + // Create feature branch + $this->info('Creating feature branch...'); + $output = []; + 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 '.escapeshellarg($tmpDir)); + + return false; + } + + // Copy each file to its target path in the CDN repo + $copiedFiles = []; + foreach ($files as $sourceFile => $targetPath) { + if (! file_exists($sourceFile)) { + $this->warn("Source file not found, skipping: $sourceFile"); + + continue; + } + + $destPath = "$tmpDir/$targetPath"; + $destDir = dirname($destPath); + + if (! is_dir($destDir)) { + if (! mkdir($destDir, 0755, true)) { + $this->error("Failed to create directory: $destDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + } + + if (copy($sourceFile, $destPath) === false) { + $this->error("Failed to copy $sourceFile to $destPath"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + $copiedFiles[] = $targetPath; + $this->info("Copied: $targetPath"); + } + + if (empty($copiedFiles)) { + $this->warn('No files were copied. Nothing to commit.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return true; + } + + // Stage all copied files + $this->info('Staging changes...'); + $output = []; + $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1'; + exec($stageCmd, $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to stage changes: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Check for changes + $this->info('Checking for changes...'); + $statusOutput = []; + exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 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('All files are already up to date. No changes to commit.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return true; + } + + // Commit changes + $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s'); + $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 '.escapeshellarg($tmpDir)); + + return false; + } + + // Push to remote + $this->info('Pushing branch to remote...'); + $output = []; + 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 '.escapeshellarg($tmpDir)); + + return false; + } + + // Create pull request + $this->info('Creating pull request...'); + $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s'); + $fileList = implode("\n- ", $copiedFiles); + $prBody = "Automated update of $envLabel files:\n- $fileList"; + $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; + $output = []; + exec($prCommand, $output, $returnCode); + + // Clean up + exec('rm -rf '.escapeshellarg($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 URL: '.implode("\n", $output)); + } + $this->info('Files synced: '.count($copiedFiles)); + + return true; + } catch (\Throwable $e) { + $this->error('Error syncing files to GitHub: '.$e->getMessage()); + + return false; + } + } + /** * Sync versions.json to GitHub repository via PR */ @@ -581,11 +737,130 @@ public function handle() $versions_location = "$parent_dir/other/nightly/$versions"; } if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn."); + $this->newLine(); + + // Build file mapping for diff if ($nightly) { - $this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.'); + $fileMapping = [ + $compose_file_location => 'docker/nightly/docker-compose.yml', + $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', + $production_env_location => 'environment/nightly/.env.production', + $upgrade_script_location => 'scripts/nightly/upgrade.sh', + $install_script_location => 'scripts/nightly/install.sh', + ]; } else { - $this->info('About to sync files PRODUCTION (docker-compose.yml, docker-compose.prod.yml, upgrade.sh, install.sh, etc) to BunnyCDN.'); + $fileMapping = [ + $compose_file_location => 'docker/docker-compose.yml', + $compose_file_prod_location => 'docker/docker-compose.prod.yml', + $production_env_location => 'environment/.env.production', + $upgrade_script_location => 'scripts/upgrade.sh', + $install_script_location => 'scripts/install.sh', + ]; } + + // BunnyCDN file mapping (local file => CDN URL path) + $bunnyFileMapping = [ + $compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file", + $compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod", + $production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env", + $upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script", + $install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script", + ]; + + $diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time(); + @mkdir($diffTmpDir, 0755, true); + $hasChanges = false; + + // Diff against BunnyCDN + $this->info('Fetching files from BunnyCDN to compare...'); + foreach ($bunnyFileMapping as $localFile => $cdnUrl) { + if (! file_exists($localFile)) { + $this->warn('Local file not found: '.$localFile); + + continue; + } + + $fileName = basename($cdnUrl); + $remoteTmp = "$diffTmpDir/bunny-$fileName"; + + try { + $response = Http::timeout(10)->get($cdnUrl); + if ($response->successful()) { + file_put_contents($remoteTmp, $response->body()); + $diffOutput = []; + exec('diff -u '.escapeshellarg($remoteTmp).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode); + if ($diffCode !== 0) { + $hasChanges = true; + $this->newLine(); + $this->info("--- BunnyCDN: $bunny_cdn_path/$fileName"); + $this->info("+++ Local: $fileName"); + foreach ($diffOutput as $line) { + if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { + continue; + } + $this->line($line); + } + } + } else { + $this->info("NEW on BunnyCDN: $bunny_cdn_path/$fileName (HTTP {$response->status()})"); + $hasChanges = true; + } + } catch (\Throwable $e) { + $this->warn("Could not fetch $cdnUrl: {$e->getMessage()}"); + } + } + + // Diff against GitHub coolify-cdn repo + $this->newLine(); + $this->info('Fetching coolify-cdn repo to compare...'); + $output = []; + exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode); + + if ($returnCode === 0) { + foreach ($fileMapping as $localFile => $cdnPath) { + $remotePath = "$diffTmpDir/repo/$cdnPath"; + if (! file_exists($localFile)) { + continue; + } + if (! file_exists($remotePath)) { + $this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)"); + $hasChanges = true; + + continue; + } + + $diffOutput = []; + exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode); + if ($diffCode !== 0) { + $hasChanges = true; + $this->newLine(); + $this->info("--- GitHub: $cdnPath"); + $this->info("+++ Local: $cdnPath"); + foreach ($diffOutput as $line) { + if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { + continue; + } + $this->line($line); + } + } + } + } else { + $this->warn('Could not fetch coolify-cdn repo for diff.'); + } + + exec('rm -rf '.escapeshellarg($diffTmpDir)); + + if (! $hasChanges) { + $this->newLine(); + $this->info('No differences found. All files are already up to date.'); + + return; + } + + $this->newLine(); + $confirmed = confirm('Are you sure you want to sync?'); if (! $confirmed) { return; @@ -692,7 +967,34 @@ public function handle() $pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"), ]); - $this->info('All files uploaded & purged...'); + $this->info('All files uploaded & purged to BunnyCDN.'); + $this->newLine(); + + // Sync files to GitHub CDN repository via PR + $this->info('Creating GitHub PR for coolify-cdn repository...'); + if ($nightly) { + $files = [ + $compose_file_location => 'docker/nightly/docker-compose.yml', + $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', + $production_env_location => 'environment/nightly/.env.production', + $upgrade_script_location => 'scripts/nightly/upgrade.sh', + $install_script_location => 'scripts/nightly/install.sh', + ]; + } else { + $files = [ + $compose_file_location => 'docker/docker-compose.yml', + $compose_file_prod_location => 'docker/docker-compose.prod.yml', + $production_env_location => 'environment/.env.production', + $upgrade_script_location => 'scripts/upgrade.sh', + $install_script_location => 'scripts/install.sh', + ]; + } + + $githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly); + $this->newLine(); + $this->info('=== Summary ==='); + $this->info('BunnyCDN sync: Complete'); + $this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed')); } catch (\Throwable $e) { $this->error('Error: '.$e->getMessage()); } From b8b49b9f4226add14a1789fe221659d5fc8e8ec2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:13:54 +0100 Subject: [PATCH 16/21] chore(docker): update container image versions - Bump coolify-realtime from 1.0.10 to 1.0.11 - Pin redis to 7-alpine across all compose files - Remove unnecessary quotes in extra_hosts entries --- other/nightly/docker-compose.prod.yml | 2 +- other/nightly/docker-compose.windows.yml | 2 +- other/nightly/docker-compose.yml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index d42047245..0bd4ae2dd 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.11' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index bf1f94af0..ca233356a 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -79,7 +79,7 @@ services: retries: 10 timeout: 2s redis: - image: redis:alpine + image: redis:7-alpine pull_policy: always container_name: coolify-redis restart: always diff --git a/other/nightly/docker-compose.yml b/other/nightly/docker-compose.yml index 68d0f0744..0fd3dda07 100644 --- a/other/nightly/docker-compose.yml +++ b/other/nightly/docker-compose.yml @@ -4,7 +4,7 @@ services: restart: always working_dir: /var/www/html extra_hosts: - - 'host.docker.internal:host-gateway' + - host.docker.internal:host-gateway networks: - coolify depends_on: @@ -18,7 +18,7 @@ services: networks: - coolify redis: - image: redis:alpine + image: redis:7-alpine container_name: coolify-redis restart: always networks: @@ -26,7 +26,7 @@ services: soketi: container_name: coolify-realtime extra_hosts: - - 'host.docker.internal:host-gateway' + - host.docker.internal:host-gateway restart: always networks: - coolify From 14a7f8646cad266d567035a7beb9cf6cfd90d54c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:43:47 +0100 Subject: [PATCH 17/21] fix(backup): prevent notification failures from affecting backup status - Wrap notification calls in try-catch blocks to log failures instead - Prevent failed() method from overwriting successful backup status - Skip failure notifications if backup already completed successfully - Ensures post-backup errors (e.g. notification failures) never retroactively mark successful backups as failed Fixes #9088 --- app/Jobs/DatabaseBackupJob.php | 61 ++++++++++++++------ tests/Feature/DatabaseBackupJobTest.php | 76 +++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 16 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index b55c324be..041d31bad 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -399,7 +399,15 @@ public function handle(): void 's3_uploaded' => null, ]); } - $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + try { + $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + } catch (\Throwable $notifyException) { + Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [ + 'backup_id' => $this->backup->uuid, + 'database' => $database, + 'error' => $notifyException->getMessage(), + ]); + } continue; } @@ -439,11 +447,20 @@ public function handle(): void 'local_storage_deleted' => $localStorageDeleted, ]); - // Send appropriate notification - if ($s3UploadError) { - $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError)); - } else { - $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); + // Send appropriate notification (wrapped in try-catch so notification + // failures never affect backup status — see GitHub issue #9088) + try { + if ($s3UploadError) { + $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError)); + } else { + $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); + } + } catch (\Throwable $e) { + Log::channel('scheduled-errors')->warning('Failed to send backup success notification', [ + 'backup_id' => $this->backup->uuid, + 'database' => $database, + 'error' => $e->getMessage(), + ]); } } } @@ -710,20 +727,32 @@ public function failed(?Throwable $exception): void $log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first(); if ($log) { - $log->update([ - 'status' => 'failed', - 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'), - 'size' => 0, - 'filename' => null, - 'finished_at' => Carbon::now(), - ]); + // Don't overwrite a successful backup status — a post-backup error + // (e.g. notification failure) should not retroactively mark the backup + // as failed (see GitHub issue #9088) + if ($log->status !== 'success') { + $log->update([ + 'status' => 'failed', + 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'), + 'size' => 0, + 'filename' => null, + 'finished_at' => Carbon::now(), + ]); + } } - // Notify team about permanent failure - if ($this->team) { + // Notify team about permanent failure (only if backup didn't already succeed) + if ($this->team && $log?->status !== 'success') { $databaseName = $log?->database_name ?? 'unknown'; $output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error'; - $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName)); + try { + $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName)); + } catch (\Throwable $e) { + Log::channel('scheduled-errors')->warning('Failed to send backup permanent failure notification', [ + 'backup_id' => $this->backup->uuid, + 'error' => $e->getMessage(), + ]); + } } } } diff --git a/tests/Feature/DatabaseBackupJobTest.php b/tests/Feature/DatabaseBackupJobTest.php index 37c377dab..05cb21f12 100644 --- a/tests/Feature/DatabaseBackupJobTest.php +++ b/tests/Feature/DatabaseBackupJobTest.php @@ -120,6 +120,82 @@ expect($unrelatedBackup->save_s3)->toBeTruthy(); }); +test('failed method does not overwrite successful backup status', function () { + $team = Team::factory()->create(); + + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => false, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + $log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => 'test-uuid-success-guard', + 'database_name' => 'test_db', + 'filename' => '/backup/test.dmp', + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'message' => 'Backup completed successfully', + 'size' => 1024, + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + + $teamProp = $reflection->getProperty('team'); + $teamProp->setValue($job, $team); + + $logUuidProp = $reflection->getProperty('backup_log_uuid'); + $logUuidProp->setValue($job, 'test-uuid-success-guard'); + + // Simulate a post-backup failure (e.g. notification error) + $job->failed(new Exception('Request to the Resend API failed')); + + $log->refresh(); + expect($log->status)->toBe('success'); + expect($log->message)->toBe('Backup completed successfully'); + expect($log->size)->toBe(1024); +}); + +test('failed method updates status when backup was not successful', function () { + $team = Team::factory()->create(); + + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => false, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + $log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => 'test-uuid-pending-guard', + 'database_name' => 'test_db', + 'filename' => '/backup/test.dmp', + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'pending', + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + + $teamProp = $reflection->getProperty('team'); + $teamProp->setValue($job, $team); + + $logUuidProp = $reflection->getProperty('backup_log_uuid'); + $logUuidProp->setValue($job, 'test-uuid-pending-guard'); + + $job->failed(new Exception('Some real failure')); + + $log->refresh(); + expect($log->status)->toBe('failed'); + expect($log->message)->toContain('Some real failure'); +}); + test('s3 storage has scheduled backups relationship', function () { $team = Team::factory()->create(); From ca769baf179569e07a241967b6df9a77f2d56ba4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:25:41 +0100 Subject: [PATCH 18/21] chore: bump version to 4.0.0-beta.471 --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index b0a772541..828493208 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.470', + 'version' => '4.0.0-beta.471', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.11', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index c2ab7a7c1..af11ef4d3 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.470" + "version": "4.0.0-beta.471" }, "nightly": { "version": "4.0.0" diff --git a/versions.json b/versions.json index c2ab7a7c1..af11ef4d3 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.470" + "version": "4.0.0-beta.471" }, "nightly": { "version": "4.0.0" From 3034e89edb3c01c82468af52ae51c60fdcb23395 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:26:50 +0100 Subject: [PATCH 19/21] feat(preview-env): add production variable fallback for docker-compose When preview environment variables are configured, fall back to production variables for keys not overridden by preview values. This ensures variables like DB_PASSWORD that exist only in production are available in the preview .env file, enabling proper ${VAR} interpolation in docker-compose YAML. Fallback only applies when preview variables are configured, preventing unintended leakage of production values when previews aren't in use. Also improves UI by hiding the Domains section when only database services are present, and simplifies the logs view by removing status checks. --- app/Jobs/ApplicationDeploymentJob.php | 16 ++ app/Models/EnvironmentVariable.php | 5 + .../components/applications/links.blade.php | 2 +- .../project/application/general.blade.php | 36 ++- .../livewire/project/shared/logs.blade.php | 58 ++-- tests/Feature/PreviewEnvVarFallbackTest.php | 247 ++++++++++++++++++ 6 files changed, 318 insertions(+), 46 deletions(-) create mode 100644 tests/Feature/PreviewEnvVarFallbackTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 9d927d10c..2af380a45 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1333,6 +1333,22 @@ private function generate_runtime_environment_variables() foreach ($runtime_environment_variables_preview as $env) { $envs->push($env->key.'='.$env->real_value); } + + // Fall back to production env vars for keys not overridden by preview vars, + // but only when preview vars are configured. This ensures variables like + // DB_PASSWORD that are only set for production will be available in the + // preview .env file (fixing ${VAR} interpolation in docker-compose YAML), + // while avoiding leaking production values when previews aren't configured. + if ($runtime_environment_variables_preview->isNotEmpty()) { + $previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray(); + $fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) { + return $env->is_runtime && ! in_array($env->key, $previewKeys); + }); + foreach ($fallback_production_vars as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } + // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) { diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index cf60d5ab5..5acd4c1e4 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -32,6 +32,11 @@ )] class EnvironmentVariable extends BaseModel { + protected $attributes = [ + 'is_runtime' => true, + 'is_buildtime' => true, + ]; + protected $fillable = [ // Core identification 'key', diff --git a/resources/views/components/applications/links.blade.php b/resources/views/components/applications/links.blade.php index 26b1cedf5..85e8f7431 100644 --- a/resources/views/components/applications/links.blade.php +++ b/resources/views/components/applications/links.blade.php @@ -4,7 +4,7 @@ @if ( (data_get($application, 'fqdn') || - collect(json_decode($this->application->docker_compose_domains))->count() > 0 || + collect(json_decode($this->application->docker_compose_domains))->contains(fn($fqdn) => !empty(data_get($fqdn, 'domain'))) || data_get($application, 'previews', collect([]))->count() > 0 || data_get($application, 'ports_mappings_array')) && data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true) diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index e27eda8b6..d743e346e 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -49,7 +49,13 @@ !is_null($parsedServices) && count($parsedServices) > 0 && !$application->settings->is_raw_compose_deployment_enabled) -

Domains

+ @php + $hasNonDatabaseService = collect(data_get($parsedServices, 'services', [])) + ->contains(fn($service) => !isDatabaseImage(data_get($service, 'image'))); + @endphp + @if ($hasNonDatabaseService) +

Domains

+ @endif @foreach (data_get($parsedServices, 'services') as $serviceName => $service) @if (!isDatabaseImage(data_get($service, 'image')))
@@ -86,18 +92,20 @@ ]" /> @endcan @endif -
- @if ($application->could_set_build_commands()) - - @endif - @if ($isStatic && $buildPack !== 'static') - - @endif -
+ @if ($application->could_set_build_commands() || ($isStatic && $buildPack !== 'static')) +
+ @if ($application->could_set_build_commands()) + + @endif + @if ($isStatic && $buildPack !== 'static') + + @endif +
+ @endif @if ($buildPack !== 'dockercompose')
@if ($application->settings->is_container_label_readonly_enabled == false) @@ -209,7 +217,7 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @endif
@endif -
+

Build

@if ($application->build_pack === 'dockerimage')

Logs

- @if (str($status)->contains('exited')) -
The resource is not running.
- @else -
- Loading containers... -
-
- @forelse ($servers as $server) -
-

Server: {{ $server->name }}

- @if ($server->isFunctional()) - @if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0) - @php - $totalContainers = collect($serverContainers)->flatten(1)->count(); - @endphp - @foreach ($serverContainers[$server->id] as $container) - - @endforeach - @else -
No containers are running on server: {{ $server->name }}
- @endif +
+ Loading containers... +
+
+ @forelse ($servers as $server) +
+

Server: {{ $server->name }}

+ @if ($server->isFunctional()) + @if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0) + @php + $totalContainers = collect($serverContainers)->flatten(1)->count(); + @endphp + @foreach ($serverContainers[$server->id] as $container) + + @endforeach @else -
Server {{ $server->name }} is not functional.
+
No containers are running on server: {{ $server->name }}
@endif -
- @empty -
No functional server found for the application.
- @endforelse -
- @endif + @else +
Server {{ $server->name }} is not functional.
+ @endif +
+ @empty +
No functional server found for the application.
+ @endforelse +
@elseif ($type === 'database')

Logs

diff --git a/tests/Feature/PreviewEnvVarFallbackTest.php b/tests/Feature/PreviewEnvVarFallbackTest.php new file mode 100644 index 000000000..e3fc3023f --- /dev/null +++ b/tests/Feature/PreviewEnvVarFallbackTest.php @@ -0,0 +1,247 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); + + $this->actingAs($this->user); +}); + +/** + * Simulate the preview .env generation logic from + * ApplicationDeploymentJob::generate_runtime_environment_variables() + * including the production fallback fix. + */ +function simulatePreviewEnvGeneration(Application $application): \Illuminate\Support\Collection +{ + $sorted_environment_variables = $application->environment_variables->sortBy('id'); + $sorted_environment_variables_preview = $application->environment_variables_preview->sortBy('id'); + + $envs = collect([]); + + // Preview vars + $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(fn ($env) => $env->is_runtime); + foreach ($runtime_environment_variables_preview as $env) { + $envs->push($env->key.'='.$env->real_value); + } + + // Fallback: production vars not overridden by preview, + // only when preview vars are configured + if ($runtime_environment_variables_preview->isNotEmpty()) { + $previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray(); + $fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) { + return $env->is_runtime && ! in_array($env->key, $previewKeys); + }); + foreach ($fallback_production_vars as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } + + return $envs; +} + +test('production vars fall back when preview vars exist but do not cover all keys', function () { + // Create two production vars (booted hook auto-creates preview copies) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'APP_KEY', + 'value' => 'app_key_value', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete only the DB_PASSWORD preview copy — APP_KEY preview copy remains + $this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->delete(); + $this->application->refresh(); + + // Preview has APP_KEY but not DB_PASSWORD + expect($this->application->environment_variables_preview()->where('key', 'APP_KEY')->count())->toBe(1); + expect($this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->count())->toBe(0); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + // DB_PASSWORD should fall back from production + expect($envString)->toContain('DB_PASSWORD='); + // APP_KEY should use the preview value + expect($envString)->toContain('APP_KEY='); +}); + +test('no fallback when no preview vars are configured at all', function () { + // Create a production-only var (booted hook auto-creates preview copy) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete ALL preview copies — simulates no preview config + $this->application->environment_variables_preview()->delete(); + $this->application->refresh(); + + expect($this->application->environment_variables_preview()->count())->toBe(0); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + // Should NOT fall back to production when no preview vars exist + expect($envString)->not->toContain('DB_PASSWORD='); +}); + +test('preview var overrides production var when both exist', function () { + // Create production var (auto-creates preview copy) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'prod_password', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Update the auto-created preview copy with a different value + $this->application->environment_variables_preview() + ->where('key', 'DB_PASSWORD') + ->update(['value' => encrypt('preview_password')]); + + $this->application->refresh(); + $envs = simulatePreviewEnvGeneration($this->application); + + // Should contain preview value only, not production + $envEntries = $envs->filter(fn ($e) => str_starts_with($e, 'DB_PASSWORD=')); + expect($envEntries)->toHaveCount(1); + expect($envEntries->first())->toContain('preview_password'); +}); + +test('preview-only var works without production counterpart', function () { + // Create a preview-only var directly (no production counterpart) + EnvironmentVariable::create([ + 'key' => 'PREVIEW_ONLY_VAR', + 'value' => 'preview_value', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $this->application->refresh(); + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + expect($envString)->toContain('PREVIEW_ONLY_VAR='); +}); + +test('buildtime-only production vars are not included in preview fallback', function () { + // Create a runtime preview var so fallback is active + EnvironmentVariable::create([ + 'key' => 'SOME_PREVIEW_VAR', + 'value' => 'preview_value', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Create a buildtime-only production var + EnvironmentVariable::create([ + 'key' => 'BUILD_SECRET', + 'value' => 'build_only', + 'is_preview' => false, + 'is_runtime' => false, + 'is_buildtime' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete the auto-created preview copy of BUILD_SECRET + $this->application->environment_variables_preview()->where('key', 'BUILD_SECRET')->delete(); + $this->application->refresh(); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + expect($envString)->not->toContain('BUILD_SECRET'); + expect($envString)->toContain('SOME_PREVIEW_VAR='); +}); + +test('preview env var inherits is_runtime and is_buildtime from production var', function () { + // Create production var WITH explicit flags + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $preview = EnvironmentVariable::where('key', 'DB_PASSWORD') + ->where('is_preview', true) + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($preview)->not->toBeNull(); + expect($preview->is_runtime)->toBeTrue(); + expect($preview->is_buildtime)->toBeTrue(); +}); + +test('preview env var gets correct defaults when production var created without explicit flags', function () { + // Simulate code paths (docker-compose parser, dev view bulk submit) that create + // env vars without explicitly setting is_runtime/is_buildtime + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $preview = EnvironmentVariable::where('key', 'DB_PASSWORD') + ->where('is_preview', true) + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($preview)->not->toBeNull(); + expect($preview->is_runtime)->toBeTrue(); + expect($preview->is_buildtime)->toBeTrue(); +}); From 69ea7dfa50f431fd205b2adb18b04d41c92443f2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:08:48 +0100 Subject: [PATCH 20/21] docs(tdd): add bug fix workflow section with TDD requirements Add a new "Bug Fix Workflow (TDD)" section that establishes the strict test-driven development process for bug fixes. Clarify that every bug fix must follow TDD: write a failing test, fix the bug, verify the test passes without modification. Update the Key Conventions to reference this workflow. --- CLAUDE.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8e398586b..5dc2f7eee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ ## Key Conventions - PHP 8.4: constructor property promotion, explicit return types, type hints - Always create Form Request classes for validation - Run `vendor/bin/pint --dirty --format agent` before finalizing changes -- Every change must have tests — write or update tests, then run them +- Every change must have tests — write or update tests, then run them. For bug fixes, follow TDD: write a failing test first, then fix the bug (see Test Enforcement below) - Check sibling files for conventions before creating new files ## Git Workflow @@ -231,6 +231,16 @@ # Test Enforcement - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. +## Bug Fix Workflow (TDD) + +When fixing a bug, follow this strict test-driven workflow: + +1. **Write a test first** that asserts the correct (expected) behavior — this test should reproduce the bug. +2. **Run the test** and confirm it **fails**. If it passes, the test does not cover the bug — rewrite it. +3. **Fix the bug** in the source code. +4. **Re-run the exact same test without any modifications** and confirm it **passes**. +5. **Never modify the test between steps 2 and 4.** The same test must go from red to green purely from the bug fix. + === laravel/core rules === # Do Things the Laravel Way From a94517f452e225046e01c08385d6a7aedf085c7d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:20:53 +0100 Subject: [PATCH 21/21] fix(api): validate server ownership in domains endpoint and scope activity lookups - Add team-scoped server validation to domains_by_server API endpoint - Filter applications and services to only those on the requested server - Scope ActivityMonitor activity lookups to the current team - Fix query param disambiguation (query vs route param) in domains endpoint - Fix undefined $ip variable in services domain collection Co-Authored-By: Claude Opus 4.6 --- .../Controllers/Api/ServersController.php | 21 ++++-- app/Livewire/ActivityMonitor.php | 13 +++- .../Feature/ActivityMonitorCrossTeamTest.php | 67 +++++++++++++++++++ tests/Feature/DomainsByServerApiTest.php | 49 +++++++++++++- 4 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/ActivityMonitorCrossTeamTest.php diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index da94521a8..2ef95ce8b 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -290,7 +290,11 @@ public function domains_by_server(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } - $uuid = $request->get('uuid'); + $server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + if (is_null($server)) { + return response()->json(['message' => 'Server not found.'], 404); + } + $uuid = $request->query('uuid'); if ($uuid) { $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); if (! $application) { @@ -301,7 +305,9 @@ public function domains_by_server(Request $request) } $projects = Project::where('team_id', $teamId)->get(); $domains = collect(); - $applications = $projects->pluck('applications')->flatten(); + $applications = $projects->pluck('applications')->flatten()->filter(function ($application) use ($server) { + return $application->destination?->server?->id === $server->id; + }); $settings = instanceSettings(); if ($applications->count() > 0) { foreach ($applications as $application) { @@ -341,7 +347,9 @@ public function domains_by_server(Request $request) } } } - $services = $projects->pluck('services')->flatten(); + $services = $projects->pluck('services')->flatten()->filter(function ($service) use ($server) { + return $service->server_id === $server->id; + }); if ($services->count() > 0) { foreach ($services as $service) { $service_applications = $service->applications; @@ -354,7 +362,8 @@ public function domains_by_server(Request $request) })->filter(function (Stringable $fqdn) { return $fqdn->isNotEmpty(); }); - if ($ip === 'host.docker.internal') { + $serviceIp = $server->ip; + if ($serviceIp === 'host.docker.internal') { if ($settings->public_ipv4) { $domains->push([ 'domain' => $fqdn, @@ -370,13 +379,13 @@ public function domains_by_server(Request $request) if (! $settings->public_ipv4 && ! $settings->public_ipv6) { $domains->push([ 'domain' => $fqdn, - 'ip' => $ip, + 'ip' => $serviceIp, ]); } } else { $domains->push([ 'domain' => $fqdn, - 'ip' => $ip, + 'ip' => $serviceIp, ]); } } diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php index 370ff1eaa..85ba60c33 100644 --- a/app/Livewire/ActivityMonitor.php +++ b/app/Livewire/ActivityMonitor.php @@ -55,7 +55,18 @@ public function hydrateActivity() return; } - $this->activity = Activity::find($this->activityId); + $activity = Activity::find($this->activityId); + + if ($activity) { + $teamId = data_get($activity, 'properties.team_id'); + if ($teamId && $teamId !== currentTeam()?->id) { + $this->activity = null; + + return; + } + } + + $this->activity = $activity; } public function updatedActivityId($value) diff --git a/tests/Feature/ActivityMonitorCrossTeamTest.php b/tests/Feature/ActivityMonitorCrossTeamTest.php new file mode 100644 index 000000000..7e4aebc2f --- /dev/null +++ b/tests/Feature/ActivityMonitorCrossTeamTest.php @@ -0,0 +1,67 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); +}); + +test('hydrateActivity blocks access to another teams activity', function () { + $otherActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'test activity', + 'properties' => ['team_id' => $this->otherTeam->id], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + $component = Livewire::test(ActivityMonitor::class) + ->set('activityId', $otherActivity->id) + ->assertSet('activity', null); +}); + +test('hydrateActivity allows access to own teams activity', function () { + $ownActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'test activity', + 'properties' => ['team_id' => $this->team->id], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + $component = Livewire::test(ActivityMonitor::class) + ->set('activityId', $ownActivity->id); + + expect($component->get('activity'))->not->toBeNull(); + expect($component->get('activity')->id)->toBe($ownActivity->id); +}); + +test('hydrateActivity allows access to activity without team_id in properties', function () { + $legacyActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'legacy activity', + 'properties' => [], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + $component = Livewire::test(ActivityMonitor::class) + ->set('activityId', $legacyActivity->id); + + expect($component->get('activity'))->not->toBeNull(); + expect($component->get('activity')->id)->toBe($legacyActivity->id); +}); diff --git a/tests/Feature/DomainsByServerApiTest.php b/tests/Feature/DomainsByServerApiTest.php index 1e799bec5..ea799275b 100644 --- a/tests/Feature/DomainsByServerApiTest.php +++ b/tests/Feature/DomainsByServerApiTest.php @@ -16,11 +16,12 @@ $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); - $this->token = $this->user->createToken('test-token', ['*'], $this->team->id); + session(['currentTeam' => $this->team]); + $this->token = $this->user->createToken('test-token', ['*']); $this->bearerToken = $this->token->plainTextToken; $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); }); @@ -53,7 +54,7 @@ function authHeaders(): array $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); - $otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); @@ -78,3 +79,45 @@ function authHeaders(): array $response->assertNotFound(); $response->assertJson(['message' => 'Application not found.']); }); + +test('returns 404 when server uuid belongs to another team', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$otherServer->uuid}/domains"); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Server not found.']); +}); + +test('only returns domains for applications on the specified server', function () { + $application = Application::factory()->create([ + 'fqdn' => 'https://app-on-server.example.com', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $otherServer = Server::factory()->create(['team_id' => $this->team->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first(); + + $applicationOnOtherServer = Application::factory()->create([ + 'fqdn' => 'https://app-on-other-server.example.com', + 'environment_id' => $this->environment->id, + 'destination_id' => $otherDestination->id, + 'destination_type' => $otherDestination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains"); + + $response->assertOk(); + $responseContent = $response->json(); + $allDomains = collect($responseContent)->pluck('domains')->flatten()->toArray(); + expect($allDomains)->toContain('app-on-server.example.com'); + expect($allDomains)->not->toContain('app-on-other-server.example.com'); +});