From d2206cd93c21c2c8ad4cbd6e5acc6ffd78234516 Mon Sep 17 00:00:00 2001 From: Taras Machyshyn Date: Fri, 3 Jul 2026 15:15:43 +0300 Subject: [PATCH 01/15] Update configuration for compatibility with EspoCRM v10 --- templates/compose/espocrm.yaml | 52 +++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml index 677b1fa64..8fec5d2ed 100644 --- a/templates/compose/espocrm.yaml +++ b/templates/compose/espocrm.yaml @@ -7,7 +7,8 @@ services: espocrm: - image: espocrm/espocrm:9 + image: espocrm/espocrm:10 + container_name: espocrm environment: - SERVICE_URL_ESPOCRM - ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin} @@ -19,30 +20,39 @@ services: - ESPOCRM_DATABASE_PASSWORD=${SERVICE_PASSWORD_MARIADB} - ESPOCRM_SITE_URL=${SERVICE_URL_ESPOCRM} volumes: - - espocrm:/var/www/html - healthcheck: - test: ["CMD", "curl", "-f", "http://127.0.0.1:80"] - interval: 2s - start_period: 60s - timeout: 10s - retries: 15 + - espocrm-data:/var/www/html/data + - espocrm-custom:/var/www/html/custom + - espocrm-custom-client:/var/www/html/client/custom + restart: unless-stopped depends_on: espocrm-db: condition: service_healthy + healthcheck: + test: ["CMD", "bin/command", "app-check"] + start_period: 20s + interval: 60s + timeout: 20s + retries: 3 espocrm-daemon: - image: espocrm/espocrm:9 + image: espocrm/espocrm:10 container_name: espocrm-daemon - volumes: - - espocrm:/var/www/html - restart: always + volumes_from: + - espocrm + restart: unless-stopped entrypoint: docker-daemon.sh depends_on: espocrm: condition: service_healthy + healthcheck: + test: ["CMD", "bin/command", "app-check"] + start_period: 20s + interval: 180s + timeout: 20s + retries: 3 espocrm-websocket: - image: espocrm/espocrm:9 + image: espocrm/espocrm:10 container_name: espocrm-websocket environment: - SERVICE_URL_ESPOCRM_WEBSOCKET_8080 @@ -50,16 +60,23 @@ services: - ESPOCRM_CONFIG_WEB_SOCKET_URL=$SERVICE_URL_ESPOCRM_WEBSOCKET - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN=tcp://*:7777 - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN=tcp://espocrm-websocket:7777 - volumes: - - espocrm:/var/www/html - restart: always + volumes_from: + - espocrm + restart: unless-stopped entrypoint: docker-websocket.sh depends_on: espocrm: condition: service_healthy + healthcheck: + test: ["CMD", "bin/command", "app-check"] + start_period: 20s + interval: 180s + timeout: 20s + retries: 3 espocrm-db: - image: mariadb:11.8 + image: mariadb:12.3 + container_name: espocrm-db environment: - MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm} - MARIADB_USER=${SERVICE_USER_MARIADB} @@ -67,6 +84,7 @@ services: - MARIADB_ROOT_PASSWORD=${SERVICE_PASSWORD_ROOT} volumes: - espocrm-db:/var/lib/mysql + restart: unless-stopped healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 20s From 915af969810c7c9107b1b122abfaa0a9e2a25de0 Mon Sep 17 00:00:00 2001 From: Smaug Date: Tue, 7 Jul 2026 13:24:35 +0200 Subject: [PATCH 02/15] feat(templates): bump alexandrie to version 8.10.0 * bump frontend to v8.10.0 * bump backend to v8.10.0 * bump rustfs to 1.0.0-beta.8 --- templates/compose/alexandrie.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/alexandrie.yaml b/templates/compose/alexandrie.yaml index 32bb98b2f..56267063a 100644 --- a/templates/compose/alexandrie.yaml +++ b/templates/compose/alexandrie.yaml @@ -7,7 +7,7 @@ services: frontend: - image: ghcr.io/smaug6739/alexandrie-frontend:v8.7.2 + image: ghcr.io/smaug6739/alexandrie-frontend:v8.10.0 environment: - SERVICE_URL_FRONTEND_8200 - PORT=8200 @@ -21,7 +21,7 @@ services: - backend backend: - image: ghcr.io/smaug6739/alexandrie-backend:v8.7.2 + image: ghcr.io/smaug6739/alexandrie-backend:v8.10.0 environment: - SERVICE_URL_BACKEND_8201 - BACKEND_PORT=8201 @@ -74,7 +74,7 @@ services: retries: 5 rustfs: - image: rustfs/rustfs:1.0.0-alpha.90 + image: rustfs/rustfs:1.0.0-beta.8 environment: - SERVICE_URL_RUSTFS_9000 - RUSTFS_ACCESS_KEY=${SERVICE_USER_RUSTFS} From bd82b4c1cd977712f67c9efe2dd5457852872678 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:35:42 +0000 Subject: [PATCH 03/15] chore(deps): bump web-auth/webauthn-lib from 5.3.3 to 5.3.5 Bumps [web-auth/webauthn-lib](https://github.com/web-auth/webauthn-lib) from 5.3.3 to 5.3.5. - [Commits](https://github.com/web-auth/webauthn-lib/compare/5.3.3...5.3.5) --- updated-dependencies: - dependency-name: web-auth/webauthn-lib dependency-version: 5.3.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- composer.lock | 120 +++++++++++++++++++++++++------------------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/composer.lock b/composer.lock index 7d958a9cc..a1ebd8377 100644 --- a/composer.lock +++ b/composer.lock @@ -5240,16 +5240,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "version": "2.3.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "shasum": "" }, "require": { @@ -5281,9 +5281,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2026-07-08T07:01:06+00:00" }, { "name": "pion/laravel-chunk-upload", @@ -8517,16 +8517,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -8564,7 +8564,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -8584,7 +8584,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", @@ -9585,16 +9585,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -9643,7 +9643,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -9663,7 +9663,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:13:48+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-idn", @@ -9839,16 +9839,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -9900,7 +9900,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -9920,7 +9920,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", @@ -10008,16 +10008,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.37.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "shasum": "" }, "require": { @@ -10064,7 +10064,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, "funding": [ { @@ -10084,20 +10084,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-27T06:51:48+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -10144,7 +10144,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -10164,7 +10164,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", @@ -10735,16 +10735,16 @@ }, { "name": "symfony/serializer", - "version": "v8.0.10", + "version": "v8.0.14", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf" + "reference": "33d395158f1c3b6038738fbb8656e05ae7d2bf0d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/72ed7e1475790714f07c3a59bd01fd32cd022fdf", - "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf", + "url": "https://api.github.com/repos/symfony/serializer/zipball/33d395158f1c3b6038738fbb8656e05ae7d2bf0d", + "reference": "33d395158f1c3b6038738fbb8656e05ae7d2bf0d", "shasum": "" }, "require": { @@ -10809,7 +10809,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v8.0.10" + "source": "https://github.com/symfony/serializer/tree/v8.0.14" }, "funding": [ { @@ -10829,7 +10829,7 @@ "type": "tidelift" } ], - "time": "2026-05-04T13:41:39+00:00" + "time": "2026-06-27T08:56:37+00:00" }, { "name": "symfony/service-contracts", @@ -10986,16 +10986,16 @@ }, { "name": "symfony/string", - "version": "v8.0.11", + "version": "v8.0.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" + "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", - "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", + "url": "https://api.github.com/repos/symfony/string/zipball/f2e3e4d33579350d1b12001ef2872f86b27ed3dc", + "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc", "shasum": "" }, "require": { @@ -11052,7 +11052,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.11" + "source": "https://github.com/symfony/string/tree/v8.0.13" }, "funding": [ { @@ -11072,7 +11072,7 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:07:53+00:00" + "time": "2026-05-23T18:05:53+00:00" }, { "name": "symfony/translation", @@ -11928,16 +11928,16 @@ }, { "name": "web-auth/webauthn-lib", - "version": "5.3.3", + "version": "5.3.5", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df" + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df", - "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", "shasum": "" }, "require": { @@ -11998,7 +11998,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" }, "funding": [ { @@ -12010,20 +12010,20 @@ "type": "patreon" } ], - "time": "2026-05-17T19:04:30+00:00" + "time": "2026-05-31T15:00:08+00:00" }, { "name": "webmozart/assert", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -12074,9 +12074,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-05-20T13:07:01+00:00" + "time": "2026-06-15T15:31:57+00:00" }, { "name": "yosymfony/parser-utils", From e01b8a057e6437cf2a2bafe61db67943acc7bb39 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:35:10 +0200 Subject: [PATCH 04/15] fix(servers): retain cloud instances awaiting IPs Persist DigitalOcean, Hetzner, and Vultr servers before public IP assignment, then backfill placeholder addresses from provider state. Treat partial Sentinel snapshots as non-authoritative and document the destinations API with OpenAPI schemas. --- .../Api/DestinationsController.php | 117 +++++++ .../Controllers/Api/SentinelController.php | 15 +- app/Jobs/PushServerUpdateJob.php | 12 + app/Livewire/Server/New/ByDigitalOcean.php | 42 +-- app/Livewire/Server/New/ByHetzner.php | 11 +- app/Livewire/Server/New/ByVultr.php | 21 +- app/Livewire/Server/Show.php | 5 + app/Models/Server.php | 39 ++- app/Models/StandaloneDocker.php | 19 ++ ...4_add_uuid_to_cloud_init_scripts_table.php | 2 +- openapi.json | 291 ++++++++++++++++++ openapi.yaml | 190 ++++++++++++ .../views/livewire/server/navbar.blade.php | 2 +- tests/Feature/Api/DestinationsApiTest.php | 20 ++ .../Authorization/ApiAuthorizationTest.php | 5 +- .../DigitalOceanServerCreationTest.php | 86 ++++++ tests/Feature/Mcp/McpEndpointTest.php | 11 + tests/Feature/MoveResourceApiTest.php | 5 +- .../PushServerUpdateJobLastOnlineTest.php | 109 +++++++ .../Feature/SentinelPushDeduplicationTest.php | 21 ++ .../Server/HetznerServerPlaceholderIpTest.php | 118 +++++++ tests/Feature/ServiceExtraFieldsTest.php | 4 +- tests/Feature/VultrServerCreationTest.php | 44 ++- ...pplicationDeploymentRailpackConfigTest.php | 8 +- .../ApplicationConfigurationSnapshotTest.php | 2 + tests/Unit/DestinationsOpenApiTest.php | 13 + tests/Unit/DigitalOceanServerStateTest.php | 48 ++- tests/Unit/NavbarThemeSwitcherTest.php | 7 +- tests/Unit/ServerPlaceholderIpTest.php | 67 ++++ 29 files changed, 1275 insertions(+), 59 deletions(-) create mode 100644 tests/Feature/Server/HetznerServerPlaceholderIpTest.php create mode 100644 tests/Unit/DestinationsOpenApiTest.php create mode 100644 tests/Unit/ServerPlaceholderIpTest.php diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php index f58e2ee71..a745ea5d2 100644 --- a/app/Http/Controllers/Api/DestinationsController.php +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -10,6 +10,7 @@ use App\Models\SwarmDocker; use Illuminate\Database\QueryException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use OpenApi\Attributes as OA; class DestinationsController extends Controller { @@ -59,6 +60,22 @@ class DestinationsController extends Controller ?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail(); } + #[OA\Get( + summary: 'List destinations', + description: 'List all Docker network destinations for the authenticated team.', + path: '/destinations', + operationId: 'list-destinations', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + responses: [ + new OA\Response( + response: 200, + description: 'Destinations for the authenticated team.', + content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + ], + )] public function index(Request $request): JsonResponse { $teamId = $this->teamIdOrAbort(); @@ -74,6 +91,26 @@ class DestinationsController extends Controller ); } + #[OA\Get( + summary: 'List destinations by server', + description: 'List Docker network destinations attached to a server owned by the authenticated team.', + path: '/servers/{server_uuid}/destinations', + operationId: 'list-server-destinations', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Destinations attached to the server.', + content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] public function index_by_server(Request $request, string $server_uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); @@ -89,6 +126,26 @@ class DestinationsController extends Controller return response()->json($list->map(fn ($destination) => $this->transform($destination))->values()); } + #[OA\Get( + summary: 'Get destination', + description: 'Get a Docker network destination by UUID.', + path: '/destinations/{uuid}', + operationId: 'get-destination-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Destination details.', + content: new OA\JsonContent(ref: '#/components/schemas/Destination'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] public function show(Request $request, string $uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); @@ -100,6 +157,40 @@ class DestinationsController extends Controller return response()->json($this->transform($destination)); } + #[OA\Post( + summary: 'Create destination', + description: 'Create a Docker network destination on a server owned by the authenticated team.', + path: '/servers/{server_uuid}/destinations', + operationId: 'create-server-destination', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['network'], + properties: [ + new OA\Property(property: 'name', type: 'string', maxLength: 255), + new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'), + new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Destination created.', + content: new OA\JsonContent(ref: '#/components/schemas/Destination'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'A destination with this network already exists.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] public function create(Request $request, string $server_uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); @@ -183,6 +274,32 @@ class DestinationsController extends Controller || in_array($driverCode, ['19', '1062', '2067'], true); } + #[OA\Delete( + summary: 'Delete destination', + description: 'Delete an unused Docker network destination.', + path: '/destinations/{uuid}', + operationId: 'delete-destination-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Destination deleted.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Deleted.'), + ], + type: 'object', + ), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Destination has attached resources.'), + ], + )] public function delete(Request $request, string $uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index 8c82fa2ad..b3685daa4 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -143,8 +143,9 @@ class SentinelController extends Controller * health checks can flap between starting/healthy/unhealthy while the * container lifecycle state remains unchanged. Both would otherwise defeat * the hash and dispatch DB-heavy PushServerUpdateJob instances too often. - * The force window still refreshes full state periodically. Sorted by name - * so container ordering from Sentinel does not affect the hash. + * The snapshot completeness flag is included so a complete snapshot always + * dispatches after a partial snapshot. Sorted by name so container ordering + * from Sentinel does not affect the hash. */ private function containerStateHash(array $data): string { @@ -157,6 +158,14 @@ class SentinelController extends Controller ->values() ->all(); - return hash('xxh128', json_encode($containers)); + return hash('xxh128', json_encode([ + 'snapshot_complete' => $this->isCompleteSnapshot($data), + 'containers' => $containers, + ])); + } + + private function isCompleteSnapshot(array $data): bool + { + return data_get($data, 'snapshot.complete', true) !== false; } } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 62e98934e..fbf5cd154 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -311,6 +311,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } } + if (! $this->isCompleteSnapshot()) { + return; + } + $this->updateProxyStatus(); $this->updateNotFoundApplicationStatus(); @@ -329,6 +333,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->checkLogDrainContainer(); } + private function isCompleteSnapshot(): bool + { + return data_get($this->data, 'snapshot.complete', true) !== false; + } + private function loadApplications(): Collection { [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds(); @@ -700,6 +709,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $database->status = $containerStatus; $database->save(); } + if (! $this->isCompleteSnapshot()) { + return; + } if ($this->isRunning($containerStatus) && $tcpProxy) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running'; diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php index 7b0151851..a59f23efb 100644 --- a/app/Livewire/Server/New/ByDigitalOcean.php +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -402,11 +402,11 @@ class ByDigitalOcean extends Component } /** - * @return array{droplet: array, ip: string|null} + * Create the droplet on DigitalOcean and return the raw droplet payload. + * The public IP may not be assigned yet at this point. */ - private function createDigitalOceanDroplet(string $token): array + private function createDigitalOceanDroplet(DigitalOceanService $digitalOceanService): array { - $digitalOceanService = new DigitalOceanService($token); $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); @@ -442,14 +442,7 @@ class ByDigitalOcean extends Component $params['user_data'] = $this->cloud_init_script; } - $droplet = $digitalOceanService->createDroplet($params); - $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6); - $ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6); - - return [ - 'droplet' => $droplet, - 'ip' => $ipAddress, - ]; + return $digitalOceanService->createDroplet($params); } public function submit() @@ -473,17 +466,14 @@ class ByDigitalOcean extends Component ]); } - $result = $this->createDigitalOceanDroplet($this->getDigitalOceanToken()); - $droplet = $result['droplet']; - $ipAddress = $result['ip']; - - if (! $ipAddress) { - throw new \Exception('No public IP address available for the new droplet.'); - } + $digitalOceanService = new DigitalOceanService($this->getDigitalOceanToken()); + $droplet = $this->createDigitalOceanDroplet($digitalOceanService); + // Persist the server immediately so the droplet is always tracked + // in Coolify, even if waiting for the public IP fails below. $server = Server::create([ 'name' => strtolower(trim($this->server_name)), - 'ip' => $ipAddress, + 'ip' => Server::PLACEHOLDER_IP, 'user' => 'root', 'port' => 22, 'team_id' => currentTeam()->id, @@ -497,6 +487,20 @@ class ByDigitalOcean extends Component $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + try { + $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6); + $ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6); + if ($ipAddress) { + $server->update([ + 'ip' => $ipAddress, + 'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status, + ]); + } + } catch (\Throwable $e) { + // Non-fatal: the server page polling backfills the IP later. + report($e); + } + if ($this->from_onboarding) { currentTeam()->update([ 'show_boarding' => false, diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 5ef88acee..1059c6713 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -717,20 +717,19 @@ class ByHetzner extends Component $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; } - if (! $ipAddress) { - throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.'); - } - - // Create server in Coolify database + // Create server in Coolify database immediately so the Hetzner + // server is always tracked, even when no IP is assigned yet — + // the server page polling backfills the placeholder IP later. $server = Server::create([ 'name' => $this->server_name, - 'ip' => $ipAddress, + 'ip' => $ipAddress ?? Server::PLACEHOLDER_IP, 'user' => 'root', 'port' => 22, 'team_id' => currentTeam()->id, 'private_key_id' => $this->private_key_id, 'cloud_provider_token_id' => $this->selected_token_id, 'hetzner_server_id' => $hetznerServer['id'], + 'hetzner_server_status' => $hetznerServer['status'] ?? null, ]); $server->proxy->set('status', 'exited'); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index 9e6bc3cf2..fd0aa4c34 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -438,7 +438,7 @@ class ByVultr extends Component $vultrService = new VultrService($this->getVultrToken()); $vultrInstance = $this->createVultrServer($this->getVultrToken()); - $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? '0.0.0.0'; + $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP; $server = Server::create([ 'name' => strtolower(trim($this->server_name)), @@ -452,13 +452,18 @@ class ByVultr extends Component 'vultr_instance_status' => $vultrInstance['status'] ?? null, ]); - $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); - $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); - if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { - $server->update([ - 'ip' => $assignedIpAddress, - 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, - ]); + try { + $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); + $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); + if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { + $server->update([ + 'ip' => $assignedIpAddress, + 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + ]); + } + } catch (\Throwable $e) { + // Non-fatal: the server page polling backfills the IP later. + report($e); } $server->proxy->set('status', 'exited'); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 15af859c9..83f4f83d9 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -488,6 +488,11 @@ class Show extends Component $this->server->hetzner_server_status = $this->hetznerServerStatus; $this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]); } + + $assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip'); + if ($this->server->backfillPlaceholderIp($assignedIp)) { + $this->ip = $this->server->ip; + } if ($manual) { $this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown')); } diff --git a/app/Models/Server.php b/app/Models/Server.php index 4bf57207f..928f80fed 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -112,6 +112,13 @@ class Server extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; + /** + * Sentinel IP for servers that do not have a real address yet + * (cloud provisioning in progress or parked as unreachable). + * Scheduled jobs skip these servers via skipServer(). + */ + public const PLACEHOLDER_IP = '1.2.3.4'; + public static $batch_counter = 0; /** @@ -307,6 +314,29 @@ class Server extends BaseModel return 'server'; } + public function hasPlaceholderIp(): bool + { + // Cast: the saving hook stores the ip as a Stringable in memory. + $ip = (string) $this->ip; + + return blank($ip) || in_array($ip, [self::PLACEHOLDER_IP, '0.0.0.0', '::'], true); + } + + /** + * Replace a placeholder IP with the real address once the cloud + * provider reports one. Returns true when the IP was updated. + */ + public function backfillPlaceholderIp(?string $ip): bool + { + if ($ip && $this->hasPlaceholderIp()) { + $this->update(['ip' => $ip]); + + return true; + } + + return false; + } + public function refreshVultrState(): ?string { if (! $this->vultr_instance_id || ! $this->cloudProviderToken) { @@ -339,8 +369,7 @@ class Server extends BaseModel $updates['vultr_instance_status'] = $status; } - $hasPlaceholderIp = blank($this->ip) || in_array($this->ip, ['0.0.0.0', '::'], true); - if ($hasPlaceholderIp && $publicIp) { + if ($this->hasPlaceholderIp() && $publicIp) { $updates['ip'] = $publicIp; } @@ -388,7 +417,7 @@ class Server extends BaseModel $ip = $digitalOceanService->getPublicIpAddress($droplet); $updates = ['digitalocean_droplet_status' => $status]; - if ($ip && $ip !== $this->ip) { + if ($ip && $this->hasPlaceholderIp()) { $updates['ip'] = $ip; } @@ -1176,7 +1205,7 @@ $schema://$host { public function skipServer() { - if ($this->ip === '1.2.3.4') { + if ($this->hasPlaceholderIp()) { return true; } if ($this->settings->force_disabled === true) { @@ -1188,7 +1217,7 @@ $schema://$host { public function isFunctional() { - $isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && $this->ip !== '1.2.3.4'; + $isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && ! $this->hasPlaceholderIp(); if ($isFunctional === false) { Storage::disk('ssh-mux')->delete($this->muxFilename()); diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index c1dd4bf67..604a245fc 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -7,7 +7,22 @@ use App\Support\ValidationPatterns; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; +use OpenApi\Attributes as OA; +#[OA\Schema( + schema: 'Destination', + description: 'A Docker network destination attached to a server.', + type: 'object', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'network', type: 'string'), + new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']), + new OA\Property(property: 'server_uuid', type: 'string'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ], +)] class StandaloneDocker extends BaseModel { use HasFactory; @@ -23,6 +38,10 @@ class StandaloneDocker extends BaseModel { parent::boot(); static::created(function ($newStandaloneDocker) { + if (app()->runningUnitTests()) { + return; + } + $server = $newStandaloneDocker->server; $safeNetwork = escapeshellarg($newStandaloneDocker->network); instant_remote_process([ diff --git a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php index 500fa1fcf..98b0c73c2 100644 --- a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php +++ b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php @@ -15,7 +15,7 @@ return new class extends Migration DB::table('cloud_init_scripts') ->whereNull('uuid') - ->orderBy('id') + ->lazyById() ->each(function (object $script): void { DB::table('cloud_init_scripts') ->where('id', $script->id) diff --git a/openapi.json b/openapi.json index 7ffe9ecca..4f34d3f65 100644 --- a/openapi.json +++ b/openapi.json @@ -8059,6 +8059,260 @@ ] } }, + "\/destinations": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "List destinations", + "description": "List all Docker network destinations for the authenticated team.", + "operationId": "list-destinations", + "responses": { + "200": { + "description": "Destinations for the authenticated team.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{server_uuid}\/destinations": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "List destinations by server", + "description": "List Docker network destinations attached to a server owned by the authenticated team.", + "operationId": "list-server-destinations", + "parameters": [ + { + "name": "server_uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destinations attached to the server.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Destinations" + ], + "summary": "Create destination", + "description": "Create a Docker network destination on a server owned by the authenticated team.", + "operationId": "create-server-destination", + "parameters": [ + { + "name": "server_uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "network" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "network": { + "type": "string", + "maxLength": 255, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "type": { + "type": "string", + "enum": [ + "standalone", + "swarm" + ] + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Destination created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "A destination with this network already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/destinations\/{uuid}": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "Get destination", + "description": "Get a Docker network destination by UUID.", + "operationId": "get-destination-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Destination UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destination details.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Destinations" + ], + "summary": "Delete destination", + "description": "Delete an unused Docker network destination.", + "operationId": "delete-destination-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Destination UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destination deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Destination has attached resources." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/digitalocean\/regions": { "get": { "tags": [ @@ -15514,6 +15768,39 @@ }, "type": "object" }, + "Destination": { + "description": "A Docker network destination attached to a server.", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "network": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "standalone", + "swarm" + ] + }, + "server_uuid": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + }, "Tag": { "description": "Tag model", "properties": { @@ -15754,6 +16041,10 @@ "name": "Deployments", "description": "Deployments" }, + { + "name": "Destinations", + "description": "Destinations" + }, { "name": "DigitalOcean", "description": "DigitalOcean" diff --git a/openapi.yaml b/openapi.yaml index 3b2f5c4d5..55751abd5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5232,6 +5232,170 @@ paths: security: - bearerAuth: [] + /destinations: + get: + tags: + - Destinations + summary: 'List destinations' + description: 'List all Docker network destinations for the authenticated team.' + operationId: list-destinations + responses: + '200': + description: 'Destinations for the authenticated team.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + security: + - + bearerAuth: [] + '/servers/{server_uuid}/destinations': + get: + tags: + - Destinations + summary: 'List destinations by server' + description: 'List Docker network destinations attached to a server owned by the authenticated team.' + operationId: list-server-destinations + parameters: + - + name: server_uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destinations attached to the server.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Destinations + summary: 'Create destination' + description: 'Create a Docker network destination on a server owned by the authenticated team.' + operationId: create-server-destination + parameters: + - + name: server_uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - network + properties: + name: + type: string + maxLength: 255 + network: + type: string + maxLength: 255 + pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$' + type: + type: string + enum: [standalone, swarm] + type: object + responses: + '201': + description: 'Destination created.' + content: + application/json: + schema: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'A destination with this network already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/destinations/{uuid}': + get: + tags: + - Destinations + summary: 'Get destination' + description: 'Get a Docker network destination by UUID.' + operationId: get-destination-by-uuid + parameters: + - + name: uuid + in: path + description: 'Destination UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destination details.' + content: + application/json: + schema: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + delete: + tags: + - Destinations + summary: 'Delete destination' + description: 'Delete an unused Docker network destination.' + operationId: delete-destination-by-uuid + parameters: + - + name: uuid + in: path + description: 'Destination UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destination deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: Deleted. } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Destination has attached resources.' + security: + - + bearerAuth: [] /digitalocean/regions: get: tags: @@ -9958,6 +10122,29 @@ components: type: string description: 'The date and time when the service was deleted.' type: object + Destination: + description: 'A Docker network destination attached to a server.' + properties: + uuid: + type: string + name: + type: string + network: + type: string + type: + type: string + enum: + - standalone + - swarm + server_uuid: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + type: object Tag: description: 'Tag model' properties: @@ -10117,6 +10304,9 @@ tags: - name: Deployments description: Deployments + - + name: Destinations + description: Destinations - name: DigitalOcean description: DigitalOcean diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index 2d08f9c3e..eab84a611 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -15,7 +15,7 @@

Server

-
+
{{ data_get($server, 'name') }}
@php diff --git a/tests/Feature/Api/DestinationsApiTest.php b/tests/Feature/Api/DestinationsApiTest.php index 027f4acdb..2de639b5c 100644 --- a/tests/Feature/Api/DestinationsApiTest.php +++ b/tests/Feature/Api/DestinationsApiTest.php @@ -102,6 +102,26 @@ describe('GET /api/v1/servers/{server_uuid}/destinations', function () { }); describe('POST /api/v1/servers/{server_uuid}/destinations', function () { + test('creates a standalone destination', function () { + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [ + 'name' => 'API Standalone', + 'network' => 'api-standalone-network', + ]); + + $response->assertCreated() + ->assertJson([ + 'name' => 'API Standalone', + 'network' => 'api-standalone-network', + 'type' => 'standalone', + 'server_uuid' => $this->server->uuid, + ]); + + expect(StandaloneDocker::where('server_id', $this->server->id) + ->where('network', 'api-standalone-network') + ->exists())->toBeTrue(); + }); + test('requires a write token', function () { $readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']); diff --git a/tests/Feature/Authorization/ApiAuthorizationTest.php b/tests/Feature/Authorization/ApiAuthorizationTest.php index 66a6900a3..c26476edf 100644 --- a/tests/Feature/Authorization/ApiAuthorizationTest.php +++ b/tests/Feature/Authorization/ApiAuthorizationTest.php @@ -15,7 +15,10 @@ use Illuminate\Support\Str; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate( + ['id' => 0], + ['is_api_enabled' => true], + )); $this->team = Team::factory()->create(); diff --git a/tests/Feature/DigitalOceanServerCreationTest.php b/tests/Feature/DigitalOceanServerCreationTest.php index 4bc9e7bcf..b42148dfc 100644 --- a/tests/Feature/DigitalOceanServerCreationTest.php +++ b/tests/Feature/DigitalOceanServerCreationTest.php @@ -1,10 +1,14 @@ actingAs($this->user); session(['currentTeam' => $this->team]); + + $this->digitalOceanToken = CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'provider' => 'digitalocean', + 'token' => 'test-digitalocean-token', + 'name' => 'Test DigitalOcean Token', + ]); + + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); +}); + +function submitDigitalOceanServer(): void +{ + Livewire::test(ByDigitalOcean::class, ['selectedTokenUuid' => test()->digitalOceanToken->uuid]) + ->assertSet('current_step', 2) + ->set('server_name', 'test-do-server') + ->set('selected_region', 'nyc1') + ->set('selected_size', 's-1vcpu-1gb') + ->set('selected_image', 'ubuntu-24-04-x64') + ->set('private_key_id', test()->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); +} + +it('persists the server with a placeholder IP when waiting for the droplet IP fails', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/account/keys' => Http::response([ + 'ssh_key' => ['id' => 123], + ], 201), + 'https://api.digitalocean.com/v2/account/keys*' => Http::response([ + 'ssh_keys' => [], + ], 200), + 'https://api.digitalocean.com/v2/droplets' => Http::response([ + 'droplet' => ['id' => 555, 'status' => 'new'], + ], 202), + 'https://api.digitalocean.com/v2/droplets/555' => Http::response(['message' => 'server error'], 500), + ]); + + submitDigitalOceanServer(); + + $this->assertDatabaseHas('servers', [ + 'name' => 'test-do-server', + 'ip' => Server::PLACEHOLDER_IP, + 'team_id' => $this->team->id, + 'cloud_provider_token_id' => $this->digitalOceanToken->id, + 'digitalocean_droplet_id' => '555', + 'digitalocean_droplet_status' => 'new', + ]); +}); + +it('updates the placeholder IP once the droplet reports one', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/account/keys' => Http::response([ + 'ssh_key' => ['id' => 123], + ], 201), + 'https://api.digitalocean.com/v2/account/keys*' => Http::response([ + 'ssh_keys' => [], + ], 200), + 'https://api.digitalocean.com/v2/droplets' => Http::response([ + 'droplet' => ['id' => 555, 'status' => 'new'], + ], 202), + 'https://api.digitalocean.com/v2/droplets/555' => Http::response([ + 'droplet' => [ + 'id' => 555, + 'status' => 'active', + 'networks' => [ + 'v4' => [ + ['type' => 'public', 'ip_address' => '203.0.113.40'], + ], + ], + ], + ], 200), + ]); + + submitDigitalOceanServer(); + + $this->assertDatabaseHas('servers', [ + 'name' => 'test-do-server', + 'ip' => '203.0.113.40', + 'digitalocean_droplet_id' => '555', + 'digitalocean_droplet_status' => 'active', + ]); }); it('renders only the full width buy button at the bottom of the DigitalOcean form', function () { diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php index f70584ecb..ca966bdb2 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -100,6 +100,17 @@ test('MCP endpoint returns 403 when the token team has MCP disabled', function ( $response->assertJson(['message' => 'MCP server is disabled for this team.']); }); +test('MCP endpoint is enabled for teams by default', function () { + $defaultTeam = Team::factory()->create(); + $this->user->teams()->attach($defaultTeam->id, ['role' => 'owner']); + session(['currentTeam' => $defaultTeam]); + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpListTools($token); + + $response->assertOk(); +}); + test('MCP endpoint rejects unauthenticated requests', function () { $response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); $response->assertStatus(401); diff --git a/tests/Feature/MoveResourceApiTest.php b/tests/Feature/MoveResourceApiTest.php index 29c868216..775f526ac 100644 --- a/tests/Feature/MoveResourceApiTest.php +++ b/tests/Feature/MoveResourceApiTest.php @@ -20,7 +20,10 @@ use Illuminate\Support\Facades\Log; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate( + ['id' => 0], + ['is_api_enabled' => true], + )); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); diff --git a/tests/Feature/PushServerUpdateJobLastOnlineTest.php b/tests/Feature/PushServerUpdateJobLastOnlineTest.php index d986d421d..a5f8f85ee 100644 --- a/tests/Feature/PushServerUpdateJobLastOnlineTest.php +++ b/tests/Feature/PushServerUpdateJobLastOnlineTest.php @@ -126,3 +126,112 @@ function createPushUpdatePostgresql(Team $team, array $attributes = []): Standal return $database; } + +test('partial sentinel snapshots do not mark missing databases as exited', function () { + $team = Team::factory()->create(); + $database = createPushUpdatePostgresql($team, [ + 'status' => 'running:healthy', + 'is_public' => true, + ]); + + $server = $database->destination->server; + Queue::fake(); + + $data = [ + 'snapshot' => [ + 'version' => 1, + 'complete' => false, + ], + 'containers' => [ + [ + 'name' => 'unrelated-container', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => 'true', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + expect($database->status)->toBe('running:healthy'); +}); + +test('partial sentinel snapshots apply directly observed database status updates', function () { + $team = Team::factory()->create(); + $database = createPushUpdatePostgresql($team, [ + 'status' => 'exited', + ]); + + $server = $database->destination->server; + Queue::fake(); + + $data = [ + 'snapshot' => [ + 'version' => 1, + 'complete' => false, + ], + 'containers' => [ + [ + 'name' => $database->uuid, + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => 'true', + 'coolify.type' => 'database', + 'com.docker.compose.service' => $database->uuid, + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + expect($database->status)->toBe('running:healthy'); +}); + +test('partial sentinel snapshots do not trigger missing tcp proxy recovery', function () { + $team = Team::factory()->create(); + $database = createPushUpdatePostgresql($team, [ + 'status' => 'exited', + 'is_public' => true, + ]); + + $server = $database->destination->server; + Queue::fake(); + + $data = [ + 'snapshot' => [ + 'version' => 1, + 'complete' => false, + ], + 'containers' => [ + [ + 'name' => $database->uuid, + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => 'true', + 'coolify.type' => 'database', + 'com.docker.compose.service' => $database->uuid, + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + expect($database->status)->toBe('running:healthy'); + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/SentinelPushDeduplicationTest.php b/tests/Feature/SentinelPushDeduplicationTest.php index d88cff658..aef1b7484 100644 --- a/tests/Feature/SentinelPushDeduplicationTest.php +++ b/tests/Feature/SentinelPushDeduplicationTest.php @@ -196,3 +196,24 @@ it('rejects an invalid token without dispatching', function () use ($running) { Queue::assertNotPushed(PushServerUpdateJob::class); }); + +it('dispatches a complete snapshot after an identical partial snapshot', function () use ($running) { + $partialPayload = sentinelPayload($running()) + [ + 'snapshot' => [ + 'version' => 1, + 'complete' => false, + ], + ]; + + $completePayload = sentinelPayload($running()) + [ + 'snapshot' => [ + 'version' => 1, + 'complete' => true, + ], + ]; + + pushSentinel($this->token, $partialPayload)->assertOk(); + pushSentinel($this->token, $completePayload)->assertOk(); + + Queue::assertPushed(PushServerUpdateJob::class, 2); +}); diff --git a/tests/Feature/Server/HetznerServerPlaceholderIpTest.php b/tests/Feature/Server/HetznerServerPlaceholderIpTest.php new file mode 100644 index 000000000..7a44ebbfc --- /dev/null +++ b/tests/Feature/Server/HetznerServerPlaceholderIpTest.php @@ -0,0 +1,118 @@ + 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + ])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->hetznerToken = CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'test-hetzner-token', + 'name' => 'Test Hetzner Token', + ]); + + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); +}); + +it('persists the server with a placeholder IP when Hetzner has not assigned one yet', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ + 'ssh_key' => ['id' => 42, 'fingerprint' => 'ff:ff'], + ], 201), + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'ssh_keys' => [], + ], 200), + 'https://api.hetzner.cloud/v1/servers' => Http::response([ + 'server' => [ + 'id' => 777, + 'status' => 'initializing', + 'public_net' => [ + 'ipv4' => null, + 'ipv6' => null, + ], + ], + ], 201), + ]); + + Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid]) + ->assertSet('current_step', 2) + ->set('server_name', 'test-hetzner-server') + ->set('selected_location', 'fsn1') + ->set('selected_server_type', 'cx22') + ->set('selected_image', 114690387) + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('servers', [ + 'name' => 'test-hetzner-server', + 'ip' => Server::PLACEHOLDER_IP, + 'team_id' => $this->team->id, + 'cloud_provider_token_id' => $this->hetznerToken->id, + 'hetzner_server_id' => '777', + 'hetzner_server_status' => 'initializing', + ]); +}); + +it('creates the server with the real IP when Hetzner assigns one immediately', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ + 'ssh_key' => ['id' => 42, 'fingerprint' => 'ff:ff'], + ], 201), + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'ssh_keys' => [], + ], 200), + 'https://api.hetzner.cloud/v1/servers' => Http::response([ + 'server' => [ + 'id' => 777, + 'status' => 'running', + 'public_net' => [ + 'ipv4' => ['ip' => '203.0.113.30'], + ], + ], + ], 201), + ]); + + Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid]) + ->assertSet('current_step', 2) + ->set('server_name', 'test-hetzner-server') + ->set('selected_location', 'fsn1') + ->set('selected_server_type', 'cx22') + ->set('selected_image', 114690387) + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('servers', [ + 'name' => 'test-hetzner-server', + 'ip' => '203.0.113.30', + 'hetzner_server_id' => '777', + 'hetzner_server_status' => 'running', + ]); +}); diff --git a/tests/Feature/ServiceExtraFieldsTest.php b/tests/Feature/ServiceExtraFieldsTest.php index c90a76bc6..2b6ed0c0b 100644 --- a/tests/Feature/ServiceExtraFieldsTest.php +++ b/tests/Feature/ServiceExtraFieldsTest.php @@ -15,8 +15,8 @@ function serviceExtraFieldsTestServiceWithApplicationImage(string $image): Servi $team = Team::factory()->create(); $project = Project::factory()->create(['team_id' => $team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]); - $server = Server::factory()->create(); - $destination = StandaloneDocker::factory()->create(['server_id' => $server->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); $service = Service::factory()->create([ 'environment_id' => $environment->id, diff --git a/tests/Feature/VultrServerCreationTest.php b/tests/Feature/VultrServerCreationTest.php index 612986391..d8e5af321 100644 --- a/tests/Feature/VultrServerCreationTest.php +++ b/tests/Feature/VultrServerCreationTest.php @@ -4,6 +4,7 @@ use App\Livewire\Server\New\ByVultr; use App\Models\CloudProviderToken; use App\Models\InstanceSettings; use App\Models\PrivateKey; +use App\Models\Server; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -95,7 +96,7 @@ it('creates a Vultr server through the Livewire flow', function () { 'instance' => [ 'id' => 'instance-1', 'label' => 'test-vultr-server', - 'main_ip' => '1.2.3.4', + 'main_ip' => '192.0.2.10', 'v6_main_ip' => '2001:db8::1', 'status' => 'pending', ], @@ -115,7 +116,7 @@ it('creates a Vultr server through the Livewire flow', function () { $this->assertDatabaseHas('servers', [ 'name' => 'test-vultr-server', - 'ip' => '1.2.3.4', + 'ip' => '192.0.2.10', 'team_id' => $this->team->id, 'cloud_provider_token_id' => $this->vultrToken->id, 'vultr_instance_id' => 'instance-1', @@ -132,6 +133,45 @@ it('creates a Vultr server through the Livewire flow', function () { }); }); +it('persists the server with a placeholder IP when Vultr has not assigned one yet', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances/instance-1' => Http::response(['error' => 'temporary error'], 500), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'label' => 'test-vultr-server', + 'main_ip' => '0.0.0.0', + 'v6_main_ip' => '::', + 'status' => 'pending', + ], + ], 202), + ]); + + Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid]) + ->set('server_name', 'test-vultr-server') + ->set('selected_region', 'ewr') + ->set('selected_plan', 'vc2-1c-1gb') + ->set('selected_os_id', 2284) + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('servers', [ + 'name' => 'test-vultr-server', + 'ip' => Server::PLACEHOLDER_IP, + 'team_id' => $this->team->id, + 'vultr_instance_id' => 'instance-1', + 'vultr_instance_status' => 'pending', + ]); +}); + it('requires IPv6 when public IPv4 is disabled', function () { Livewire::test(ByVultr::class) ->set('selected_token_id', $this->vultrToken->id) diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 03822cfd2..168e8be88 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -3,12 +3,14 @@ use App\Exceptions\DeploymentException; use App\Jobs\ApplicationDeploymentJob; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationSetting; use App\Models\EnvironmentVariable; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Collection; use Tests\TestCase; -uses(TestCase::class); +uses(TestCase::class, RefreshDatabase::class); class TestableRailpackDeploymentJob extends ApplicationDeploymentJob { @@ -373,9 +375,9 @@ it('creates an empty build-time env file for railpack when there are no generate ])); foreach ([ - 'application_deployment_queue' => new class + 'application_deployment_queue' => new class extends ApplicationDeploymentQueue { - public function addLogEntry(string $message, string $type = 'info', bool $hidden = false): void {} + public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false): void {} }, 'build_pack' => 'railpack', 'pull_request_id' => 0, diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index c6c823633..f717b9fae 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -86,6 +86,7 @@ it('detects environment variable value changes without exposing secret values', 'is_buildtime' => false, 'is_runtime' => true, 'is_preview' => false, + 'is_shown_once' => true, 'resourceable_type' => Application::class, 'resourceable_id' => $application->id, ]); @@ -112,6 +113,7 @@ it('describes added environment variables as set without exposing secret values' 'is_buildtime' => false, 'is_runtime' => true, 'is_preview' => false, + 'is_shown_once' => true, 'resourceable_type' => Application::class, 'resourceable_id' => $application->id, ]); diff --git a/tests/Unit/DestinationsOpenApiTest.php b/tests/Unit/DestinationsOpenApiTest.php new file mode 100644 index 000000000..a8686ace2 --- /dev/null +++ b/tests/Unit/DestinationsOpenApiTest.php @@ -0,0 +1,13 @@ +toContain("schema: 'Destination'") + ->and($controller)->toContain("operationId: 'list-destinations'") + ->and($controller)->toContain("operationId: 'get-destination-by-uuid'") + ->and($controller)->toContain("operationId: 'delete-destination-by-uuid'") + ->and($controller)->toContain("operationId: 'list-server-destinations'") + ->and($controller)->toContain("operationId: 'create-server-destination'"); +}); diff --git a/tests/Unit/DigitalOceanServerStateTest.php b/tests/Unit/DigitalOceanServerStateTest.php index 7a6a123de..93b3379bb 100644 --- a/tests/Unit/DigitalOceanServerStateTest.php +++ b/tests/Unit/DigitalOceanServerStateTest.php @@ -10,7 +10,7 @@ use Tests\TestCase; uses(TestCase::class, RefreshDatabase::class); -function createDigitalOceanServerForStateTest(string $status = 'active'): Server +function createDigitalOceanServerForStateTest(string $status = 'active', array $attributes = []): Server { $team = Team::create([ 'name' => 'Test Team', @@ -24,13 +24,13 @@ function createDigitalOceanServerForStateTest(string $status = 'active'): Server ]); $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); - return Server::factory()->create([ + return Server::factory()->create(array_merge([ 'team_id' => $team->id, 'private_key_id' => $privateKey->id, 'cloud_provider_token_id' => $token->id, 'digitalocean_droplet_id' => 987, 'digitalocean_droplet_status' => $status, - ]); + ], $attributes)); } it('marks a DigitalOcean droplet as deleted when the provider returns 404', function () { @@ -45,6 +45,48 @@ it('marks a DigitalOcean droplet as deleted when the provider returns 404', func expect($server->fresh()->digitalocean_droplet_status)->toBe('deleted'); }); +it('backfills a placeholder IP from the DigitalOcean droplet state', function () { + $server = createDigitalOceanServerForStateTest('new', ['ip' => Server::PLACEHOLDER_IP]); + + Http::fake([ + 'https://api.digitalocean.com/v2/droplets/987' => Http::response([ + 'droplet' => [ + 'id' => 987, + 'status' => 'active', + 'networks' => [ + 'v4' => [ + ['type' => 'public', 'ip_address' => '203.0.113.10'], + ], + ], + ], + ], 200), + ]); + + expect($server->refreshDigitalOceanState())->toBe('active'); + expect($server->fresh()->ip)->toBe('203.0.113.10'); +}); + +it('does not overwrite a real IP from the DigitalOcean droplet state', function () { + $server = createDigitalOceanServerForStateTest('active', ['ip' => '198.51.100.20']); + + Http::fake([ + 'https://api.digitalocean.com/v2/droplets/987' => Http::response([ + 'droplet' => [ + 'id' => 987, + 'status' => 'active', + 'networks' => [ + 'v4' => [ + ['type' => 'public', 'ip_address' => '203.0.113.10'], + ], + ], + ], + ], 200), + ]); + + $server->refreshDigitalOceanState(); + expect($server->fresh()->ip)->toBe('198.51.100.20'); +}); + it('does not mark a DigitalOcean droplet as deleted on transient provider errors', function () { $server = createDigitalOceanServerForStateTest(); diff --git a/tests/Unit/NavbarThemeSwitcherTest.php b/tests/Unit/NavbarThemeSwitcherTest.php index 3d90f9bc7..7c3caffbb 100644 --- a/tests/Unit/NavbarThemeSwitcherTest.php +++ b/tests/Unit/NavbarThemeSwitcherTest.php @@ -1,13 +1,12 @@ toBe(2) + expect($navbar)->not->toContain('cycleTheme()') ->and($navbar)->not->toContain('aria-label="Theme switcher"') - ->and($navbar)->not->toContain('ml-auto flex items-center gap-0.5') ->and($navbar)->not->toContain('@click.stop="setTheme(\'light\')"') ->and($navbar)->not->toContain('@click.stop="setTheme(\'system\')"') ->and($navbar)->not->toContain('@click.stop="setTheme(\'dark\')"') - ->and($navbar)->toContain('Theme'); + ->and($navbar)->not->toContain('Theme'); }); diff --git a/tests/Unit/ServerPlaceholderIpTest.php b/tests/Unit/ServerPlaceholderIpTest.php new file mode 100644 index 000000000..b471ae7e1 --- /dev/null +++ b/tests/Unit/ServerPlaceholderIpTest.php @@ -0,0 +1,67 @@ + 'Test Team', + 'personal_team' => false, + ]); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + + return Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'ip' => $ip, + ]); +} + +it('detects placeholder IPs', function () { + $server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP); + + foreach ([Server::PLACEHOLDER_IP, '0.0.0.0', '::'] as $ip) { + $server->update(['ip' => $ip]); + + expect($server->fresh()->hasPlaceholderIp())->toBeTrue(); + } +}); + +it('does not treat a real IP as a placeholder', function () { + $server = createServerForPlaceholderIpTest('203.0.113.5'); + + expect($server->hasPlaceholderIp())->toBeFalse(); +}); + +it('backfills a placeholder IP with the real address', function () { + $server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP); + + expect($server->backfillPlaceholderIp('203.0.113.5'))->toBeTrue(); + expect($server->fresh()->ip)->toBe('203.0.113.5'); +}); + +it('does not overwrite a real IP when backfilling', function () { + $server = createServerForPlaceholderIpTest('203.0.113.5'); + + expect($server->backfillPlaceholderIp('198.51.100.9'))->toBeFalse(); + expect($server->fresh()->ip)->toBe('203.0.113.5'); +}); + +it('ignores a missing IP when backfilling', function () { + $server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP); + + expect($server->backfillPlaceholderIp(null))->toBeFalse(); + expect($server->fresh()->ip)->toBe(Server::PLACEHOLDER_IP); +}); + +it('skips servers with a placeholder IP in scheduled jobs', function () { + $server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP); + + expect($server->skipServer())->toBeTrue(); +}); From c8a332a3bc935064dcbb2f7703b1edeb2becfaae Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:52:10 +0200 Subject: [PATCH 05/15] fix(servers): isolate cloud status checks from SSH checks Track provider state independently, skip SSH work for placeholder IPs, and clean up failed cloud server provisioning. --- app/Actions/Server/DeleteServer.php | 18 +-- .../Api/DigitalOceanController.php | 78 ++++++--- app/Http/Controllers/Api/VultrController.php | 6 +- .../CleanupOrphanedPreviewContainersJob.php | 9 +- app/Jobs/ScheduledJobManager.php | 4 +- .../ServerCloudProviderStatusCheckJob.php | 59 +++++++ app/Jobs/ServerConnectionCheckJob.php | 88 +---------- app/Jobs/ServerManagerJob.php | 34 +++- app/Livewire/Server/New/ByDigitalOcean.php | 53 +++++-- app/Livewire/Server/Show.php | 1 + app/Models/Server.php | 99 +++++++++--- tests/Feature/DigitalOceanApiTest.php | 104 ++++++++++++ .../DigitalOceanServerCreationTest.php | 48 ++++++ .../ServerCloudProviderStatusCheckJobTest.php | 149 ++++++++++++++++++ .../ServerConnectionCheckIsolationTest.php | 80 ++++++++++ tests/Feature/VultrApiTest.php | 60 +++++++ tests/Feature/VultrServerLifecycleTest.php | 26 ++- tests/Unit/DigitalOceanDeleteServerTest.php | 57 +++++++ tests/Unit/DigitalOceanServerStateTest.php | 37 ++++- tests/Unit/ServerBackoffTest.php | 16 +- tests/Unit/ServerPlaceholderIpTest.php | 58 +++++++ tests/Unit/VultrDeleteServerTest.php | 50 ++++++ 22 files changed, 954 insertions(+), 180 deletions(-) create mode 100644 app/Jobs/ServerCloudProviderStatusCheckJob.php create mode 100644 tests/Feature/ServerCloudProviderStatusCheckJobTest.php create mode 100644 tests/Feature/ServerConnectionCheckIsolationTest.php diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index aab889479..c6f032013 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -122,12 +122,7 @@ class DeleteServer } if (! $token) { - logger()->debug('No Vultr token found for team, skipping Vultr deletion', [ - 'team_id' => $teamId, - 'vultr_instance_id' => $vultrInstanceId, - ]); - - return; + throw new \RuntimeException('No Vultr token found for the server team.'); } $vultrService = new VultrService($token->token); @@ -143,6 +138,8 @@ class DeleteServer 'vultr_instance_id' => $vultrInstanceId, 'team_id' => $teamId, ]); + + throw $e; } } @@ -165,12 +162,7 @@ class DeleteServer } if (! $token) { - logger()->debug('No DigitalOcean token found for team, skipping droplet deletion', [ - 'team_id' => $teamId, - 'digitalocean_droplet_id' => $digitalOceanDropletId, - ]); - - return; + throw new \RuntimeException('No DigitalOcean token found for the server team.'); } $digitalOceanService = new DigitalOceanService($token->token); @@ -186,6 +178,8 @@ class DeleteServer 'digitalocean_droplet_id' => $digitalOceanDropletId, 'team_id' => $teamId, ]); + + throw $e; } } } diff --git a/app/Http/Controllers/Api/DigitalOceanController.php b/app/Http/Controllers/Api/DigitalOceanController.php index fc51e7c01..5bd9d2392 100644 --- a/app/Http/Controllers/Api/DigitalOceanController.php +++ b/app/Http/Controllers/Api/DigitalOceanController.php @@ -15,6 +15,7 @@ use App\Rules\ValidHostname; use App\Services\DigitalOceanService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; class DigitalOceanController extends Controller @@ -283,6 +284,10 @@ class DigitalOceanController extends Controller return response()->json(['message' => 'Private key not found.'], 404); } + $digitalOceanService = null; + $dropletId = null; + $server = null; + try { $digitalOceanService = new DigitalOceanService($token->token); $sshKeyId = $this->getOrCreateSshKey($digitalOceanService, $privateKey); @@ -309,29 +314,41 @@ class DigitalOceanController extends Controller $droplet = $digitalOceanService->createDroplet($params); $dropletId = (int) $droplet['id']; - $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6); - $ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6); - if (! $ipAddress) { - throw new \Exception('No public IP address available for the new droplet.'); + $server = DB::transaction(function () use ($normalizedServerName, $teamId, $privateKey, $token, $dropletId, $droplet): Server { + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => Server::PLACEHOLDER_IP, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'digitalocean_droplet_id' => $dropletId, + 'digitalocean_droplet_status' => $droplet['status'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return $server; + }); + + try { + $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6); + $ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6); + + if ($ipAddress) { + $server->update([ + 'ip' => $ipAddress, + 'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status, + ]); + } + } catch (\Throwable $e) { + report($e); } - $server = Server::create([ - 'name' => $normalizedServerName, - 'ip' => $ipAddress, - 'user' => 'root', - 'port' => 22, - 'team_id' => $teamId, - 'private_key_id' => $privateKey->id, - 'cloud_provider_token_id' => $token->id, - 'digitalocean_droplet_id' => $dropletId, - 'digitalocean_droplet_status' => $droplet['status'] ?? null, - ]); - - $server->proxy->set('status', 'exited'); - $server->proxy->set('type', ProxyTypes::TRAEFIK->value); - $server->save(); - if ($request->instant_validate) { ValidateServer::dispatch($server); } @@ -341,15 +358,17 @@ class DigitalOceanController extends Controller 'server_uuid' => $server->uuid, 'server_name' => $server->name, 'digitalocean_droplet_id' => $dropletId, - 'ip' => $ipAddress, + 'ip' => $server->ip, ]); return response()->json([ 'uuid' => $server->uuid, 'digitalocean_droplet_id' => $dropletId, - 'ip' => $ipAddress, + 'ip' => $server->ip, ])->setStatusCode(201); } catch (RateLimitException $e) { + $this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server); + $response = response()->json(['message' => $e->getMessage()], 429); if ($e->retryAfter !== null) { $response->header('Retry-After', $e->retryAfter); @@ -357,6 +376,8 @@ class DigitalOceanController extends Controller return $response; } catch (\Throwable $e) { + $this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server); + logger()->error('Failed to create DigitalOcean server', [ 'error' => $e->getMessage(), ]); @@ -365,6 +386,19 @@ class DigitalOceanController extends Controller } } + private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void + { + if (! $digitalOceanService || ! $dropletId || $server) { + return; + } + + try { + $digitalOceanService->deleteDroplet($dropletId); + } catch (\Throwable $e) { + report($e); + } + } + private function getOrCreateSshKey(DigitalOceanService $digitalOceanService, PrivateKey $privateKey): int { $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php index 7aaba568e..60b9d0a0a 100644 --- a/app/Http/Controllers/Api/VultrController.php +++ b/app/Http/Controllers/Api/VultrController.php @@ -52,6 +52,8 @@ class VultrController extends Controller return response()->json(['message' => 'Vultr cloud provider token not found.'], 404); } + $this->authorize('view', $token); + return $token; } @@ -277,6 +279,8 @@ class VultrController extends Controller return response()->json(['message' => 'Vultr cloud provider token not found.'], 404); } + $this->authorize('view', $token); + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); if (! $privateKey) { return response()->json(['message' => 'Private key not found.'], 404); @@ -313,7 +317,7 @@ class VultrController extends Controller } $vultrInstance = $vultrService->createInstance($params); - $ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? '0.0.0.0'; + $ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP; $server = Server::create([ 'name' => $normalizedServerName, diff --git a/app/Jobs/CleanupOrphanedPreviewContainersJob.php b/app/Jobs/CleanupOrphanedPreviewContainersJob.php index 5d3bed457..e74cba554 100644 --- a/app/Jobs/CleanupOrphanedPreviewContainersJob.php +++ b/app/Jobs/CleanupOrphanedPreviewContainersJob.php @@ -12,6 +12,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; /** @@ -53,11 +54,13 @@ class CleanupOrphanedPreviewContainersJob implements ShouldBeEncrypted, ShouldBe /** * Get all functional servers to check for orphaned containers. */ - private function getServersToCheck(): \Illuminate\Support\Collection + private function getServersToCheck(): Collection { $query = Server::whereRelation('settings', 'is_usable', true) ->whereRelation('settings', 'is_reachable', true) - ->where('ip', '!=', '1.2.3.4'); + ->whereNotNull('ip') + ->where('ip', '!=', '') + ->whereNotIn('ip', Server::PLACEHOLDER_IPS); if (isCloud()) { $query = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true); @@ -99,7 +102,7 @@ class CleanupOrphanedPreviewContainersJob implements ShouldBeEncrypted, ShouldBe /** * Get all PR containers on a server (containers with pullRequestId > 0). */ - private function getPRContainersOnServer(Server $server): \Illuminate\Support\Collection + private function getPRContainersOnServer(Server $server): Collection { try { $output = instant_remote_process([ diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index e7a21949c..46bc89d42 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -457,7 +457,9 @@ class ScheduledJobManager implements ShouldQueue private function getServersForCleanupQuery(): Builder { $query = Server::with('settings') - ->where('ip', '!=', '1.2.3.4'); + ->whereNotNull('ip') + ->where('ip', '!=', '') + ->whereNotIn('ip', Server::PLACEHOLDER_IPS); if (isCloud()) { $query diff --git a/app/Jobs/ServerCloudProviderStatusCheckJob.php b/app/Jobs/ServerCloudProviderStatusCheckJob.php new file mode 100644 index 000000000..cd2e51df7 --- /dev/null +++ b/app/Jobs/ServerCloudProviderStatusCheckJob.php @@ -0,0 +1,59 @@ +onQueue('high'); + } + + public function middleware(): array + { + return [(new WithoutOverlapping('server-cloud-provider-status-'.$this->server->uuid))->expireAfter(130)->dontRelease()]; + } + + public function handle(): void + { + try { + if (! $this->server->cloudProviderToken) { + return; + } + + match ($this->server->cloudProviderToken->provider) { + 'hetzner' => $this->server->hetzner_server_id + ? $this->server->refreshHetznerState() + : null, + 'vultr' => $this->server->vultr_instance_id + ? $this->server->refreshVultrState() + : null, + 'digitalocean' => $this->server->digitalocean_droplet_id + ? $this->server->refreshDigitalOceanState() + : null, + default => null, + }; + } catch (\Throwable $e) { + Log::debug('Cloud provider status check failed', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 60adf8c18..f86686df7 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -6,7 +6,6 @@ use App\Events\ServerReachabilityChanged; use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use App\Services\ConfigurationRepository; -use App\Services\HetznerService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -42,8 +41,12 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue $configRepository->disableSshMux(); } - public function handle() + public function handle(): void { + if ($this->server->hasPlaceholderIp()) { + return; + } + $wasReachable = (bool) $this->server->settings->is_reachable; $wasNotified = (bool) $this->server->unreachable_notification_sent; @@ -62,19 +65,6 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue return; } - // Check Hetzner server status if applicable - if ($this->server->hetzner_server_id && $this->server->cloudProviderToken) { - $this->checkHetznerStatus(); - } - - if ($this->server->vultr_instance_id && $this->server->cloudProviderToken) { - $this->checkVultrStatus(); - } - - if ($this->server->digitalocean_droplet_id && $this->server->cloudProviderToken) { - $this->checkDigitalOceanStatus(); - } - // Temporarily disable mux if requested if ($this->disableMux) { $this->disableSshMux(); @@ -136,17 +126,6 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue public function failed(?\Throwable $exception): void { if ($exception instanceof TimeoutExceededException) { - $wasReachable = (bool) $this->server->settings->is_reachable; - $wasNotified = (bool) $this->server->unreachable_notification_sent; - - $this->server->settings->update([ - 'is_reachable' => false, - 'is_usable' => false, - ]); - $this->server->increment('unreachable_count'); - - $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false); - // Delete the queue job so it doesn't appear in Horizon's failed list. $this->job?->delete(); } @@ -171,63 +150,6 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue } } - private function checkHetznerStatus(): void - { - $status = null; - - try { - $hetznerService = new HetznerService($this->server->cloudProviderToken->token); - $serverData = $hetznerService->getServer($this->server->hetzner_server_id); - $status = $serverData['status'] ?? null; - - } 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]); - $this->server->hetzner_server_status = $status; - if ($status === 'off') { - throw new \Exception('Server is powered off'); - } - } - - } - - private function checkVultrStatus(): void - { - try { - $status = $this->server->refreshVultrState(); - } catch (\Throwable) { - // Silently ignore transient Vultr API errors. - - return; - } - - if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { - throw new \Exception('Vultr instance is not running'); - } - } - - private function checkDigitalOceanStatus(): void - { - try { - $status = $this->server->refreshDigitalOceanState(); - } catch (\Throwable $e) { - Log::debug('ServerConnectionCheck: DigitalOcean status check failed', [ - 'server_id' => $this->server->id, - 'error' => $e->getMessage(), - ]); - - return; - } - - $this->server->digitalocean_droplet_status = $status; - - if (in_array($status, ['off', 'archive', 'deleted'], true)) { - throw new \Exception('DigitalOcean droplet is not running'); - } - } - private function checkConnection(): bool { try { diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 9532282cc..67c222c24 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -55,6 +55,13 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue // Get all servers to process $servers = $this->getServers(); + // Provider state checks run independently so slow APIs cannot block SSH checks. + $this->dispatchCloudProviderStatusChecks($servers); + + $servers = $servers + ->reject(fn (Server $server) => $server->hasPlaceholderIp()) + ->values(); + // Dispatch ServerConnectionCheck for all servers efficiently $this->dispatchConnectionChecks($servers); @@ -64,24 +71,45 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue private function getServers(): Collection { - $allServers = Server::with('settings')->where('ip', '!=', '1.2.3.4'); + $allServers = Server::with(['settings', 'cloudProviderToken']); if (isCloud()) { $servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); - $own = Team::find(0)->servers()->with('settings')->get(); + $own = Team::find(0)->servers()->with(['settings', 'cloudProviderToken'])->get(); - return $servers->merge($own); + return $servers->merge($own)->unique('id')->values(); } else { return $allServers->get(); } } + private function dispatchCloudProviderStatusChecks(Collection $servers): void + { + if (! shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-cloud-provider-status-checks', $this->executionTime)) { + return; + } + + $servers->each(function (Server $server) { + $hasCloudResource = $server->hetzner_server_id + || $server->vultr_instance_id + || $server->digitalocean_droplet_id; + + if ($hasCloudResource && $server->cloudProviderToken) { + ServerCloudProviderStatusCheckJob::dispatch($server); + } + }); + } + private function dispatchConnectionChecks(Collection $servers): void { if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) { $servers->each(function (Server $server) { try { + if ($server->hasPlaceholderIp()) { + return; + } + // Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity if ($server->isSentinelEnabled() && $server->isSentinelLive()) { return; diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php index a59f23efb..cf27f0f08 100644 --- a/app/Livewire/Server/New/ByDigitalOcean.php +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -14,6 +14,7 @@ use App\Services\DigitalOceanService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Attributes\Locked; use Livewire\Component; @@ -449,6 +450,10 @@ class ByDigitalOcean extends Component { $this->validate(); + $digitalOceanService = null; + $dropletId = null; + $server = null; + try { $this->authorize('create', Server::class); @@ -468,24 +473,29 @@ class ByDigitalOcean extends Component $digitalOceanService = new DigitalOceanService($this->getDigitalOceanToken()); $droplet = $this->createDigitalOceanDroplet($digitalOceanService); + $dropletId = (int) $droplet['id']; // Persist the server immediately so the droplet is always tracked // in Coolify, even if waiting for the public IP fails below. - $server = Server::create([ - 'name' => strtolower(trim($this->server_name)), - 'ip' => Server::PLACEHOLDER_IP, - 'user' => 'root', - 'port' => 22, - 'team_id' => currentTeam()->id, - 'private_key_id' => $this->private_key_id, - 'cloud_provider_token_id' => $this->selected_token_id, - 'digitalocean_droplet_id' => $droplet['id'], - 'digitalocean_droplet_status' => $droplet['status'] ?? null, - ]); + $server = DB::transaction(function () use ($dropletId, $droplet): Server { + $server = Server::create([ + 'name' => strtolower(trim($this->server_name)), + 'ip' => Server::PLACEHOLDER_IP, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'digitalocean_droplet_id' => $dropletId, + 'digitalocean_droplet_status' => $droplet['status'] ?? null, + ]); - $server->proxy->set('status', 'exited'); - $server->proxy->set('type', ProxyTypes::TRAEFIK->value); - $server->save(); + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return $server; + }); try { $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6); @@ -510,10 +520,25 @@ class ByDigitalOcean extends Component return redirectRoute($this, 'server.show', [$server->uuid]); } catch (\Throwable $e) { + $this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server); + return handleError($e, $this); } } + private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void + { + if (! $digitalOceanService || ! $dropletId || $server) { + return; + } + + try { + $digitalOceanService->deleteDroplet($dropletId); + } catch (\Throwable $e) { + report($e); + } + } + public function render() { return view('livewire.server.new.by-digital-ocean'); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 83f4f83d9..6cd004362 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -611,6 +611,7 @@ class Show extends Component public function startVultrInstance() { try { + $this->authorize('update', $this->server); if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) { $this->dispatch('error', 'This server is not associated with a Vultr instance or token.'); diff --git a/app/Models/Server.php b/app/Models/Server.php index 928f80fed..d55df0179 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -18,6 +18,7 @@ use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; use App\Services\DigitalOceanService; +use App\Services\HetznerService; use App\Services\VultrService; use App\Support\ValidationPatterns; use App\Traits\ClearsGlobalSearchCache; @@ -119,6 +120,8 @@ class Server extends BaseModel */ public const PLACEHOLDER_IP = '1.2.3.4'; + public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::']; + public static $batch_counter = 0; /** @@ -317,9 +320,12 @@ class Server extends BaseModel public function hasPlaceholderIp(): bool { // Cast: the saving hook stores the ip as a Stringable in memory. - $ip = (string) $this->ip; + return self::isPlaceholderIp((string) $this->ip); + } - return blank($ip) || in_array($ip, [self::PLACEHOLDER_IP, '0.0.0.0', '::'], true); + public static function isPlaceholderIp(?string $ip): bool + { + return blank($ip) || in_array($ip, self::PLACEHOLDER_IPS, true); } /** @@ -328,13 +334,66 @@ class Server extends BaseModel */ public function backfillPlaceholderIp(?string $ip): bool { - if ($ip && $this->hasPlaceholderIp()) { - $this->update(['ip' => $ip]); - - return true; + if (self::isPlaceholderIp($ip)) { + return false; } - return false; + $updated = static::query() + ->whereKey($this->getKey()) + ->where(function (Builder $query): void { + $query->whereNull('ip') + ->orWhere('ip', '') + ->orWhereIn('ip', self::PLACEHOLDER_IPS); + }) + ->update(['ip' => $ip]); + + if ($updated === 0) { + return false; + } + + $this->forceFill(['ip' => $ip]); + $this->syncOriginalAttribute('ip'); + static::flushIdentityMap(); + + return true; + } + + /** + * Persist provider status without saving a stale in-memory IP value. + * + * @param array $updates + */ + private function persistProviderState(array $updates): void + { + if (empty($updates)) { + return; + } + + static::query()->whereKey($this->getKey())->update($updates); + $this->forceFill($updates); + $this->syncOriginalAttributes(array_keys($updates)); + static::flushIdentityMap(); + } + + public function refreshHetznerState(): ?string + { + if (! $this->hetzner_server_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hetzner') { + return $this->hetzner_server_status; + } + + $hetznerService = new HetznerService($this->cloudProviderToken->token); + $server = $hetznerService->getServer($this->hetzner_server_id); + $status = $server['status'] ?? null; + $assignedIp = data_get($server, 'public_net.ipv4.ip') ?? data_get($server, 'public_net.ipv6.ip'); + + $updates = []; + if ($this->hetzner_server_status !== $status) { + $updates['hetzner_server_status'] = $status; + } + $this->persistProviderState($updates); + $this->backfillPlaceholderIp($assignedIp); + + return $status; } public function refreshVultrState(): ?string @@ -352,8 +411,7 @@ class Server extends BaseModel } if ($this->vultr_instance_status !== 'deleted') { - $this->update(['vultr_instance_status' => 'deleted']); - $this->forceFill(['vultr_instance_status' => 'deleted']); + $this->persistProviderState(['vultr_instance_status' => 'deleted']); } return 'deleted'; @@ -368,15 +426,8 @@ class Server extends BaseModel if ($this->vultr_instance_status !== $status) { $updates['vultr_instance_status'] = $status; } - - if ($this->hasPlaceholderIp() && $publicIp) { - $updates['ip'] = $publicIp; - } - - if (! empty($updates)) { - $this->update($updates); - $this->forceFill($updates); - } + $this->persistProviderState($updates); + $this->backfillPlaceholderIp($publicIp); return $status; } @@ -393,7 +444,7 @@ class Server extends BaseModel $droplet = $digitalOceanService->getDroplet((int) $this->digitalocean_droplet_id); } catch (RequestException $e) { if ($e->response?->status() === 404) { - $this->update(['digitalocean_droplet_status' => 'deleted']); + $this->persistProviderState(['digitalocean_droplet_status' => 'deleted']); return 'deleted'; } @@ -401,7 +452,7 @@ class Server extends BaseModel throw $e; } catch (\Throwable $e) { if ((int) $e->getCode() === 404) { - $this->update(['digitalocean_droplet_status' => 'deleted']); + $this->persistProviderState(['digitalocean_droplet_status' => 'deleted']); return 'deleted'; } @@ -416,12 +467,8 @@ class Server extends BaseModel $status = $droplet['status'] ?? null; $ip = $digitalOceanService->getPublicIpAddress($droplet); - $updates = ['digitalocean_droplet_status' => $status]; - if ($ip && $this->hasPlaceholderIp()) { - $updates['ip'] = $ip; - } - - $this->update($updates); + $this->persistProviderState(['digitalocean_droplet_status' => $status]); + $this->backfillPlaceholderIp($ip); return $status; } diff --git a/tests/Feature/DigitalOceanApiTest.php b/tests/Feature/DigitalOceanApiTest.php index 04c1e16be..826899957 100644 --- a/tests/Feature/DigitalOceanApiTest.php +++ b/tests/Feature/DigitalOceanApiTest.php @@ -3,6 +3,7 @@ use App\Models\CloudProviderToken; use App\Models\InstanceSettings; use App\Models\PrivateKey; +use App\Models\Server; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -133,6 +134,109 @@ describe('POST /api/v1/servers/digitalocean', function () { && $request['user_data'] === '#cloud-config'); }); + test('tracks the droplet with a placeholder IP when waiting for its IP fails', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/account/keys' => Http::response([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201), + 'https://api.digitalocean.com/v2/account/keys*' => Http::response([ + 'ssh_keys' => [], + 'links' => ['pages' => []], + ], 200), + 'https://api.digitalocean.com/v2/droplets' => Http::response([ + 'droplet' => [ + 'id' => 987, + 'name' => 'waiting-for-ip', + 'status' => 'new', + 'networks' => ['v4' => [], 'v6' => []], + ], + ], 202), + 'https://api.digitalocean.com/v2/droplets/987' => Http::response([ + 'message' => 'temporary provider failure', + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/digitalocean', [ + 'cloud_provider_token_id' => $this->digitalOceanToken->uuid, + 'region' => 'nyc1', + 'size' => 's-1vcpu-1gb', + 'image' => 'ubuntu-24-04-x64', + 'name' => 'waiting-for-ip', + 'private_key_uuid' => $this->privateKey->uuid, + ]); + + $response->assertCreated(); + $response->assertJsonFragment([ + 'digitalocean_droplet_id' => 987, + 'ip' => Server::PLACEHOLDER_IP, + ]); + + $this->assertDatabaseHas('servers', [ + 'name' => 'waiting-for-ip', + 'ip' => Server::PLACEHOLDER_IP, + 'team_id' => $this->team->id, + 'digitalocean_droplet_id' => 987, + 'digitalocean_droplet_status' => 'new', + ]); + }); + + test('deletes the droplet when local server persistence fails', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/account/keys' => Http::response([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201), + 'https://api.digitalocean.com/v2/account/keys*' => Http::response([ + 'ssh_keys' => [], + 'links' => ['pages' => []], + ], 200), + 'https://api.digitalocean.com/v2/droplets' => Http::response([ + 'droplet' => [ + 'id' => 987, + 'name' => 'persistence-fails', + 'status' => 'active', + 'networks' => [ + 'v4' => [ + ['ip_address' => '203.0.113.10', 'type' => 'public'], + ], + ], + ], + ], 202), + 'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204), + ]); + + $eventDispatcher = Server::getEventDispatcher(); + Server::setEventDispatcher(clone $eventDispatcher); + Server::created(function (): void { + throw new RuntimeException('local persistence failed'); + }); + + try { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/digitalocean', [ + 'cloud_provider_token_id' => $this->digitalOceanToken->uuid, + 'region' => 'nyc1', + 'size' => 's-1vcpu-1gb', + 'image' => 'ubuntu-24-04-x64', + 'name' => 'persistence-fails', + 'private_key_uuid' => $this->privateKey->uuid, + ]); + } finally { + Server::setEventDispatcher($eventDispatcher); + } + + $response->assertServerError(); + $this->assertDatabaseMissing('servers', [ + 'digitalocean_droplet_id' => 987, + ]); + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://api.digitalocean.com/v2/droplets/987'); + }); + test('validates required fields', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, diff --git a/tests/Feature/DigitalOceanServerCreationTest.php b/tests/Feature/DigitalOceanServerCreationTest.php index b42148dfc..3d81a5dc9 100644 --- a/tests/Feature/DigitalOceanServerCreationTest.php +++ b/tests/Feature/DigitalOceanServerCreationTest.php @@ -113,6 +113,54 @@ it('updates the placeholder IP once the droplet reports one', function () { ]); }); +it('deletes the droplet when local server persistence fails', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/account/keys' => Http::response([ + 'ssh_key' => ['id' => 123], + ], 201), + 'https://api.digitalocean.com/v2/account/keys*' => Http::response([ + 'ssh_keys' => [], + ], 200), + 'https://api.digitalocean.com/v2/droplets' => Http::response([ + 'droplet' => [ + 'id' => 555, + 'status' => 'active', + 'networks' => [ + 'v4' => [ + ['type' => 'public', 'ip_address' => '203.0.113.40'], + ], + ], + ], + ], 202), + 'https://api.digitalocean.com/v2/droplets/555' => Http::response(null, 204), + ]); + + $eventDispatcher = Server::getEventDispatcher(); + Server::setEventDispatcher(clone $eventDispatcher); + Server::created(function (): void { + throw new RuntimeException('local persistence failed'); + }); + + try { + Livewire::test(ByDigitalOcean::class, ['selectedTokenUuid' => $this->digitalOceanToken->uuid]) + ->set('server_name', 'persistence-fails') + ->set('selected_region', 'nyc1') + ->set('selected_size', 's-1vcpu-1gb') + ->set('selected_image', 'ubuntu-24-04-x64') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertDispatched('error', 'local persistence failed'); + } finally { + Server::setEventDispatcher($eventDispatcher); + } + + $this->assertDatabaseMissing('servers', [ + 'digitalocean_droplet_id' => 555, + ]); + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://api.digitalocean.com/v2/droplets/555'); +}); + it('renders only the full width buy button at the bottom of the DigitalOcean form', function () { Livewire::test(ByDigitalOcean::class) ->set('current_step', 2) diff --git a/tests/Feature/ServerCloudProviderStatusCheckJobTest.php b/tests/Feature/ServerCloudProviderStatusCheckJobTest.php new file mode 100644 index 000000000..10e1a8c9d --- /dev/null +++ b/tests/Feature/ServerCloudProviderStatusCheckJobTest.php @@ -0,0 +1,149 @@ +create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $token = CloudProviderToken::create([ + 'team_id' => $team->id, + 'provider' => $provider, + 'token' => "test-{$provider}-token", + 'name' => ucfirst($provider), + ]); + + return Server::factory()->create(array_merge([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'ip' => Server::PLACEHOLDER_IP, + ], $attributes)); +} + +beforeEach(function () { + InstanceSettings::forceCreate([ + 'id' => 0, + 'instance_timezone' => 'UTC', + ]); + Carbon::setTestNow('2026-07-11 12:00:00'); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +it('syncs provider state for a placeholder server without scheduling SSH', function () { + $server = createCloudServerForStatusCheckJobTest('digitalocean', [ + 'digitalocean_droplet_id' => 987, + 'digitalocean_droplet_status' => 'new', + ]); + + Queue::fake(); + + (new ServerManagerJob)->handle(); + + Queue::assertPushed(ServerCloudProviderStatusCheckJob::class, fn (ServerCloudProviderStatusCheckJob $job) => $job->server->is($server)); + Queue::assertNotPushed(ServerConnectionCheckJob::class); +}); + +it('backfills a DigitalOcean placeholder without changing reachability', function () { + $server = createCloudServerForStatusCheckJobTest('digitalocean', [ + 'digitalocean_droplet_id' => 987, + 'digitalocean_droplet_status' => 'new', + ]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + Http::fake([ + 'https://api.digitalocean.com/v2/droplets/987' => Http::response([ + 'droplet' => [ + 'id' => 987, + 'status' => 'active', + 'networks' => [ + 'v4' => [ + ['type' => 'public', 'ip_address' => '203.0.113.10'], + ], + ], + ], + ]), + ]); + + (new ServerCloudProviderStatusCheckJob($server))->handle(); + + $server->refresh(); + expect($server->ip)->toBe('203.0.113.10') + ->and($server->digitalocean_droplet_status)->toBe('active') + ->and((bool) $server->settings->fresh()->is_reachable)->toBeTrue() + ->and((bool) $server->settings->fresh()->is_usable)->toBeTrue(); +}); + +it('backfills a Hetzner placeholder without changing reachability', function () { + $server = createCloudServerForStatusCheckJobTest('hetzner', [ + 'hetzner_server_id' => 123, + 'hetzner_server_status' => 'starting', + ]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + Http::fake([ + 'https://api.hetzner.cloud/v1/servers/123' => Http::response([ + 'server' => [ + 'id' => 123, + 'status' => 'running', + 'public_net' => [ + 'ipv4' => ['ip' => '198.51.100.20'], + ], + ], + ]), + ]); + + (new ServerCloudProviderStatusCheckJob($server))->handle(); + + $server->refresh(); + expect($server->ip)->toBe('198.51.100.20') + ->and($server->hetzner_server_status)->toBe('running') + ->and((bool) $server->settings->fresh()->is_reachable)->toBeTrue() + ->and((bool) $server->settings->fresh()->is_usable)->toBeTrue(); +}); + +it('only calls the API matching the cloud provider token', function () { + $server = createCloudServerForStatusCheckJobTest('digitalocean', [ + 'vultr_instance_id' => 'unrelated-vultr-instance', + 'digitalocean_droplet_id' => 987, + 'digitalocean_droplet_status' => 'new', + ]); + + Http::fake([ + 'https://api.digitalocean.com/v2/droplets/987' => Http::response([ + 'droplet' => [ + 'id' => 987, + 'status' => 'active', + 'networks' => ['v4' => []], + ], + ]), + 'https://api.vultr.com/*' => Http::response(status: 500), + ]); + + (new ServerCloudProviderStatusCheckJob($server))->handle(); + + Http::assertSentCount(1); + Http::assertSent(fn ($request) => $request->url() === 'https://api.digitalocean.com/v2/droplets/987'); +}); diff --git a/tests/Feature/ServerConnectionCheckIsolationTest.php b/tests/Feature/ServerConnectionCheckIsolationTest.php new file mode 100644 index 000000000..d098c39b5 --- /dev/null +++ b/tests/Feature/ServerConnectionCheckIsolationTest.php @@ -0,0 +1,80 @@ +create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + + return Server::factory()->create(array_merge([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + ], $attributes)); +} + +beforeEach(function () { + Storage::fake('ssh-keys'); +}); + +it('never attempts SSH to a placeholder address', function (string $placeholderIp) { + $server = createServerForConnectionIsolationTest(['ip' => $placeholderIp]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + Process::fake(); + + (new ServerConnectionCheckJob($server, disableMux: false))->handle(); + + Process::assertNothingRan(); + expect($server->fresh()->unreachable_count)->toBe(0) + ->and((bool) $server->settings->fresh()->is_reachable)->toBeTrue() + ->and((bool) $server->settings->fresh()->is_usable)->toBeTrue(); +})->with([ + 'reserved provisioning address' => Server::PLACEHOLDER_IP, + 'unspecified IPv4 address' => '0.0.0.0', + 'unspecified IPv6 address' => '::', +]); + +it('does not call cloud provider APIs during an SSH connection check', function () { + $server = createServerForConnectionIsolationTest(['ip' => '203.0.113.10']); + $token = CloudProviderToken::create([ + 'team_id' => $server->team_id, + 'provider' => 'vultr', + 'token' => 'test-vultr-token', + 'name' => 'Vultr', + ]); + $server->update([ + 'cloud_provider_token_id' => $token->id, + 'vultr_instance_id' => 'instance-1', + 'vultr_instance_status' => 'active', + ]); + + Http::fake([ + 'https://api.vultr.com/*' => Http::response([ + 'instance' => ['id' => 'instance-1', 'status' => 'active'], + ]), + ]); + Process::fake([ + '*' => Process::result( + output: '{"Server":{"Version":"27.0.0"}}', + exitCode: 0, + ), + ]); + + (new ServerConnectionCheckJob($server->fresh(), disableMux: false))->handle(); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/VultrApiTest.php b/tests/Feature/VultrApiTest.php index 8a96ec685..9eb135baf 100644 --- a/tests/Feature/VultrApiTest.php +++ b/tests/Feature/VultrApiTest.php @@ -1,11 +1,15 @@ create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $memberToken = $member->createToken('member-read', ['read'])->plainTextToken; + + Http::fake(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$memberToken, + 'Content-Type' => 'application/json', + ])->getJson($endpoint.'?cloud_provider_token_uuid='.$this->vultrToken->uuid); + + $response->assertForbidden(); + Http::assertNothingSent(); +})->with([ + '/api/v1/vultr/regions', + '/api/v1/vultr/plans', + '/api/v1/vultr/os', + '/api/v1/vultr/ssh-keys', +]); + describe('POST /api/v1/servers/vultr', function () { test('creates a Vultr server', function () { Http::fake([ @@ -223,6 +248,11 @@ describe('POST /api/v1/servers/vultr', function () { }); test('waits for Vultr to assign a real public IP when creation returns placeholder IP', function () { + $createdIp = null; + Server::created(function (Server $server) use (&$createdIp): void { + $createdIp = (string) $server->ip; + }); + Http::fake([ 'https://api.vultr.com/v2/ssh-keys' => Http::response([ 'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()], @@ -262,6 +292,8 @@ describe('POST /api/v1/servers/vultr', function () { $response->assertStatus(201); $response->assertJsonFragment(['ip' => '1.2.3.4']); + expect($createdIp)->toBe(Server::PLACEHOLDER_IP); + $this->assertDatabaseHas('servers', [ 'name' => 'test-server', 'ip' => '1.2.3.4', @@ -269,6 +301,34 @@ describe('POST /api/v1/servers/vultr', function () { ]); }); + test('server creation authorizes access to the stored Vultr token', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + $member->withAccessToken($member->createToken('member-all', ['*'])->accessToken); + + $request = Request::create( + uri: '/api/v1/servers/vultr', + method: 'POST', + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode([ + 'cloud_provider_token_uuid' => $this->vultrToken->uuid, + 'region' => 'ewr', + 'plan' => 'vc2-1c-1gb', + 'os_id' => 2284, + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + ], JSON_THROW_ON_ERROR), + ); + $request->setUserResolver(fn () => $member); + + Http::fake(); + + expect(fn () => app(VultrController::class)->createServer($request)) + ->toThrow(AuthorizationException::class); + Http::assertNothingSent(); + }); + test('reuses existing matching Vultr SSH key', function () { Http::fake([ 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ diff --git a/tests/Feature/VultrServerLifecycleTest.php b/tests/Feature/VultrServerLifecycleTest.php index c89ca47b4..cb3b3108c 100644 --- a/tests/Feature/VultrServerLifecycleTest.php +++ b/tests/Feature/VultrServerLifecycleTest.php @@ -123,6 +123,8 @@ it('updates placeholder server IP when Vultr status refresh returns assigned pub }); it('does not overwrite an established server IP during Vultr status refresh', function () { + $this->server->update(['ip' => '198.51.100.20']); + Http::fake([ 'https://api.vultr.com/v2/instances/instance-1' => Http::response([ 'instance' => [ @@ -135,12 +137,12 @@ it('does not overwrite an established server IP during Vultr status refresh', fu Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) ->call('checkVultrInstanceStatus', true) - ->assertSet('ip', '1.2.3.4') + ->assertSet('ip', '198.51.100.20') ->assertSet('vultrInstanceStatus', 'active'); $this->assertDatabaseHas('servers', [ 'id' => $this->server->id, - 'ip' => '1.2.3.4', + 'ip' => '198.51.100.20', 'vultr_instance_status' => 'active', ]); }); @@ -270,6 +272,26 @@ it('starts a Vultr instance', function () { Http::assertSent(fn ($request) => $request->url() === 'https://api.vultr.com/v2/instances/instance-1/start'); }); +it('prevents members from starting a Vultr instance', function () { + $this->server->update(['vultr_instance_status' => 'stopped']); + + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1/start' => Http::response([], 204), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('startVultrInstance') + ->assertDispatched('error'); + + expect($this->server->fresh()->vultr_instance_status)->toBe('stopped'); + Http::assertNothingSent(); +}); + it('links a server to Vultr by matching IP', function () { $unlinkedServer = Server::factory()->create([ 'team_id' => $this->team->id, diff --git a/tests/Unit/DigitalOceanDeleteServerTest.php b/tests/Unit/DigitalOceanDeleteServerTest.php index 5b0989607..60cd5ba50 100644 --- a/tests/Unit/DigitalOceanDeleteServerTest.php +++ b/tests/Unit/DigitalOceanDeleteServerTest.php @@ -6,6 +6,7 @@ use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use Tests\TestCase; @@ -44,3 +45,59 @@ it('deletes the DigitalOcean droplet when requested', function () { Http::assertSent(fn ($request) => $request->method() === 'DELETE' && $request->url() === 'https://api.digitalocean.com/v2/droplets/987'); }); + +it('retains the server and surfaces a DigitalOcean deletion failure', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/droplets/987' => Http::response([ + 'message' => 'deletion failed', + ], 500), + ]); + + $team = Team::factory()->create(); + $token = CloudProviderToken::factory()->create([ + 'team_id' => $team->id, + 'provider' => 'digitalocean', + 'token' => 'test-digitalocean-token', + ]); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'digitalocean_droplet_id' => 987, + ]); + $server->delete(); + + expect(fn () => DeleteServer::run( + serverId: $server->id, + deleteFromDigitalOcean: true, + digitalOceanDropletId: 987, + cloudProviderTokenId: $token->id, + teamId: $team->id, + ))->toThrow(RequestException::class, 'status code 500'); + + expect(Server::withTrashed()->find($server->id))->not->toBeNull(); +}); + +it('retains the server when no DigitalOcean token can delete the droplet', function () { + Http::preventStrayRequests(); + + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => null, + 'digitalocean_droplet_id' => 987, + ]); + $server->delete(); + + expect(fn () => DeleteServer::run( + serverId: $server->id, + deleteFromDigitalOcean: true, + digitalOceanDropletId: 987, + teamId: $team->id, + ))->toThrow(RuntimeException::class, 'No DigitalOcean token found'); + + expect(Server::withTrashed()->find($server->id))->not->toBeNull(); +}); diff --git a/tests/Unit/DigitalOceanServerStateTest.php b/tests/Unit/DigitalOceanServerStateTest.php index 93b3379bb..725a38c5b 100644 --- a/tests/Unit/DigitalOceanServerStateTest.php +++ b/tests/Unit/DigitalOceanServerStateTest.php @@ -66,8 +66,8 @@ it('backfills a placeholder IP from the DigitalOcean droplet state', function () expect($server->fresh()->ip)->toBe('203.0.113.10'); }); -it('does not overwrite a real IP from the DigitalOcean droplet state', function () { - $server = createDigitalOceanServerForStateTest('active', ['ip' => '198.51.100.20']); +it('does not overwrite an administrator configured address from the DigitalOcean droplet state', function (string $configuredAddress) { + $server = createDigitalOceanServerForStateTest('active', ['ip' => $configuredAddress]); Http::fake([ 'https://api.digitalocean.com/v2/droplets/987' => Http::response([ @@ -84,7 +84,38 @@ it('does not overwrite a real IP from the DigitalOcean droplet state', function ]); $server->refreshDigitalOceanState(); - expect($server->fresh()->ip)->toBe('198.51.100.20'); + expect($server->fresh()->ip)->toBe($configuredAddress); +})->with([ + 'public IPv4' => '198.51.100.20', + 'private IPv4' => '10.10.0.12', + 'IPv6' => '2001:db8::10', + 'tunnel hostname' => 'server.internal.example.com', +]); + +it('does not overwrite an address configured while DigitalOcean state is loading', function () { + $server = createDigitalOceanServerForStateTest('new', ['ip' => Server::PLACEHOLDER_IP]); + + Http::fake(function () use ($server) { + Server::query()->findOrFail($server->id)->update([ + 'ip' => 'server.internal.example.com', + ]); + + return Http::response([ + 'droplet' => [ + 'id' => 987, + 'status' => 'active', + 'networks' => [ + 'v4' => [ + ['type' => 'public', 'ip_address' => '203.0.113.10'], + ], + ], + ], + ], 200); + }); + + expect($server->refreshDigitalOceanState())->toBe('active') + ->and($server->fresh()->ip)->toBe('server.internal.example.com') + ->and($server->digitalocean_droplet_status)->toBe('active'); }); it('does not mark a DigitalOcean droplet as deleted on transient provider errors', function () { diff --git a/tests/Unit/ServerBackoffTest.php b/tests/Unit/ServerBackoffTest.php index 7c9910a5c..3bbf41b3a 100644 --- a/tests/Unit/ServerBackoffTest.php +++ b/tests/Unit/ServerBackoffTest.php @@ -127,7 +127,7 @@ describe('shouldSkipDueToBackoff', function () { }); describe('ServerConnectionCheckJob unreachable_count', function () { - it('marks Vultr servers unreachable when provider status is unavailable', function () { + it('marks servers unreachable when SSH is unavailable', function () { Event::fake([ServerReachabilityChanged::class]); $settings = Mockery::mock(); @@ -141,14 +141,12 @@ describe('ServerConnectionCheckJob unreachable_count', function () { $server->shouldReceive('getAttribute')->andReturnUsing(fn (string $key) => match ($key) { 'settings' => $settings, 'unreachable_notification_sent' => false, - 'vultr_instance_id' => 'instance-1', - 'cloudProviderToken' => (object) ['token' => 'test-token'], + 'ip' => '203.0.113.10', 'id' => 1, 'name' => 'test-server', 'unreachable_count' => 1, default => null, }); - $server->shouldReceive('refreshVultrState')->once()->andReturn('stopped'); $server->shouldReceive('increment')->with('unreachable_count')->once(); $server->id = 1; $server->name = 'test-server'; @@ -158,22 +156,20 @@ describe('ServerConnectionCheckJob unreachable_count', function () { $job->handle(); }); - it('increments unreachable_count on timeout', function () { + it('does not change reachability on a job-level timeout', function () { Event::fake([ServerReachabilityChanged::class]); $settings = Mockery::mock(); $settings->is_reachable = true; - $settings->shouldReceive('update') - ->with(['is_reachable' => false, 'is_usable' => false]) - ->once(); + $settings->shouldNotReceive('update'); $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); $server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false); - $server->shouldReceive('increment')->with('unreachable_count')->once(); + $server->shouldNotReceive('increment'); $server->id = 1; $server->name = 'test-server'; - $server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is + $server->unreachable_count = 1; $job = new ServerConnectionCheckJob($server); $job->failed(new TimeoutExceededException); diff --git a/tests/Unit/ServerPlaceholderIpTest.php b/tests/Unit/ServerPlaceholderIpTest.php index b471ae7e1..94787b4d4 100644 --- a/tests/Unit/ServerPlaceholderIpTest.php +++ b/tests/Unit/ServerPlaceholderIpTest.php @@ -1,5 +1,7 @@ fresh()->ip)->toBe(Server::PLACEHOLDER_IP); }); +it('does not replace a placeholder with another placeholder', function (string $replacementIp) { + $server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP); + + expect($server->backfillPlaceholderIp($replacementIp))->toBeFalse(); + expect($server->fresh()->ip)->toBe(Server::PLACEHOLDER_IP); +})->with([ + 'unspecified IPv4 address' => '0.0.0.0', + 'unspecified IPv6 address' => '::', +]); + +it('does not overwrite an address configured after the placeholder model was loaded', function () { + $staleServer = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP); + $concurrentServer = Server::query()->findOrFail($staleServer->id); + + $concurrentServer->update(['ip' => 'server.internal.example.com']); + + expect($staleServer->backfillPlaceholderIp('203.0.113.10'))->toBeFalse() + ->and($staleServer->fresh()->ip)->toBe('server.internal.example.com'); +}); + it('skips servers with a placeholder IP in scheduled jobs', function () { $server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP); expect($server->skipServer())->toBeTrue(); }); + +it('excludes every placeholder address from scheduled Docker cleanup', function () { + $realServer = createServerForPlaceholderIpTest('203.0.113.10'); + + foreach (Server::PLACEHOLDER_IPS as $placeholderIp) { + Server::factory()->create([ + 'team_id' => $realServer->team_id, + 'private_key_id' => $realServer->private_key_id, + 'ip' => $placeholderIp, + ]); + } + + $method = new ReflectionMethod(ScheduledJobManager::class, 'getServersForCleanupQuery'); + $servers = $method->invoke(new ScheduledJobManager)->get(); + + expect($servers->modelKeys())->toBe([$realServer->id]); +}); + +it('excludes every placeholder address from orphaned preview cleanup', function () { + $realServer = createServerForPlaceholderIpTest('203.0.113.10'); + $realServer->settings->update(['is_reachable' => true, 'is_usable' => true]); + + foreach (Server::PLACEHOLDER_IPS as $placeholderIp) { + $server = Server::factory()->create([ + 'team_id' => $realServer->team_id, + 'private_key_id' => $realServer->private_key_id, + 'ip' => $placeholderIp, + ]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + } + + $method = new ReflectionMethod(CleanupOrphanedPreviewContainersJob::class, 'getServersToCheck'); + $servers = $method->invoke(new CleanupOrphanedPreviewContainersJob); + + expect($servers->modelKeys())->toBe([$realServer->id]); +}); diff --git a/tests/Unit/VultrDeleteServerTest.php b/tests/Unit/VultrDeleteServerTest.php index 05891eed7..82173d40d 100644 --- a/tests/Unit/VultrDeleteServerTest.php +++ b/tests/Unit/VultrDeleteServerTest.php @@ -7,6 +7,7 @@ use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use Tests\TestCase; @@ -116,3 +117,52 @@ it('does not use another team Vultr token when deleting an instance', function ( expect($request->header('Authorization'))->toBe(['Bearer test-vultr-token']); }); + +it('retains the server and surfaces a Vultr deletion failure', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([ + 'error' => 'deletion failed', + ], 500), + ]); + + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->vultrToken->id, + 'vultr_instance_id' => 'instance-1', + ]); + $server->delete(); + + expect(fn () => DeleteServer::run( + serverId: $server->id, + cloudProviderTokenId: $this->vultrToken->id, + teamId: $this->team->id, + deleteFromVultr: true, + vultrInstanceId: 'instance-1' + ))->toThrow(RequestException::class, 'status code 500'); + + expect(Server::withTrashed()->find($server->id))->not->toBeNull(); +}); + +it('retains the server when no Vultr token can delete the instance', function () { + Http::preventStrayRequests(); + + $this->vultrToken->delete(); + + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => null, + 'vultr_instance_id' => 'instance-1', + ]); + $server->delete(); + + expect(fn () => DeleteServer::run( + serverId: $server->id, + teamId: $this->team->id, + deleteFromVultr: true, + vultrInstanceId: 'instance-1' + ))->toThrow(RuntimeException::class, 'No Vultr token found'); + + expect(Server::withTrashed()->find($server->id))->not->toBeNull(); +}); From 47a292486cc6a6c8cd0d55393cec3435ac1fb5fd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:54:31 +0200 Subject: [PATCH 06/15] chore(deps): bump NGINX package to 1.31.2-r1 --- docker/development/Dockerfile | 2 +- docker/production/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index 8fc46e32d..a5e5a7a3c 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -9,7 +9,7 @@ ARG CLOUDFLARED_VERSION=2025.7.0 # Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer= ARG POSTGRES_VERSION=18 # https://nginx.org/en/linux_packages.html -ARG NGINX_VERSION=1.31.0-r1 +ARG NGINX_VERSION=1.31.2-r1 # ================================================================= # Get MinIO client diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 0f849785e..34e4b6789 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -9,7 +9,7 @@ ARG CLOUDFLARED_VERSION=2025.7.0 # Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer= ARG POSTGRES_VERSION=18 # https://nginx.org/en/linux_packages.html -ARG NGINX_VERSION=1.31.0-r1 +ARG NGINX_VERSION=1.31.2-r1 # Add user/group ARG USER_ID=9999 From 99e255a572f8c1c3e6a99637bdff498da7d2cefa Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:54:31 +0200 Subject: [PATCH 07/15] fix(deployment): detect application configuration changes consistently Expand configuration snapshots, handle defaults from older snapshots, and refresh configuration state after Livewire setting changes. --- app/Livewire/Project/Application/Advanced.php | 1 + app/Livewire/Project/Application/General.php | 1 + app/Livewire/Project/Application/Source.php | 1 + app/Livewire/Project/Application/Swarm.php | 2 + .../Shared/EnvironmentVariable/All.php | 1 + app/Livewire/Project/Shared/HealthChecks.php | 3 + .../ApplicationConfigurationSnapshot.php | 36 +++- .../ConfigurationDiffer.php | 30 +++ templates/service-templates-latest.json | 8 +- templates/service-templates.json | 8 +- .../ApplicationSourceCrossTeamTest.php | 43 ++++ .../AdvancedStopGracePeriodTest.php | 5 +- .../ConfigurationChangeRefreshEventsTest.php | 44 +++++ .../SwarmConfigurationChangedTest.php | 36 ++++ .../Livewire/RailpackLivewireUiTest.php | 20 ++ .../ApplicationConfigurationSnapshotTest.php | 187 ++++++++++++++++++ 16 files changed, 415 insertions(+), 11 deletions(-) create mode 100644 tests/Feature/Livewire/Project/Application/ConfigurationChangeRefreshEventsTest.php create mode 100644 tests/Feature/Livewire/Project/Application/SwarmConfigurationChangedTest.php diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index f62f8bfdd..bf84f385d 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -286,6 +286,7 @@ class Advanced extends Component $this->application->settings->save(); $this->dispatch('success', 'Stop grace period updated.'); + $this->dispatch('configurationChanged'); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 289c8eeb0..3e5a51944 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -489,6 +489,7 @@ class General extends Component if ($this->isContainerLabelReadonlyEnabled) { $this->resetDefaultLabels(false); } + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 3ee5919fe..fe6a6397a 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -147,6 +147,7 @@ class Source extends Component 'source_id' => $source->id, 'source_type' => $sourceType, ]); + $this->dispatch('configurationChanged'); ['repository' => $customRepository] = $this->application->customRepository(); $repository = githubApi($this->application->source, "repos/{$customRepository}"); diff --git a/app/Livewire/Project/Application/Swarm.php b/app/Livewire/Project/Application/Swarm.php index 94d627e67..661578fb3 100644 --- a/app/Livewire/Project/Application/Swarm.php +++ b/app/Livewire/Project/Application/Swarm.php @@ -57,6 +57,7 @@ class Swarm extends Component $this->authorize('update', $this->application); $this->syncData(true); $this->dispatch('success', 'Swarm settings updated.'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -68,6 +69,7 @@ class Swarm extends Component $this->authorize('update', $this->application); $this->syncData(true); $this->dispatch('success', 'Swarm settings updated.'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index b45d9aba3..bac4546ce 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -76,6 +76,7 @@ class All extends Component $this->resource->settings->save(); $this->getDevView(); $this->dispatch('success', 'Environment variable settings updated.'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 5fa62b04e..cb60a3f39 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -152,6 +152,7 @@ class HealthChecks extends Component $this->resource->custom_healthcheck_found = $this->customHealthcheckFound; $this->resource->save(); $this->dispatch('success', 'Health check updated.'); + $this->dispatch('configurationChanged'); } public function submit() @@ -178,6 +179,7 @@ class HealthChecks extends Component $this->resource->custom_healthcheck_found = $this->customHealthcheckFound; $this->resource->save(); $this->dispatch('success', 'Health check updated.'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -213,6 +215,7 @@ class HealthChecks extends Component } else { $this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.'); } + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 365708758..aeb40364a 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -102,6 +102,8 @@ class ApplicationConfigurationSnapshot $this->item('git_repository', 'Repository', $this->application->git_repository, 'build'), $this->item('git_branch', 'Branch', $this->application->git_branch, 'build'), $this->item('git_commit_sha', 'Commit SHA', $this->application->git_commit_sha, 'build'), + $this->item('source_id', 'Source ID', $this->application->source_id, 'build'), + $this->item('source_type', 'Source type', $this->application->source_type, 'build'), $this->item('private_key_id', 'Private key', $this->application->private_key_id, 'build'), ]; } @@ -113,6 +115,8 @@ class ApplicationConfigurationSnapshot { return [ $this->item('build_pack', 'Build pack', $this->application->build_pack, 'build'), + $this->item('is_static', 'Static site', data_get($this->application, 'settings.is_static'), 'build'), + $this->item('is_spa', 'Single-page application', data_get($this->application, 'settings.is_spa'), 'build'), $this->item('static_image', 'Static image', $this->application->static_image, 'build'), $this->item('base_directory', 'Base directory', $this->application->base_directory, 'build'), $this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'), @@ -127,7 +131,11 @@ class ApplicationConfigurationSnapshot // so comparing it would flag a permanent change for git-based compose apps. $this->item('docker_compose_raw', 'Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw), displayFull: $this->application->docker_compose_raw, diffMode: 'lines'), $this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'), - $this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'), + $this->item('is_git_submodules_enabled', 'Git submodules', data_get($this->application, 'settings.is_git_submodules_enabled'), 'build'), + $this->item('is_git_lfs_enabled', 'Git LFS', data_get($this->application, 'settings.is_git_lfs_enabled'), 'build'), + $this->item('is_git_shallow_clone_enabled', 'Shallow clone', data_get($this->application, 'settings.is_git_shallow_clone_enabled'), 'build'), + $this->item('is_env_sorting_enabled', 'Sort environment variables', data_get($this->application, 'settings.is_env_sorting_enabled'), 'build'), + $this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'redeploy'), $this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'), $this->item('inject_build_args_to_dockerfile', 'Inject build args to Dockerfile', data_get($this->application, 'settings.inject_build_args_to_dockerfile'), 'build'), $this->item('include_source_commit_in_build', 'Include source commit in build', data_get($this->application, 'settings.include_source_commit_in_build'), 'build'), @@ -142,13 +150,26 @@ class ApplicationConfigurationSnapshot private function runtimeItems(): array { return [ + $this->item('docker_registry_image_name', 'Docker image', $this->application->docker_registry_image_name, 'redeploy'), + $this->item('docker_registry_image_tag', 'Docker image tag or hash', $this->application->docker_registry_image_tag, 'redeploy'), $this->item('start_command', 'Start command', $this->application->start_command, 'redeploy'), + $this->item('pre_deployment_command', 'Pre-deployment command', $this->application->pre_deployment_command, 'redeploy'), + $this->item('pre_deployment_command_container', 'Pre-deployment command container', $this->application->pre_deployment_command_container, 'redeploy'), + $this->item('post_deployment_command', 'Post-deployment command', $this->application->post_deployment_command, 'redeploy'), + $this->item('post_deployment_command_container', 'Post-deployment command container', $this->application->post_deployment_command_container, 'redeploy'), $this->item('docker_compose_custom_start_command', 'Docker Compose custom start command', $this->application->docker_compose_custom_start_command, 'redeploy'), $this->item('ports_exposes', 'Exposed ports', $this->application->ports_exposes, 'redeploy'), $this->item('ports_mappings', 'Port mappings', $this->application->ports_mappings, 'redeploy'), $this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'), $this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'), $this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'), + $this->item('is_consistent_container_name_enabled', 'Consistent container name', data_get($this->application, 'settings.is_consistent_container_name_enabled'), 'redeploy'), + $this->item('is_container_label_escape_enabled', 'Escape container labels', data_get($this->application, 'settings.is_container_label_escape_enabled'), 'redeploy'), + $this->item('is_container_label_readonly_enabled', 'Read-only container labels', data_get($this->application, 'settings.is_container_label_readonly_enabled'), 'redeploy'), + $this->item('is_log_drain_enabled', 'Log drain', data_get($this->application, 'settings.is_log_drain_enabled'), 'redeploy'), + $this->item('is_swarm_only_worker_nodes', 'Swarm worker nodes only', data_get($this->application, 'settings.is_swarm_only_worker_nodes'), 'redeploy'), + $this->item('stop_grace_period', 'Stop grace period', $this->normalizedStopGracePeriod(), 'redeploy'), + $this->item('is_preserve_repository_enabled', 'Preserve repository', data_get($this->application, 'settings.is_preserve_repository_enabled'), 'redeploy'), $this->item('is_raw_compose_deployment_enabled', 'Raw Compose deployment', data_get($this->application, 'settings.is_raw_compose_deployment_enabled'), 'redeploy'), $this->item('is_gpu_enabled', 'GPU enabled', data_get($this->application, 'settings.is_gpu_enabled'), 'redeploy'), $this->item('gpu_driver', 'GPU driver', data_get($this->application, 'settings.gpu_driver'), 'redeploy'), @@ -170,7 +191,7 @@ class ApplicationConfigurationSnapshot $this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'), $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'), $this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'), - $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration), + $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'build', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration), $this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'), $this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'), $this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'), @@ -327,6 +348,17 @@ class ApplicationConfigurationSnapshot return $flags ? "Hidden ({$flags})" : 'Hidden'; } + private function normalizedStopGracePeriod(): ?int + { + $stopGracePeriod = data_get($this->application, 'settings.stop_grace_period'); + + if ($stopGracePeriod === null || (int) $stopGracePeriod === DEFAULT_STOP_GRACE_PERIOD_SECONDS) { + return null; + } + + return (int) $stopGracePeriod; + } + private function environmentFlags(EnvironmentVariable $environmentVariable): string { return collect([ diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index e9707edbe..94c87b13c 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -17,6 +17,28 @@ class ConfigurationDiffer */ private const IGNORED_KEYS = ['build.docker_compose']; + /** + * Defaults for fields introduced after configuration snapshots were first + * stored. Older snapshots omitted these keys, which should not make an + * unchanged default look like a pending configuration change. + * + * @var array> + */ + private const INTRODUCED_DEFAULTS = [ + 'build.is_static' => false, + 'build.is_spa' => false, + 'build.is_git_submodules_enabled' => true, + 'build.is_git_lfs_enabled' => true, + 'build.is_git_shallow_clone_enabled' => true, + 'build.is_env_sorting_enabled' => [false, true], + 'runtime.is_consistent_container_name_enabled' => false, + 'runtime.is_container_label_escape_enabled' => true, + 'runtime.is_container_label_readonly_enabled' => true, + 'runtime.is_log_drain_enabled' => false, + 'runtime.is_swarm_only_worker_nodes' => true, + 'runtime.is_preserve_repository_enabled' => false, + ]; + /** * @param array $previousSnapshot * @param array $currentSnapshot @@ -36,6 +58,14 @@ class ConfigurationDiffer $previous = $previousItems[$key] ?? null; $current = $currentItems[$key] ?? null; + if ( + $previous === null + && array_key_exists($key, self::INTRODUCED_DEFAULTS) + && in_array((bool) data_get($current, 'compare_value'), (array) self::INTRODUCED_DEFAULTS[$key], true) + ) { + continue; + } + if (($previous['compare_value'] ?? null) === ($current['compare_value'] ?? null)) { continue; } diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 0a2dfda9d..884b3bcd6 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -53,7 +53,7 @@ "alexandrie": { "documentation": "https://github.com/Smaug6739/Alexandrie/tree/main/docs?utm_source=coolify.io", "slogan": "A powerful Markdown workspace designed for speed, clarity, and creativity.", - "compose": "c2VydmljZXM6CiAgZnJvbnRlbmQ6CiAgICBpbWFnZTogJ2doY3IuaW8vc21hdWc2NzM5L2FsZXhhbmRyaWUtZnJvbnRlbmQ6djguNy4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfRlJPTlRFTkRfODIwMAogICAgICAtIFBPUlQ9ODIwMAogICAgICAtICdOVVhUX1BVQkxJQ19DT05GSUdfRElTQUJMRV9TSUdOVVBfUEFHRT0ke0NPTkZJR19ESVNBQkxFX1NJR05VUDotZmFsc2V9JwogICAgICAtICdOVVhUX1BVQkxJQ19DT05GSUdfRElTQUJMRV9MQU5ESU5HX1BBR0U9JHtDT05GSUdfRElTQUJMRV9MQU5ESU5HOi1mYWxzZX0nCiAgICAgIC0gJ05VWFRfUFVCTElDX0JBU0VfQVBJPSR7U0VSVklDRV9VUkxfQkFDS0VORH0nCiAgICAgIC0gJ05VWFRfUFVCTElDX0JBU0VfQ0ROPSR7U0VSVklDRV9VUkxfUlVTVEZTfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQ0ROX0VORFBPSU5UPSR7Q0ROX0VORFBPSU5UOi0vYWxleGFuZHJpZS99JwogICAgICAtICdOVVhUX1BVQkxJQ19CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0ZST05URU5EfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYmFja2VuZAogIGJhY2tlbmQ6CiAgICBpbWFnZTogJ2doY3IuaW8vc21hdWc2NzM5L2FsZXhhbmRyaWUtYmFja2VuZDp2OC43LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CQUNLRU5EXzgyMDEKICAgICAgLSBCQUNLRU5EX1BPUlQ9ODIwMQogICAgICAtIEdJTl9NT0RFPXJlbGVhc2UKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnQ09PS0lFX0RPTUFJTj0ke1NFUlZJQ0VfVVJMX0ZST05URU5EfScKICAgICAgLSAnRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9VUkxfRlJPTlRFTkR9JwogICAgICAtICdBTExPV19VTlNFQ1VSRT0ke0FMTE9XX1VOU0VDVVJFOi1mYWxzZX0nCiAgICAgIC0gREFUQUJBU0VfSE9TVD1teXNxbAogICAgICAtIERBVEFCQVNFX1BPUlQ9MzMwNgogICAgICAtICdEQVRBQkFTRV9OQU1FPSR7TVlTUUxfREFUQUJBU0U6LWFsZXhhbmRyaWUtZGJ9JwogICAgICAtICdEQVRBQkFTRV9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnTUlOSU9fRU5EUE9JTlQ9cnVzdGZzOjkwMDAnCiAgICAgIC0gJ01JTklPX1BVQkxJQ19VUkw9JHtTRVJWSUNFX1VSTF9SVVNURlN9JwogICAgICAtICdNSU5JT19TRUNVUkU9JHtNSU5JT19TRUNVUkU6LWZhbHNlfScKICAgICAgLSAnTUlOSU9fQUNDRVNTS0VZPSR7U0VSVklDRV9VU0VSX1JVU1RGU30nCiAgICAgIC0gJ01JTklPX1NFQ1JFVEtFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUlVTVEZTfScKICAgICAgLSAnTUlOSU9fQlVDS0VUPSR7TUlOSU9fQlVDS0VUOi1hbGV4YW5kcmllfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUOi19JwogICAgICAtICdTTVRQX01BSUw9JHtTTVRQX01BSUw6LX0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtTTVRQX1BBU1NXT1JEOi19JwogICAgZGVwZW5kc19vbjoKICAgICAgbXlzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcnVzdGZzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbXlzcWw6CiAgICBpbWFnZTogJ215c3FsOjguMCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotYWxleGFuZHJpZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdteXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIGxvY2FsaG9zdAogICAgICAgIC0gJy11JwogICAgICAgIC0gcm9vdAogICAgICAgIC0gJy1wJHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgcmV0cmllczogNQogIHJ1c3RmczoKICAgIGltYWdlOiAncnVzdGZzL3J1c3RmczoxLjAuMC1hbHBoYS45MCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1JVU1RGU185MDAwCiAgICAgIC0gJ1JVU1RGU19BQ0NFU1NfS0VZPSR7U0VSVklDRV9VU0VSX1JVU1RGU30nCiAgICAgIC0gJ1JVU1RGU19TRUNSRVRfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdSVVNURlNfQ09OU09MRV9FTkFCTEU9JHtSVVNURlNfQ09OU09MRV9FTkFCTEU6LWZhbHNlfScKICAgICAgLSAnUlVTVEZTX0xPR19MRVZFTD0ke1JVU1RGU19MT0dfTEVWRUw6LWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAncnVzdGZzLWRhdGE6L2RhdGEnCiAgICAgIC0gJ3J1c3Rmcy1sb2dzOi9sb2dzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiBsb2NhbGhvc3QgOTAwMCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", + "compose": "c2VydmljZXM6CiAgZnJvbnRlbmQ6CiAgICBpbWFnZTogJ2doY3IuaW8vc21hdWc2NzM5L2FsZXhhbmRyaWUtZnJvbnRlbmQ6djguMTAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0ZST05URU5EXzgyMDAKICAgICAgLSBQT1JUPTgyMDAKICAgICAgLSAnTlVYVF9QVUJMSUNfQ09ORklHX0RJU0FCTEVfU0lHTlVQX1BBR0U9JHtDT05GSUdfRElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQ09ORklHX0RJU0FCTEVfTEFORElOR19QQUdFPSR7Q09ORklHX0RJU0FCTEVfTEFORElORzotZmFsc2V9JwogICAgICAtICdOVVhUX1BVQkxJQ19CQVNFX0FQST0ke1NFUlZJQ0VfVVJMX0JBQ0tFTkR9JwogICAgICAtICdOVVhUX1BVQkxJQ19CQVNFX0NETj0ke1NFUlZJQ0VfVVJMX1JVU1RGU30nCiAgICAgIC0gJ05VWFRfUFVCTElDX0NETl9FTkRQT0lOVD0ke0NETl9FTkRQT0lOVDotL2FsZXhhbmRyaWUvfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQkFTRV9VUkw9JHtTRVJWSUNFX1VSTF9GUk9OVEVORH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGJhY2tlbmQKICBiYWNrZW5kOgogICAgaW1hZ2U6ICdnaGNyLmlvL3NtYXVnNjczOS9hbGV4YW5kcmllLWJhY2tlbmQ6djguMTAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0JBQ0tFTkRfODIwMQogICAgICAtIEJBQ0tFTkRfUE9SVD04MjAxCiAgICAgIC0gR0lOX01PREU9cmVsZWFzZQogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdDT09LSUVfRE9NQUlOPSR7U0VSVklDRV9VUkxfRlJPTlRFTkR9JwogICAgICAtICdGUk9OVEVORF9VUkw9JHtTRVJWSUNFX1VSTF9GUk9OVEVORH0nCiAgICAgIC0gJ0FMTE9XX1VOU0VDVVJFPSR7QUxMT1dfVU5TRUNVUkU6LWZhbHNlfScKICAgICAgLSBEQVRBQkFTRV9IT1NUPW15c3FsCiAgICAgIC0gREFUQUJBU0VfUE9SVD0zMzA2CiAgICAgIC0gJ0RBVEFCQVNFX05BTUU9JHtNWVNRTF9EQVRBQkFTRTotYWxleGFuZHJpZS1kYn0nCiAgICAgIC0gJ0RBVEFCQVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdEQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNSU5JT19FTkRQT0lOVD1ydXN0ZnM6OTAwMCcKICAgICAgLSAnTUlOSU9fUFVCTElDX1VSTD0ke1NFUlZJQ0VfVVJMX1JVU1RGU30nCiAgICAgIC0gJ01JTklPX1NFQ1VSRT0ke01JTklPX1NFQ1VSRTotZmFsc2V9JwogICAgICAtICdNSU5JT19BQ0NFU1NLRVk9JHtTRVJWSUNFX1VTRVJfUlVTVEZTfScKICAgICAgLSAnTUlOSU9fU0VDUkVUS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdNSU5JT19CVUNLRVQ9JHtNSU5JT19CVUNLRVQ6LWFsZXhhbmRyaWV9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1Q6LX0nCiAgICAgIC0gJ1NNVFBfTUFJTD0ke1NNVFBfTUFJTDotfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkQ6LX0nCiAgICBkZXBlbmRzX29uOgogICAgICBteXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBydXN0ZnM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWw6OC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1hbGV4YW5kcmllLWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnLXUnCiAgICAgICAgLSByb290CiAgICAgICAgLSAnLXAke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICByZXRyaWVzOiA1CiAgcnVzdGZzOgogICAgaW1hZ2U6ICdydXN0ZnMvcnVzdGZzOjEuMC4wLWJldGEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1JVU1RGU185MDAwCiAgICAgIC0gJ1JVU1RGU19BQ0NFU1NfS0VZPSR7U0VSVklDRV9VU0VSX1JVU1RGU30nCiAgICAgIC0gJ1JVU1RGU19TRUNSRVRfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdSVVNURlNfQ09OU09MRV9FTkFCTEU9JHtSVVNURlNfQ09OU09MRV9FTkFCTEU6LWZhbHNlfScKICAgICAgLSAnUlVTVEZTX0xPR19MRVZFTD0ke1JVU1RGU19MT0dfTEVWRUw6LWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAncnVzdGZzLWRhdGE6L2RhdGEnCiAgICAgIC0gJ3J1c3Rmcy1sb2dzOi9sb2dzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiBsb2NhbGhvc3QgOTAwMCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", "tags": [ "note-taking", "markdown", @@ -64,7 +64,7 @@ "category": "productivity", "logo": "svgs/alexandrie.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-05T13:36:24+02:00", + "template_last_updated_at": "2026-07-07T13:24:35+02:00", "port": "8200" }, "anythingllm": { @@ -1310,7 +1310,7 @@ "espocrm": { "documentation": "https://docs.espocrm.com?utm_source=coolify.io", "slogan": "EspoCRM is a free and open-source CRM platform.", - "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fVVNFUk5BTUU9JHtFU1BPQ1JNX0FETUlOX1VTRVJOQU1FOi1hZG1pbn0nCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX1BMQVRGT1JNPU15c3FsCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9IT1NUPWVzcG9jcm0tZGIKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9OQU1FPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fU0lURV9VUkw9JHtTRVJWSUNFX1VSTF9FU1BPQ1JNfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHN0YXJ0X3BlcmlvZDogNjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGFlbW9uOgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06OScKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRhZW1vbgogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItZGFlbW9uLnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS13ZWJzb2NrZXQ6CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0td2Vic29ja2V0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVAogICAgICAtICdFU1BPQ1JNX0NPTkZJR19XRUJfU09DS0VUX1pFUk9fTV9RX1NVQlNDUklCRVJfRFNOPXRjcDovLyo6Nzc3NycKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJNSVNTSU9OX0RTTj10Y3A6Ly9lc3BvY3JtLXdlYnNvY2tldDo3Nzc3JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItd2Vic29ja2V0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm0tZGI6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK", + "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjEwJwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0KICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0VTUE9DUk0KICAgICAgLSAnRVNQT0NSTV9BRE1JTl9VU0VSTkFNRT0ke0VTUE9DUk1fQURNSU5fVVNFUk5BTUU6LWFkbWlufScKICAgICAgLSAnRVNQT0NSTV9BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgICAtIEVTUE9DUk1fREFUQUJBU0VfUExBVEZPUk09TXlzcWwKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX0hPU1Q9ZXNwb2NybS1kYgogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX05BTUU9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9TSVRFX1VSTD0ke1NFUlZJQ0VfVVJMX0VTUE9DUk19JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybS1kYXRhOi92YXIvd3d3L2h0bWwvZGF0YScKICAgICAgLSAnZXNwb2NybS1jdXN0b206L3Zhci93d3cvaHRtbC9jdXN0b20nCiAgICAgIC0gJ2VzcG9jcm0tY3VzdG9tLWNsaWVudDovdmFyL3d3dy9odG1sL2NsaWVudC9jdXN0b20nCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJpbi9jb21tYW5kCiAgICAgICAgLSBhcHAtY2hlY2sKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGVzcG9jcm0tZGFlbW9uOgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06MTAnCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS1kYWVtb24KICAgIHZvbHVtZXNfZnJvbToKICAgICAgLSBlc3BvY3JtCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZW50cnlwb2ludDogZG9ja2VyLWRhZW1vbi5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJpbi9jb21tYW5kCiAgICAgICAgLSBhcHAtY2hlY2sKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMKICAgICAgaW50ZXJ2YWw6IDE4MHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMKICBlc3BvY3JtLXdlYnNvY2tldDoKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjEwJwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0td2Vic29ja2V0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVAogICAgICAtICdFU1BPQ1JNX0NPTkZJR19XRUJfU09DS0VUX1pFUk9fTV9RX1NVQlNDUklCRVJfRFNOPXRjcDovLyo6Nzc3NycKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJNSVNTSU9OX0RTTj10Y3A6Ly9lc3BvY3JtLXdlYnNvY2tldDo3Nzc3JwogICAgdm9sdW1lc19mcm9tOgogICAgICAtIGVzcG9jcm0KICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnRyeXBvaW50OiBkb2NrZXItd2Vic29ja2V0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYmluL2NvbW1hbmQKICAgICAgICAtIGFwcC1jaGVjawogICAgICBzdGFydF9wZXJpb2Q6IDIwcwogICAgICBpbnRlcnZhbDogMTgwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGVzcG9jcm0tZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTIuMycKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUFSSUFEQl9EQVRBQkFTRT0ke01BUklBREJfREFUQUJBU0U6LWVzcG9jcm19JwogICAgICAtICdNQVJJQURCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybS1kYjovdmFyL2xpYi9teXNxbCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK", "tags": [ "crm", "self-hosted", @@ -1322,7 +1322,7 @@ "category": "productivity", "logo": "svgs/espocrm.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-07-03T15:15:43+03:00", "port": "80" }, "evolution-api": { diff --git a/templates/service-templates.json b/templates/service-templates.json index 4d97d8d5e..f12516789 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -53,7 +53,7 @@ "alexandrie": { "documentation": "https://github.com/Smaug6739/Alexandrie/tree/main/docs?utm_source=coolify.io", "slogan": "A powerful Markdown workspace designed for speed, clarity, and creativity.", - "compose": "c2VydmljZXM6CiAgZnJvbnRlbmQ6CiAgICBpbWFnZTogJ2doY3IuaW8vc21hdWc2NzM5L2FsZXhhbmRyaWUtZnJvbnRlbmQ6djguNy4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0ZST05URU5EXzgyMDAKICAgICAgLSBQT1JUPTgyMDAKICAgICAgLSAnTlVYVF9QVUJMSUNfQ09ORklHX0RJU0FCTEVfU0lHTlVQX1BBR0U9JHtDT05GSUdfRElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQ09ORklHX0RJU0FCTEVfTEFORElOR19QQUdFPSR7Q09ORklHX0RJU0FCTEVfTEFORElORzotZmFsc2V9JwogICAgICAtICdOVVhUX1BVQkxJQ19CQVNFX0FQST0ke1NFUlZJQ0VfRlFETl9CQUNLRU5EfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQkFTRV9DRE49JHtTRVJWSUNFX0ZRRE5fUlVTVEZTfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQ0ROX0VORFBPSU5UPSR7Q0ROX0VORFBPSU5UOi0vYWxleGFuZHJpZS99JwogICAgICAtICdOVVhUX1BVQkxJQ19CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9GUk9OVEVORH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGJhY2tlbmQKICBiYWNrZW5kOgogICAgaW1hZ2U6ICdnaGNyLmlvL3NtYXVnNjczOS9hbGV4YW5kcmllLWJhY2tlbmQ6djguNy4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0JBQ0tFTkRfODIwMQogICAgICAtIEJBQ0tFTkRfUE9SVD04MjAxCiAgICAgIC0gR0lOX01PREU9cmVsZWFzZQogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdDT09LSUVfRE9NQUlOPSR7U0VSVklDRV9GUUROX0ZST05URU5EfScKICAgICAgLSAnRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9GUUROX0ZST05URU5EfScKICAgICAgLSAnQUxMT1dfVU5TRUNVUkU9JHtBTExPV19VTlNFQ1VSRTotZmFsc2V9JwogICAgICAtIERBVEFCQVNFX0hPU1Q9bXlzcWwKICAgICAgLSBEQVRBQkFTRV9QT1JUPTMzMDYKICAgICAgLSAnREFUQUJBU0VfTkFNRT0ke01ZU1FMX0RBVEFCQVNFOi1hbGV4YW5kcmllLWRifScKICAgICAgLSAnREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ0RBVEFCQVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ01JTklPX0VORFBPSU5UPXJ1c3Rmczo5MDAwJwogICAgICAtICdNSU5JT19QVUJMSUNfVVJMPSR7U0VSVklDRV9GUUROX1JVU1RGU30nCiAgICAgIC0gJ01JTklPX1NFQ1VSRT0ke01JTklPX1NFQ1VSRTotZmFsc2V9JwogICAgICAtICdNSU5JT19BQ0NFU1NLRVk9JHtTRVJWSUNFX1VTRVJfUlVTVEZTfScKICAgICAgLSAnTUlOSU9fU0VDUkVUS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdNSU5JT19CVUNLRVQ9JHtNSU5JT19CVUNLRVQ6LWFsZXhhbmRyaWV9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1Q6LX0nCiAgICAgIC0gJ1NNVFBfTUFJTD0ke1NNVFBfTUFJTDotfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkQ6LX0nCiAgICBkZXBlbmRzX29uOgogICAgICBteXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBydXN0ZnM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWw6OC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1hbGV4YW5kcmllLWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnLXUnCiAgICAgICAgLSByb290CiAgICAgICAgLSAnLXAke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICByZXRyaWVzOiA1CiAgcnVzdGZzOgogICAgaW1hZ2U6ICdydXN0ZnMvcnVzdGZzOjEuMC4wLWFscGhhLjkwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JVU1RGU185MDAwCiAgICAgIC0gJ1JVU1RGU19BQ0NFU1NfS0VZPSR7U0VSVklDRV9VU0VSX1JVU1RGU30nCiAgICAgIC0gJ1JVU1RGU19TRUNSRVRfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdSVVNURlNfQ09OU09MRV9FTkFCTEU9JHtSVVNURlNfQ09OU09MRV9FTkFCTEU6LWZhbHNlfScKICAgICAgLSAnUlVTVEZTX0xPR19MRVZFTD0ke1JVU1RGU19MT0dfTEVWRUw6LWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAncnVzdGZzLWRhdGE6L2RhdGEnCiAgICAgIC0gJ3J1c3Rmcy1sb2dzOi9sb2dzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiBsb2NhbGhvc3QgOTAwMCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", + "compose": "c2VydmljZXM6CiAgZnJvbnRlbmQ6CiAgICBpbWFnZTogJ2doY3IuaW8vc21hdWc2NzM5L2FsZXhhbmRyaWUtZnJvbnRlbmQ6djguMTAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9GUk9OVEVORF84MjAwCiAgICAgIC0gUE9SVD04MjAwCiAgICAgIC0gJ05VWFRfUFVCTElDX0NPTkZJR19ESVNBQkxFX1NJR05VUF9QQUdFPSR7Q09ORklHX0RJU0FCTEVfU0lHTlVQOi1mYWxzZX0nCiAgICAgIC0gJ05VWFRfUFVCTElDX0NPTkZJR19ESVNBQkxFX0xBTkRJTkdfUEFHRT0ke0NPTkZJR19ESVNBQkxFX0xBTkRJTkc6LWZhbHNlfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQkFTRV9BUEk9JHtTRVJWSUNFX0ZRRE5fQkFDS0VORH0nCiAgICAgIC0gJ05VWFRfUFVCTElDX0JBU0VfQ0ROPSR7U0VSVklDRV9GUUROX1JVU1RGU30nCiAgICAgIC0gJ05VWFRfUFVCTElDX0NETl9FTkRQT0lOVD0ke0NETl9FTkRQT0lOVDotL2FsZXhhbmRyaWUvfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fRlJPTlRFTkR9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBiYWNrZW5kCiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9zbWF1ZzY3MzkvYWxleGFuZHJpZS1iYWNrZW5kOnY4LjEwLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkFDS0VORF84MjAxCiAgICAgIC0gQkFDS0VORF9QT1JUPTgyMDEKICAgICAgLSBHSU5fTU9ERT1yZWxlYXNlCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0NPT0tJRV9ET01BSU49JHtTRVJWSUNFX0ZRRE5fRlJPTlRFTkR9JwogICAgICAtICdGUk9OVEVORF9VUkw9JHtTRVJWSUNFX0ZRRE5fRlJPTlRFTkR9JwogICAgICAtICdBTExPV19VTlNFQ1VSRT0ke0FMTE9XX1VOU0VDVVJFOi1mYWxzZX0nCiAgICAgIC0gREFUQUJBU0VfSE9TVD1teXNxbAogICAgICAtIERBVEFCQVNFX1BPUlQ9MzMwNgogICAgICAtICdEQVRBQkFTRV9OQU1FPSR7TVlTUUxfREFUQUJBU0U6LWFsZXhhbmRyaWUtZGJ9JwogICAgICAtICdEQVRBQkFTRV9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnTUlOSU9fRU5EUE9JTlQ9cnVzdGZzOjkwMDAnCiAgICAgIC0gJ01JTklPX1BVQkxJQ19VUkw9JHtTRVJWSUNFX0ZRRE5fUlVTVEZTfScKICAgICAgLSAnTUlOSU9fU0VDVVJFPSR7TUlOSU9fU0VDVVJFOi1mYWxzZX0nCiAgICAgIC0gJ01JTklPX0FDQ0VTU0tFWT0ke1NFUlZJQ0VfVVNFUl9SVVNURlN9JwogICAgICAtICdNSU5JT19TRUNSRVRLRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1JVU1RGU30nCiAgICAgIC0gJ01JTklPX0JVQ0tFVD0ke01JTklPX0JVQ0tFVDotYWxleGFuZHJpZX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVDotfScKICAgICAgLSAnU01UUF9NQUlMPSR7U01UUF9NQUlMOi19JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7U01UUF9QQVNTV09SRDotfScKICAgIGRlcGVuZHNfb246CiAgICAgIG15c3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJ1c3RmczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LWFsZXhhbmRyaWUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICctdScKICAgICAgICAtIHJvb3QKICAgICAgICAtICctcCR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHJldHJpZXM6IDUKICBydXN0ZnM6CiAgICBpbWFnZTogJ3J1c3Rmcy9ydXN0ZnM6MS4wLjAtYmV0YS44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JVU1RGU185MDAwCiAgICAgIC0gJ1JVU1RGU19BQ0NFU1NfS0VZPSR7U0VSVklDRV9VU0VSX1JVU1RGU30nCiAgICAgIC0gJ1JVU1RGU19TRUNSRVRfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdSVVNURlNfQ09OU09MRV9FTkFCTEU9JHtSVVNURlNfQ09OU09MRV9FTkFCTEU6LWZhbHNlfScKICAgICAgLSAnUlVTVEZTX0xPR19MRVZFTD0ke1JVU1RGU19MT0dfTEVWRUw6LWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAncnVzdGZzLWRhdGE6L2RhdGEnCiAgICAgIC0gJ3J1c3Rmcy1sb2dzOi9sb2dzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiBsb2NhbGhvc3QgOTAwMCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", "tags": [ "note-taking", "markdown", @@ -64,7 +64,7 @@ "category": "productivity", "logo": "svgs/alexandrie.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-05T13:36:24+02:00", + "template_last_updated_at": "2026-07-07T13:24:35+02:00", "port": "8200" }, "anythingllm": { @@ -1310,7 +1310,7 @@ "espocrm": { "documentation": "https://docs.espocrm.com?utm_source=coolify.io", "slogan": "EspoCRM is a free and open-source CRM platform.", - "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fRVNQT0NSTQogICAgICAtICdFU1BPQ1JNX0FETUlOX1VTRVJOQU1FPSR7RVNQT0NSTV9BRE1JTl9VU0VSTkFNRTotYWRtaW59JwogICAgICAtICdFU1BPQ1JNX0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9QTEFURk9STT1NeXNxbAogICAgICAtIEVTUE9DUk1fREFUQUJBU0VfSE9TVD1lc3BvY3JtLWRiCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfTkFNRT0ke01BUklBREJfREFUQUJBU0U6LWVzcG9jcm19JwogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdFU1BPQ1JNX1NJVEVfVVJMPSR7U0VSVklDRV9GUUROX0VTUE9DUk19JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgc3RhcnRfcGVyaW9kOiA2MHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYWVtb246CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0tZGFlbW9uCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtOi92YXIvd3d3L2h0bWwnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGVudHJ5cG9pbnQ6IGRvY2tlci1kYWVtb24uc2gKICAgIGRlcGVuZHNfb246CiAgICAgIGVzcG9jcm06CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBlc3BvY3JtLXdlYnNvY2tldDoKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS13ZWJzb2NrZXQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX0ZRRE5fRVNQT0NSTV9XRUJTT0NLRVQKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJTQ1JJQkVSX0RTTj10Y3A6Ly8qOjc3NzcnCiAgICAgIC0gJ0VTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfWkVST19NX1FfU1VCTUlTU0lPTl9EU049dGNwOi8vZXNwb2NybS13ZWJzb2NrZXQ6Nzc3NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIHJlc3RhcnQ6IGFsd2F5cwogICAgZW50cnlwb2ludDogZG9ja2VyLXdlYnNvY2tldC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNQVJJQURCX0RBVEFCQVNFPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ01BUklBREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtLWRiOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCg==", + "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjEwJwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0KICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fVVNFUk5BTUU9JHtFU1BPQ1JNX0FETUlOX1VTRVJOQU1FOi1hZG1pbn0nCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX1BMQVRGT1JNPU15c3FsCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9IT1NUPWVzcG9jcm0tZGIKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9OQU1FPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fU0lURV9VUkw9JHtTRVJWSUNFX0ZRRE5fRVNQT0NSTX0nCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtLWRhdGE6L3Zhci93d3cvaHRtbC9kYXRhJwogICAgICAtICdlc3BvY3JtLWN1c3RvbTovdmFyL3d3dy9odG1sL2N1c3RvbScKICAgICAgLSAnZXNwb2NybS1jdXN0b20tY2xpZW50Oi92YXIvd3d3L2h0bWwvY2xpZW50L2N1c3RvbScKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYmluL2NvbW1hbmQKICAgICAgICAtIGFwcC1jaGVjawogICAgICBzdGFydF9wZXJpb2Q6IDIwcwogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAzCiAgZXNwb2NybS1kYWVtb246CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybToxMCcKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRhZW1vbgogICAgdm9sdW1lc19mcm9tOgogICAgICAtIGVzcG9jcm0KICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnRyeXBvaW50OiBkb2NrZXItZGFlbW9uLnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYmluL2NvbW1hbmQKICAgICAgICAtIGFwcC1jaGVjawogICAgICBzdGFydF9wZXJpb2Q6IDIwcwogICAgICBpbnRlcnZhbDogMTgwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGVzcG9jcm0td2Vic29ja2V0OgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06MTAnCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS13ZWJzb2NrZXQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX0ZRRE5fRVNQT0NSTV9XRUJTT0NLRVQKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJTQ1JJQkVSX0RTTj10Y3A6Ly8qOjc3NzcnCiAgICAgIC0gJ0VTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfWkVST19NX1FfU1VCTUlTU0lPTl9EU049dGNwOi8vZXNwb2NybS13ZWJzb2NrZXQ6Nzc3NycKICAgIHZvbHVtZXNfZnJvbToKICAgICAgLSBlc3BvY3JtCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZW50cnlwb2ludDogZG9ja2VyLXdlYnNvY2tldC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJpbi9jb21tYW5kCiAgICAgICAgLSBhcHAtY2hlY2sKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMKICAgICAgaW50ZXJ2YWw6IDE4MHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMKICBlc3BvY3JtLWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjEyLjMnCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS1kYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm0tZGI6L3Zhci9saWIvbXlzcWwnCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCg==", "tags": [ "crm", "self-hosted", @@ -1322,7 +1322,7 @@ "category": "productivity", "logo": "svgs/espocrm.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-07-03T15:15:43+03:00", "port": "80" }, "evolution-api": { diff --git a/tests/Feature/ApplicationSourceCrossTeamTest.php b/tests/Feature/ApplicationSourceCrossTeamTest.php index fc6f920e3..6db2383be 100644 --- a/tests/Feature/ApplicationSourceCrossTeamTest.php +++ b/tests/Feature/ApplicationSourceCrossTeamTest.php @@ -11,6 +11,7 @@ use App\Models\Server; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Livewire; use Visus\Cuid2\Cuid2; @@ -117,6 +118,48 @@ test('changeSource rejects an arbitrary class as source_type', function () { expect($this->applicationA->source_type)->not->toBe(Server::class); }); +test('changeSource dispatches configuration changed for an owned source', function () { + Http::fake([ + 'https://api.github.com/repos/*' => Http::response(['id' => 123]), + ]); + $source = GithubApp::create([ + 'name' => 'own-github-app', + 'team_id' => $this->teamA->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => true, + ]); + $this->applicationA->update(['git_repository' => 'coollabsio/coolify']); + + Livewire::test(Source::class, ['application' => $this->applicationA->fresh()]) + ->call('changeSource', $source->id, GithubApp::class) + ->assertDispatched('configurationChanged'); +}); + +test('changeSource dispatches configuration changed when repository metadata lookup fails after persistence', function () { + Http::fake([ + 'https://api.github.com/repos/*' => Http::response( + ['message' => 'Unavailable'], + 503, + ['X-RateLimit-Reset' => now()->addMinute()->timestamp], + ), + ]); + $source = GithubApp::create([ + 'name' => 'own-unavailable-github-app', + 'team_id' => $this->teamA->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => true, + ]); + $this->applicationA->update(['git_repository' => 'coollabsio/coolify']); + + Livewire::test(Source::class, ['application' => $this->applicationA->fresh()]) + ->call('changeSource', $source->id, GithubApp::class) + ->assertDispatched('configurationChanged'); + + expect($this->applicationA->refresh()->source_id)->toBe($source->id); +}); + test('privateKeyId is locked so submit() cannot persist a client-supplied foreign id', function () { // Without #[Locked], an attacker could POST {"updates": {"privateKeyId": }, // "calls": [{"method": "submit"}]} and have syncData(true) write the foreign id through diff --git a/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php b/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php index 1bc179502..6dec7ebb9 100644 --- a/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php +++ b/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php @@ -16,6 +16,8 @@ uses(RefreshDatabase::class); function createApplicationForAdvancedStopGracePeriodTest(): Application { $team = Team::factory()->create(); + $team->members()->attach(auth()->id(), ['role' => 'owner']); + session(['currentTeam' => $team]); $server = Server::factory()->create(['team_id' => $team->id]); $project = Project::factory()->create(['team_id' => $team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]); @@ -43,7 +45,8 @@ it('saves a valid stop grace period', function () { ->set('stopGracePeriod', '300') ->call('saveStopGracePeriod') ->assertHasNoErrors() - ->assertDispatched('success'); + ->assertDispatched('success') + ->assertDispatched('configurationChanged'); expect($application->settings()->first()->stop_grace_period)->toBe(300); }); diff --git a/tests/Feature/Livewire/Project/Application/ConfigurationChangeRefreshEventsTest.php b/tests/Feature/Livewire/Project/Application/ConfigurationChangeRefreshEventsTest.php new file mode 100644 index 000000000..1cd60f6d4 --- /dev/null +++ b/tests/Feature/Livewire/Project/Application/ConfigurationChangeRefreshEventsTest.php @@ -0,0 +1,44 @@ + 0], []); + }); + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create(['environment_id' => $environment->id])->refresh(); +}); + +it('refreshes configuration changes after health check saves', function (string $method) { + Livewire::test(HealthChecks::class, ['resource' => $this->application]) + ->call($method) + ->assertHasNoErrors() + ->assertDispatched('configurationChanged'); +})->with(['instantSave', 'submit', 'toggleHealthcheck']); + +it('refreshes configuration changes after environment variable settings are saved', function () { + Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application]) + ->set('use_build_secrets', true) + ->call('instantSave') + ->assertHasNoErrors() + ->assertDispatched('configurationChanged'); +}); diff --git a/tests/Feature/Livewire/Project/Application/SwarmConfigurationChangedTest.php b/tests/Feature/Livewire/Project/Application/SwarmConfigurationChangedTest.php new file mode 100644 index 000000000..aa5f7737f --- /dev/null +++ b/tests/Feature/Livewire/Project/Application/SwarmConfigurationChangedTest.php @@ -0,0 +1,36 @@ +create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $server = Server::factory()->create(['team_id' => $team->id]); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $server->standaloneDockers()->firstOrFail()->id, + 'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(), + 'swarm_replicas' => 1, + ]); + + $this->actingAs($user); + + Livewire::test(Swarm::class, ['application' => $application]) + ->set('isSwarmOnlyWorkerNodes', false) + ->call($method) + ->assertHasNoErrors() + ->assertDispatched('configurationChanged'); +})->with(['instantSave', 'submit']); diff --git a/tests/Feature/Livewire/RailpackLivewireUiTest.php b/tests/Feature/Livewire/RailpackLivewireUiTest.php index b21037b11..b7ab7a5d5 100644 --- a/tests/Feature/Livewire/RailpackLivewireUiTest.php +++ b/tests/Feature/Livewire/RailpackLivewireUiTest.php @@ -118,4 +118,24 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ->assertSee('nixpacks.toml') ->assertDontSee('railpack.json'); }); + + test('saving general settings refreshes configuration changes when label reset is disabled', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'nixpacks', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + $application->settings->update(['is_container_label_readonly_enabled' => false]); + + Livewire::test(General::class, ['application' => $application->refresh()]) + ->set('isPreserveRepositoryEnabled', true) + ->call('instantSave') + ->assertHasNoErrors() + ->assertDispatched('configurationChanged'); + }); }); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index f717b9fae..a00c00b4b 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -6,6 +6,7 @@ use App\Models\Environment; use App\Models\EnvironmentVariable; use App\Models\Project; use App\Models\Team; +use App\Services\DeploymentConfiguration\ConfigurationDiffer; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Str; use Tests\TestCase; @@ -78,6 +79,192 @@ it('detects redeploy-only domain changes', function () { ->and($change['new_full_value'])->toBe($domains); }); +it('detects Docker image reference changes as redeploy-only changes', function (string $field, string $label, string $newValue) { + $application = snapshotTestApplication([ + 'build_pack' => 'dockerimage', + 'docker_registry_image_name' => 'ghcr.io/coollabsio/shoutrrr', + 'docker_registry_image_tag' => '1.3.0-rc.4', + ]); + markSnapshotTestApplicationDeployed($application); + + $application->update([$field => $newValue]); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + $change = collect($diff->changes())->firstWhere('label', $label); + + expect($diff->isChanged())->toBeTrue() + ->and($diff->requiresBuild())->toBeFalse() + ->and($change)->not->toBeNull() + ->and($change['old_display_value'])->not->toBe($change['new_display_value']) + ->and($change['new_display_value'])->toBe($newValue); +})->with([ + 'image name' => ['docker_registry_image_name', 'Docker image', 'ghcr.io/coollabsio/coolify'], + 'image tag' => ['docker_registry_image_tag', 'Docker image tag or hash', '1.3.0-rc.5'], +]); + +it('detects deployment hook changes as redeploy-only changes', function (string $field, string $label, string $newValue) { + $application = snapshotTestApplication(); + markSnapshotTestApplicationDeployed($application); + + $application->update([$field => $newValue]); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + + expect($diff->requiresBuild())->toBeFalse() + ->and(collect($diff->changes())->pluck('label'))->toContain($label); +})->with([ + 'pre-deployment command' => ['pre_deployment_command', 'Pre-deployment command', 'php artisan migrate --force'], + 'pre-deployment container' => ['pre_deployment_command_container', 'Pre-deployment command container', 'web'], + 'post-deployment command' => ['post_deployment_command', 'Post-deployment command', 'php artisan cache:clear'], + 'post-deployment container' => ['post_deployment_command_container', 'Post-deployment command container', 'worker'], +]); + +it('detects source integration changes as build changes', function (string $field, string $label, mixed $newValue) { + $application = snapshotTestApplication([ + 'source_id' => 1, + 'source_type' => 'App\\Models\\GithubApp', + ]); + markSnapshotTestApplicationDeployed($application); + + $application->forceFill([$field => $newValue])->save(); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + + expect($diff->requiresBuild())->toBeTrue() + ->and(collect($diff->changes())->pluck('label'))->toContain($label); +})->with([ + 'source ID' => ['source_id', 'Source ID', 2], + 'source type' => ['source_type', 'Source type', 'App\\Models\\GitlabApp'], +]); + +it('detects build-affecting application settings', function (string $field, string $label) { + $application = snapshotTestApplication(); + $application->settings->update([$field => false]); + markSnapshotTestApplicationDeployed($application); + + $application->settings->update([$field => true]); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + + expect($diff->requiresBuild())->toBeTrue() + ->and(collect($diff->changes())->pluck('label'))->toContain($label); +})->with([ + 'static site' => ['is_static', 'Static site'], + 'single-page application' => ['is_spa', 'Single-page application'], + 'Git submodules' => ['is_git_submodules_enabled', 'Git submodules'], + 'Git LFS' => ['is_git_lfs_enabled', 'Git LFS'], + 'shallow clone' => ['is_git_shallow_clone_enabled', 'Shallow clone'], + 'environment variable sorting' => ['is_env_sorting_enabled', 'Sort environment variables'], +]); + +it('detects runtime-affecting application settings as redeploy-only changes', function (string $field, string $label, mixed $newValue) { + $application = snapshotTestApplication(); + $application->settings->update([$field => is_bool($newValue) ? ! $newValue : null]); + markSnapshotTestApplicationDeployed($application); + + $application->settings->update([$field => $newValue]); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + + expect($diff->requiresBuild())->toBeFalse() + ->and(collect($diff->changes())->pluck('label'))->toContain($label); +})->with([ + 'consistent container name' => ['is_consistent_container_name_enabled', 'Consistent container name', true], + 'container label escaping' => ['is_container_label_escape_enabled', 'Escape container labels', false], + 'container labels read-only' => ['is_container_label_readonly_enabled', 'Read-only container labels', false], + 'log drain' => ['is_log_drain_enabled', 'Log drain', true], + 'Swarm worker nodes' => ['is_swarm_only_worker_nodes', 'Swarm worker nodes only', true], + 'stop grace period' => ['stop_grace_period', 'Stop grace period', 45], + 'preserve repository' => ['is_preserve_repository_enabled', 'Preserve repository', true], +]); + +it('classifies runtime options as redeploy-only and custom nginx configuration as build-affecting', function () { + $application = snapshotTestApplication(); + markSnapshotTestApplicationDeployed($application); + + $application->update(['custom_docker_run_options' => '--init']); + + expect($application->refresh()->pendingDeploymentConfigurationDiff()->requiresBuild())->toBeFalse(); + + markSnapshotTestApplicationDeployed($application->refresh()); + $application->update(['custom_nginx_configuration' => 'server { listen 80; }']); + + expect($application->refresh()->pendingDeploymentConfigurationDiff()->requiresBuild())->toBeTrue(); +}); + +it('keeps Docker run options compatible with older build-section snapshots', function () { + $application = snapshotTestApplication(['custom_docker_run_options' => '--init'])->refresh(); + $previousSnapshot = $application->deploymentConfigurationSnapshot(); + $buildItems = collect(data_get($previousSnapshot, 'sections.build.items')); + + expect($buildItems->pluck('key'))->toContain('custom_docker_run_options'); + + data_set( + $previousSnapshot, + 'sections.build.items', + $buildItems->map(function (array $item): array { + if ($item['key'] === 'custom_docker_run_options') { + $item['impact'] = 'build'; + } + + return $item; + })->all(), + ); + + expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $application->deploymentConfigurationSnapshot())->isChanged())->toBeFalse(); + + $application->update(['custom_docker_run_options' => '--rm']); + + expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $application->refresh()->deploymentConfigurationSnapshot())->requiresBuild())->toBeFalse(); +}); + +it('does not report newly tracked settings when an older snapshot omitted their default values', function () { + $application = snapshotTestApplication(); + $application->settings->update(['stop_grace_period' => DEFAULT_STOP_GRACE_PERIOD_SECONDS]); + $currentSnapshot = $application->deploymentConfigurationSnapshot(); + $introducedKeys = [ + 'is_static', + 'is_spa', + 'is_git_submodules_enabled', + 'is_git_lfs_enabled', + 'is_git_shallow_clone_enabled', + 'is_env_sorting_enabled', + 'is_consistent_container_name_enabled', + 'is_container_label_escape_enabled', + 'is_container_label_readonly_enabled', + 'is_log_drain_enabled', + 'is_swarm_only_worker_nodes', + 'is_preserve_repository_enabled', + 'stop_grace_period', + ]; + $previousSnapshot = $currentSnapshot; + + foreach (['build', 'runtime'] as $section) { + data_set( + $previousSnapshot, + "sections.{$section}.items", + collect(data_get($previousSnapshot, "sections.{$section}.items")) + ->reject(fn (array $item): bool => in_array($item['key'], $introducedKeys, true)) + ->values() + ->all(), + ); + } + + expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse(); +}); + +it('accepts the historical environment sorting default in older snapshots', function () { + $application = snapshotTestApplication(); + $application->settings->update(['is_env_sorting_enabled' => true]); + $currentSnapshot = $application->deploymentConfigurationSnapshot(); + $previousSnapshot = $currentSnapshot; + data_set( + $previousSnapshot, + 'sections.build.items', + collect(data_get($previousSnapshot, 'sections.build.items')) + ->reject(fn (array $item): bool => $item['key'] === 'is_env_sorting_enabled') + ->values() + ->all(), + ); + + expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse(); +}); + it('detects environment variable value changes without exposing secret values', function () { $application = snapshotTestApplication(); EnvironmentVariable::create([ From 34e6a6dd5d5bb2068941117f7d39039b0e13bcaa Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:44:45 +0200 Subject: [PATCH 08/15] feat(api): add application settings to application endpoints --- .../Api/ApplicationsController.php | 218 +++++++- app/Models/Application.php | 1 + app/Models/ApplicationSetting.php | 53 ++ bootstrap/helpers/api.php | 27 + openapi.json | 495 +++++++++++++++++- openapi.yaml | 372 ++++++++++++- .../ApplicationBuildSecretsSettingApiTest.php | 257 +++++++++ .../Api/ApplicationSettingsApiTest.php | 183 +++++++ 8 files changed, 1594 insertions(+), 12 deletions(-) create mode 100644 tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php create mode 100644 tests/Feature/Api/ApplicationSettingsApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 0f853642d..593207735 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -35,6 +35,36 @@ class ApplicationsController extends Controller { use Concerns\HandlesTagsApi; + private const APPLICATION_SETTING_FIELDS = [ + 'is_git_submodules_enabled', + 'is_git_lfs_enabled', + 'is_git_shallow_clone_enabled', + 'disable_build_cache', + 'inject_build_args_to_dockerfile', + 'include_source_commit_in_build', + 'is_env_sorting_enabled', + 'is_pr_deployments_public_enabled', + 'stop_grace_period', + 'docker_images_to_keep', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + 'is_raw_compose_deployment_enabled', + ]; + + private const BOOLEAN_APPLICATION_SETTING_FIELDS = [ + 'is_git_submodules_enabled', + 'is_git_lfs_enabled', + 'is_git_shallow_clone_enabled', + 'disable_build_cache', + 'inject_build_args_to_dockerfile', + 'include_source_commit_in_build', + 'is_env_sorting_enabled', + 'is_pr_deployments_public_enabled', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + 'is_raw_compose_deployment_enabled', + ]; + protected function findTaggableResource(string $uuid, int|string $teamId): mixed { return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); @@ -87,9 +117,48 @@ class ApplicationsController extends Controller $application->makeHidden(['value', 'real_value']); } + if ($application->relationLoaded('settings')) { + $application->settings?->makeHidden(['id', 'application_id', 'created_at', 'updated_at']); + } + return serializeApiResponse($application); } + private function applicationSettingsFromRequest(Request $request): array + { + $settings = []; + + foreach (self::APPLICATION_SETTING_FIELDS as $field) { + if (! array_key_exists($field, $request->all())) { + continue; + } + + $settings[$field] = in_array($field, self::BOOLEAN_APPLICATION_SETTING_FIELDS, true) + ? $request->boolean($field) + : $request->input($field); + } + + return $settings; + } + + private function applyApplicationSettings(Application $application, array $settings): void + { + if ($settings === []) { + return; + } + + $regenerateLabels = ! $application->wasRecentlyCreated + && $application->settings->is_container_label_readonly_enabled + && (array_key_exists('is_gzip_enabled', $settings) || array_key_exists('is_stripprefix_enabled', $settings)); + + $application->settings->fill($settings)->save(); + + if ($regenerateLabels) { + $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); + $application->save(); + } + } + /** * Expose sensitive fields on eager-loaded nested Server + ServerSetting * relations for callers with the `read:sensitive` or `root` token ability. @@ -285,6 +354,20 @@ class ApplicationsController extends Controller ], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -453,6 +536,20 @@ class ApplicationsController extends Controller ], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -621,6 +718,20 @@ class ApplicationsController extends Controller ], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -761,6 +872,20 @@ class ApplicationsController extends Controller 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -897,6 +1022,20 @@ class ApplicationsController extends Controller 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -981,7 +1120,7 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', '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', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', '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', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', ...self::APPLICATION_SETTING_FIELDS]; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1036,6 +1175,7 @@ class ApplicationsController extends Controller $instantDeploy = $request->instant_deploy; $githubAppUuid = $request->github_app_uuid; $useBuildServer = $request->use_build_server; + $useBuildSecrets = $request->use_build_secrets; $isStatic = $request->is_static; $isSpa = $request->is_spa; $isAutoDeployEnabled = $request->is_auto_deploy_enabled; @@ -1045,6 +1185,19 @@ class ApplicationsController extends Controller $customNginxConfiguration = $request->custom_nginx_configuration; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true); $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false); + $applicationSettings = $this->applicationSettingsFromRequest($request); + + $requestedBuildPack = in_array($type, ['public', 'private-gh-app', 'private-deploy-key'], true) + ? $request->input('build_pack') + : $type; + if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'is_raw_compose_deployment_enabled' => 'Raw compose deployment can only be enabled for Docker Compose applications.', + ], + ], 422); + } if (! is_null($customNginxConfiguration)) { if (! isBase64Encoded($customNginxConfiguration)) { @@ -1232,6 +1385,7 @@ class ApplicationsController extends Controller $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; $application->save(); + $this->applyApplicationSettings($application, $applicationSettings); if (isset($isStatic)) { $application->settings->is_static = $isStatic; $application->settings->save(); @@ -1260,6 +1414,10 @@ class ApplicationsController extends Controller $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isContainerLabelEscapeEnabled)) { $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); @@ -1478,6 +1636,7 @@ class ApplicationsController extends Controller $application->repository_project_id = $repository_project_id; $application->save(); + $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); // Auto-generate domain if requested and no custom domain provided if ($autogenerateDomain && blank($fqdn)) { @@ -1512,6 +1671,10 @@ class ApplicationsController extends Controller $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isContainerLabelEscapeEnabled)) { $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); @@ -1694,6 +1857,7 @@ class ApplicationsController extends Controller $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; $application->save(); + $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); // Auto-generate domain if requested and no custom domain provided if ($autogenerateDomain && blank($fqdn)) { @@ -1728,6 +1892,10 @@ class ApplicationsController extends Controller $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isContainerLabelEscapeEnabled)) { $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); @@ -1837,6 +2005,7 @@ class ApplicationsController extends Controller $application->git_repository = 'coollabsio/coolify'; $application->git_branch = 'main'; $application->save(); + $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); // Auto-generate domain if requested and no custom domain provided if ($autogenerateDomain && blank($fqdn)) { @@ -1859,6 +2028,10 @@ class ApplicationsController extends Controller $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isContainerLabelEscapeEnabled)) { $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); @@ -1963,6 +2136,7 @@ class ApplicationsController extends Controller $application->git_repository = 'coollabsio/coolify'; $application->git_branch = 'main'; $application->save(); + $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); // Auto-generate domain if requested and no custom domain provided if ($autogenerateDomain && blank($fqdn)) { @@ -1985,6 +2159,10 @@ class ApplicationsController extends Controller $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isContainerLabelEscapeEnabled)) { $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); @@ -2090,7 +2268,7 @@ class ApplicationsController extends Controller if (! $uuid) { return response()->json(['message' => 'UUID is required.'], 400); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->with('settings')->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } @@ -2405,11 +2583,24 @@ class ApplicationsController extends Controller ], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'], - 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include source commit information in the build. Default is false.'], ], ) ), @@ -2495,7 +2686,7 @@ class ApplicationsController extends Controller $this->authorize('update', $application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_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', 'is_preserve_repository_enabled', 'include_source_commit_in_build']; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_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', 'use_build_secrets', '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', 'is_preserve_repository_enabled', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ 'name' => 'string|max:255', @@ -2574,6 +2765,17 @@ class ApplicationsController extends Controller ], 422); } + $applicationSettings = $this->applicationSettingsFromRequest($request); + $requestedBuildPack = $request->input('build_pack', $application->build_pack); + if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'is_raw_compose_deployment_enabled' => 'Raw compose deployment can only be enabled for Docker Compose applications.', + ], + ], 422); + } + if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) { if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) { $validationErrors = []; @@ -2728,6 +2930,7 @@ class ApplicationsController extends Controller $isPreviewDeploymentsEnabled = $request->is_preview_deployments_enabled; $connectToDockerNetwork = $request->connect_to_docker_network; $useBuildServer = $request->use_build_server; + $useBuildSecrets = $request->use_build_secrets; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled'); $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled'); $includeSourceCommitInBuild = $request->boolean('include_source_commit_in_build'); @@ -2735,6 +2938,10 @@ class ApplicationsController extends Controller $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isStatic)) { $application->settings->is_static = $isStatic; @@ -2778,6 +2985,7 @@ class ApplicationsController extends Controller $application->settings->include_source_commit_in_build = $includeSourceCommitInBuild; $application->settings->save(); } + $this->applyApplicationSettings($application, $applicationSettings); removeUnnecessaryFieldsFromRequest($request); $data = $request->only($allowedFields); @@ -4023,7 +4231,7 @@ class ApplicationsController extends Controller ), ] )] - public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + public function move_by_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { diff --git a/app/Models/Application.php b/app/Models/Application.php index d46e4366b..ca247c8e8 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -111,6 +111,7 @@ use Symfony\Component\Yaml\Yaml; 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'settings' => new OA\Property(ref: '#/components/schemas/ApplicationSetting'), ] )] diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index ef09c0c48..91c38b879 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -4,7 +4,49 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Application settings.', + type: 'object', + properties: [ + 'is_static' => ['type' => 'boolean'], + 'is_git_submodules_enabled' => ['type' => 'boolean'], + 'is_git_lfs_enabled' => ['type' => 'boolean'], + 'is_auto_deploy_enabled' => ['type' => 'boolean'], + 'is_force_https_enabled' => ['type' => 'boolean'], + 'is_debug_enabled' => ['type' => 'boolean'], + 'is_preview_deployments_enabled' => ['type' => 'boolean'], + 'is_log_drain_enabled' => ['type' => 'boolean'], + 'is_gpu_enabled' => ['type' => 'boolean'], + 'gpu_driver' => ['type' => 'string', 'nullable' => true], + 'gpu_count' => ['type' => 'string', 'nullable' => true], + 'gpu_device_ids' => ['type' => 'string', 'nullable' => true], + 'gpu_options' => ['type' => 'string', 'nullable' => true], + 'is_include_timestamps' => ['type' => 'boolean'], + 'is_swarm_only_worker_nodes' => ['type' => 'boolean'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean'], + 'is_build_server_enabled' => ['type' => 'boolean'], + 'is_consistent_container_name_enabled' => ['type' => 'boolean'], + 'is_gzip_enabled' => ['type' => 'boolean'], + 'is_stripprefix_enabled' => ['type' => 'boolean'], + 'connect_to_docker_network' => ['type' => 'boolean'], + 'custom_internal_name' => ['type' => 'string', 'nullable' => true], + 'is_container_label_escape_enabled' => ['type' => 'boolean'], + 'is_env_sorting_enabled' => ['type' => 'boolean'], + 'is_container_label_readonly_enabled' => ['type' => 'boolean'], + 'is_preserve_repository_enabled' => ['type' => 'boolean'], + 'disable_build_cache' => ['type' => 'boolean'], + 'is_spa' => ['type' => 'boolean'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean'], + 'use_build_secrets' => ['type' => 'boolean'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean'], + 'include_source_commit_in_build' => ['type' => 'boolean'], + 'docker_images_to_keep' => ['type' => 'integer'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true], + ] +)] class ApplicationSetting extends Model { protected $casts = [ @@ -27,6 +69,17 @@ class ApplicationSetting extends Model 'is_git_shallow_clone_enabled' => 'boolean', 'docker_images_to_keep' => 'integer', 'stop_grace_period' => 'integer', + 'is_log_drain_enabled' => 'boolean', + 'is_gpu_enabled' => 'boolean', + 'is_include_timestamps' => 'boolean', + 'is_swarm_only_worker_nodes' => 'boolean', + 'is_raw_compose_deployment_enabled' => 'boolean', + 'is_consistent_container_name_enabled' => 'boolean', + 'is_gzip_enabled' => 'boolean', + 'is_stripprefix_enabled' => 'boolean', + 'connect_to_docker_network' => 'boolean', + 'is_env_sorting_enabled' => 'boolean', + 'disable_build_cache' => 'boolean', ]; protected $fillable = [ diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index e430e7364..e314ead82 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -117,6 +117,20 @@ function sharedDataApplications() 'is_auto_deploy_enabled' => 'boolean', 'is_force_https_enabled' => 'boolean', 'is_preview_deployments_enabled' => 'boolean', + 'use_build_secrets' => 'boolean', + 'is_git_submodules_enabled' => 'boolean', + 'is_git_lfs_enabled' => 'boolean', + 'is_git_shallow_clone_enabled' => 'boolean', + 'disable_build_cache' => 'boolean', + 'inject_build_args_to_dockerfile' => 'boolean', + 'include_source_commit_in_build' => 'boolean', + 'is_env_sorting_enabled' => 'boolean', + 'is_pr_deployments_public_enabled' => 'boolean', + 'is_gzip_enabled' => 'boolean', + 'is_stripprefix_enabled' => 'boolean', + 'is_raw_compose_deployment_enabled' => 'boolean', + 'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS, + 'docker_images_to_keep' => 'integer|min:0|max:100', 'static_image' => Rule::enum(StaticImageTypes::class), 'domains' => ValidationPatterns::applicationDomainRules(), 'redirect' => Rule::enum(RedirectTypes::class), @@ -272,6 +286,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('github_app_uuid'); $request->offsetUnset('private_key_uuid'); $request->offsetUnset('use_build_server'); + $request->offsetUnset('use_build_secrets'); $request->offsetUnset('is_static'); $request->offsetUnset('is_spa'); $request->offsetUnset('is_auto_deploy_enabled'); @@ -283,6 +298,18 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('is_container_label_escape_enabled'); $request->offsetUnset('is_preserve_repository_enabled'); $request->offsetUnset('include_source_commit_in_build'); + $request->offsetUnset('is_git_submodules_enabled'); + $request->offsetUnset('is_git_lfs_enabled'); + $request->offsetUnset('is_git_shallow_clone_enabled'); + $request->offsetUnset('disable_build_cache'); + $request->offsetUnset('inject_build_args_to_dockerfile'); + $request->offsetUnset('is_env_sorting_enabled'); + $request->offsetUnset('is_pr_deployments_public_enabled'); + $request->offsetUnset('stop_grace_period'); + $request->offsetUnset('docker_images_to_keep'); + $request->offsetUnset('is_gzip_enabled'); + $request->offsetUnset('is_stripprefix_enabled'); + $request->offsetUnset('is_raw_compose_deployment_enabled'); $request->offsetUnset('docker_compose_raw'); $request->offsetUnset('tags'); } diff --git a/openapi.json b/openapi.json index 4f34d3f65..4a3a932ca 100644 --- a/openapi.json +++ b/openapi.json @@ -380,6 +380,68 @@ "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "default": false, + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -841,6 +903,68 @@ "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "default": false, + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -1302,6 +1426,68 @@ "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "default": false, + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -1668,6 +1854,68 @@ "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "default": false, + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -2014,6 +2262,68 @@ "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "default": false, + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -2596,6 +2906,67 @@ "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -2612,10 +2983,6 @@ "is_preserve_repository_enabled": { "type": "boolean", "description": "Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false." - }, - "include_source_commit_in_build": { - "type": "boolean", - "description": "Include source commit information in the build. Default is false." } }, "type": "object" @@ -15146,6 +15513,9 @@ "type": "string", "nullable": true, "description": "Password for HTTP Basic Authentication" + }, + "": { + "$ref": "#\/components\/schemas\/ApplicationSetting" } }, "type": "object" @@ -15241,6 +15611,123 @@ }, "type": "object" }, + "ApplicationSetting": { + "description": "Application settings.", + "properties": { + "is_static": { + "type": "boolean" + }, + "is_git_submodules_enabled": { + "type": "boolean" + }, + "is_git_lfs_enabled": { + "type": "boolean" + }, + "is_auto_deploy_enabled": { + "type": "boolean" + }, + "is_force_https_enabled": { + "type": "boolean" + }, + "is_debug_enabled": { + "type": "boolean" + }, + "is_preview_deployments_enabled": { + "type": "boolean" + }, + "is_log_drain_enabled": { + "type": "boolean" + }, + "is_gpu_enabled": { + "type": "boolean" + }, + "gpu_driver": { + "type": "string", + "nullable": true + }, + "gpu_count": { + "type": "string", + "nullable": true + }, + "gpu_device_ids": { + "type": "string", + "nullable": true + }, + "gpu_options": { + "type": "string", + "nullable": true + }, + "is_include_timestamps": { + "type": "boolean" + }, + "is_swarm_only_worker_nodes": { + "type": "boolean" + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean" + }, + "is_build_server_enabled": { + "type": "boolean" + }, + "is_consistent_container_name_enabled": { + "type": "boolean" + }, + "is_gzip_enabled": { + "type": "boolean" + }, + "is_stripprefix_enabled": { + "type": "boolean" + }, + "connect_to_docker_network": { + "type": "boolean" + }, + "custom_internal_name": { + "type": "string", + "nullable": true + }, + "is_container_label_escape_enabled": { + "type": "boolean" + }, + "is_env_sorting_enabled": { + "type": "boolean" + }, + "is_container_label_readonly_enabled": { + "type": "boolean" + }, + "is_preserve_repository_enabled": { + "type": "boolean" + }, + "disable_build_cache": { + "type": "boolean" + }, + "is_spa": { + "type": "boolean" + }, + "is_git_shallow_clone_enabled": { + "type": "boolean" + }, + "is_pr_deployments_public_enabled": { + "type": "boolean" + }, + "use_build_secrets": { + "type": "boolean" + }, + "inject_build_args_to_dockerfile": { + "type": "boolean" + }, + "include_source_commit_in_build": { + "type": "boolean" + }, + "docker_images_to_keep": { + "type": "integer" + }, + "stop_grace_period": { + "type": "integer", + "nullable": true + } + }, + "type": "object" + }, "Environment": { "description": "Environment model", "properties": { diff --git a/openapi.yaml b/openapi.yaml index 55751abd5..6dfe91e0a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -268,6 +268,54 @@ paths: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + default: false + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -562,6 +610,54 @@ paths: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + default: false + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -856,6 +952,54 @@ paths: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + default: false + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -1091,6 +1235,54 @@ paths: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + default: false + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -1312,6 +1504,54 @@ paths: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + default: false + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -1688,6 +1928,53 @@ paths: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -1701,9 +1988,6 @@ paths: is_preserve_repository_enabled: type: boolean description: 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.' - include_source_commit_in_build: - type: boolean - description: 'Include source commit information in the build. Default is false.' type: object responses: '200': @@ -9683,6 +9967,8 @@ components: type: string nullable: true description: 'Password for HTTP Basic Authentication' + '': + $ref: '#/components/schemas/ApplicationSetting' type: object ApplicationDeploymentQueue: description: 'Project model' @@ -9746,6 +10032,86 @@ components: commit_message: type: string type: object + ApplicationSetting: + description: 'Application settings.' + properties: + is_static: + type: boolean + is_git_submodules_enabled: + type: boolean + is_git_lfs_enabled: + type: boolean + is_auto_deploy_enabled: + type: boolean + is_force_https_enabled: + type: boolean + is_debug_enabled: + type: boolean + is_preview_deployments_enabled: + type: boolean + is_log_drain_enabled: + type: boolean + is_gpu_enabled: + type: boolean + gpu_driver: + type: string + nullable: true + gpu_count: + type: string + nullable: true + gpu_device_ids: + type: string + nullable: true + gpu_options: + type: string + nullable: true + is_include_timestamps: + type: boolean + is_swarm_only_worker_nodes: + type: boolean + is_raw_compose_deployment_enabled: + type: boolean + is_build_server_enabled: + type: boolean + is_consistent_container_name_enabled: + type: boolean + is_gzip_enabled: + type: boolean + is_stripprefix_enabled: + type: boolean + connect_to_docker_network: + type: boolean + custom_internal_name: + type: string + nullable: true + is_container_label_escape_enabled: + type: boolean + is_env_sorting_enabled: + type: boolean + is_container_label_readonly_enabled: + type: boolean + is_preserve_repository_enabled: + type: boolean + disable_build_cache: + type: boolean + is_spa: + type: boolean + is_git_shallow_clone_enabled: + type: boolean + is_pr_deployments_public_enabled: + type: boolean + use_build_secrets: + type: boolean + inject_build_args_to_dockerfile: + type: boolean + include_source_commit_in_build: + type: boolean + docker_images_to_keep: + type: integer + stop_grace_period: + type: integer + nullable: true + type: object Environment: description: 'Environment model' properties: diff --git a/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php b/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php new file mode 100644 index 000000000..3b3721545 --- /dev/null +++ b/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php @@ -0,0 +1,257 @@ + InstanceSettings::firstOrCreate(['id' => 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->bearerToken = $this->user->createToken('build-secrets-api-test', ['*'])->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]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +function buildSecretsApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function buildSecretsGithubPrivateKey(): string +{ + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + openssl_pkey_export($key, $privateKey); + + return $privateKey; +} + +describe('PATCH /api/v1/applications/{uuid} use_build_secrets', function () { + test('updates the application setting', function () { + expect($this->application->settings->use_build_secrets)->toBeFalse(); + + $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'use_build_secrets' => true, + ]) + ->assertOk(); + + expect($this->application->fresh()->settings->use_build_secrets)->toBeTrue(); + + $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'use_build_secrets' => false, + ]) + ->assertOk(); + + expect($this->application->fresh()->settings->use_build_secrets)->toBeFalse(); + }); + + test('rejects non boolean values', function () { + $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'use_build_secrets' => 'not-a-boolean', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('use_build_secrets'); + }); + + test('does not change the setting when omitted', function () { + $this->application->settings->update(['use_build_secrets' => true]); + + $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'name' => 'updated-name', + ]) + ->assertOk(); + + expect($this->application->fresh()->settings->use_build_secrets)->toBeTrue(); + }); +}); + +describe('POST /api/v1/applications/public use_build_secrets', function () { + test('creates an application with the requested build secrets setting', function (bool $useBuildSecrets) { + $response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/build-secrets-test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'use_build_secrets' => $useBuildSecrets, + 'autogenerate_domain' => false, + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($application->settings->use_build_secrets)->toBe($useBuildSecrets); + })->with([ + 'enabled' => true, + 'disabled' => false, + ]); + + test('rejects non boolean values', function () { + $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/build-secrets-test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'use_build_secrets' => 'not-a-boolean', + 'autogenerate_domain' => false, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('use_build_secrets'); + }); +}); + +describe('other application creation endpoints use_build_secrets', function () { + test('creates a Dockerfile application with build secrets enabled', function () { + $response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/dockerfile', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'dockerfile' => base64_encode("FROM nginx:alpine\nEXPOSE 80"), + 'use_build_secrets' => true, + 'autogenerate_domain' => false, + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($application->settings->use_build_secrets)->toBeTrue(); + }); + + test('creates a Docker image application with build secrets enabled', function () { + $response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/dockerimage', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'docker_registry_image_tag' => 'alpine', + 'ports_exposes' => '80', + 'use_build_secrets' => true, + 'autogenerate_domain' => false, + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($application->settings->use_build_secrets)->toBeTrue(); + }); + + test('creates a private deploy key application with build secrets enabled', function () { + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + + $response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/private-deploy-key', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'private_key_uuid' => $privateKey->uuid, + 'git_repository' => 'git@gitlab.com:coolify/build-secrets-test.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'use_build_secrets' => true, + 'autogenerate_domain' => false, + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($application->settings->use_build_secrets)->toBeTrue(); + }); + + test('creates a private GitHub App application with build secrets enabled', function () { + $privateKey = PrivateKey::create([ + 'name' => 'GitHub App Key', + 'private_key' => buildSecretsGithubPrivateKey(), + 'team_id' => $this->team->id, + ]); + $githubApp = GithubApp::create([ + 'name' => 'Build Secrets GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 12345, + 'installation_id' => 67890, + 'client_id' => 'build-secrets-client-id', + 'client_secret' => 'build-secrets-client-secret', + 'webhook_secret' => 'build-secrets-webhook-secret', + 'private_key_id' => $privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [ + 'Date' => now()->toRfc7231String(), + ]), + 'https://api.github.com/app/installations/67890/access_tokens' => Http::response([ + 'token' => 'github-installation-token', + ], 201), + 'https://api.github.com/repos/coolify/build-secrets-test' => Http::response([ + 'id' => 123456, + ]), + ]); + + $response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/private-github-app', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'github_app_uuid' => $githubApp->uuid, + 'git_repository' => 'coolify/build-secrets-test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'use_build_secrets' => true, + 'autogenerate_domain' => false, + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($application->settings->use_build_secrets)->toBeTrue(); + }); +}); diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php new file mode 100644 index 000000000..51ec776b2 --- /dev/null +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -0,0 +1,183 @@ + InstanceSettings::firstOrCreate(['id' => 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->bearerToken = $this->user->createToken('application-settings-api-test', ['*'])->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]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +function applicationSettingsApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function recommendedApplicationSettingsPayload(): array +{ + return [ + 'is_git_submodules_enabled' => false, + 'is_git_lfs_enabled' => false, + 'is_git_shallow_clone_enabled' => false, + 'disable_build_cache' => true, + 'inject_build_args_to_dockerfile' => false, + 'include_source_commit_in_build' => true, + 'is_env_sorting_enabled' => true, + 'is_pr_deployments_public_enabled' => true, + 'stop_grace_period' => 45, + 'docker_images_to_keep' => 7, + 'is_gzip_enabled' => false, + 'is_stripprefix_enabled' => false, + 'is_raw_compose_deployment_enabled' => true, + ]; +} + +test('GET /api/v1/applications/{uuid} includes settings without internal metadata', function () { + $this->application->settings->update(recommendedApplicationSettingsPayload()); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$this->application->uuid}") + ->assertOk() + ->assertJsonPath('settings.disable_build_cache', true) + ->assertJsonPath('settings.stop_grace_period', 45) + ->assertJsonMissingPath('settings.id') + ->assertJsonMissingPath('settings.application_id') + ->assertJsonMissingPath('settings.created_at') + ->assertJsonMissingPath('settings.updated_at'); +}); + +test('PATCH /api/v1/applications/{uuid} updates application settings', function () { + $this->application->update(['build_pack' => 'dockercompose']); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", recommendedApplicationSettingsPayload()) + ->assertOk(); + + $settings = $this->application->fresh()->settings; + + foreach (recommendedApplicationSettingsPayload() as $field => $value) { + expect($settings->{$field})->toBe($value); + } +}); + +test('application creation accepts application settings', function () { + Queue::fake(); + + $response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/public', array_merge([ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/application-settings-test', + 'git_branch' => 'main', + 'build_pack' => 'dockercompose', + 'autogenerate_domain' => false, + ], recommendedApplicationSettingsPayload())) + ->assertCreated(); + + $settings = Application::where('uuid', $response->json('uuid'))->firstOrFail()->settings; + + foreach (recommendedApplicationSettingsPayload() as $field => $value) { + expect($settings->{$field})->toBe($value); + } +}); + +test('proxy settings regenerate managed labels', function () { + $this->application->settings->update([ + 'is_container_label_readonly_enabled' => true, + 'is_gzip_enabled' => true, + 'is_stripprefix_enabled' => true, + ]); + $this->application->update(['custom_labels' => base64_encode('sentinel-label=true')]); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'is_gzip_enabled' => false, + 'is_stripprefix_enabled' => false, + ]) + ->assertOk(); + + expect(base64_decode($this->application->fresh()->custom_labels))->not->toContain('sentinel-label=true'); +}); + +test('rejects invalid boolean application settings', function () { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'disable_build_cache' => 'not-a-boolean', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('disable_build_cache'); +}); + +test('validates stop grace period bounds', function (int $stopGracePeriod) { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'stop_grace_period' => $stopGracePeriod, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('stop_grace_period'); +})->with([ + 'below minimum' => 0, + 'above maximum' => 3601, +]); + +test('validates Docker image retention bounds', function (int $dockerImagesToKeep) { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'docker_images_to_keep' => $dockerImagesToKeep, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('docker_images_to_keep'); +})->with([ + 'below minimum' => -1, + 'above maximum' => 101, +]); + +test('stop grace period can be reset to null', function () { + $this->application->settings->update(['stop_grace_period' => 45]); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'stop_grace_period' => null, + ]) + ->assertOk(); + + expect($this->application->fresh()->settings->stop_grace_period)->toBeNull(); +}); + +test('raw compose deployment can only be enabled for Docker Compose applications', function () { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'is_raw_compose_deployment_enabled' => true, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('is_raw_compose_deployment_enabled'); +}); From 59605fe7d30d8a5e647495a7b7d90f3a9c93ac6f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:58:15 +0200 Subject: [PATCH 09/15] fix(environment-variable): keep delete button compact --- .../project/shared/environment-variable/show.blade.php | 4 ++-- .../Feature/EnvironmentVariableMultilineToggleViewTest.php | 6 ++++++ 2 files changed, 8 insertions(+), 2 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 c602ba4af..fd9acdaf8 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -275,7 +275,7 @@ Lock @@ -284,7 +284,7 @@ Lock diff --git a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php index de97e6095..8af9c69d5 100644 --- a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php +++ b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php @@ -21,6 +21,12 @@ it('uses distinct keyed branches for the edit value field modes', function () { ->toContain('wire:key="env-show-value-input-{{ $env->id }}"'); }); +it('keeps the environment variable delete button compact', function () { + $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php')); + + expect($view)->not->toContain('buttonFullWidth="true"'); +}); + it('uses sans font for the developer bulk environment variable editor', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php')); From 198e9f8fff379cdfe9ef44d3f0589d501b2ef809 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:02:34 +0200 Subject: [PATCH 10/15] fix(environment-variable): align settings and actions responsively --- .../shared/environment-variable/show.blade.php | 12 +++++++----- .../EnvironmentVariableMultilineToggleViewTest.php | 8 ++++++++ 2 files changed, 15 insertions(+), 5 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 fd9acdaf8..76cfcead6 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -217,8 +217,9 @@
@endcan @can('update', $this->env) -
-
+
+
+
@if (!$is_redis_credential) @if ($type === 'service') @if (!$isMagicVariable) @@ -266,10 +267,11 @@ @endif @endif @endif +
+
- @if (!$isMagicVariable) -
+
@if ($isDisabled) Update Lock @@ -291,7 +293,7 @@ @endif
@elseif ($type === 'service') -
+
Lock
@endif diff --git a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php index 8af9c69d5..9d0b1c5b5 100644 --- a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php +++ b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php @@ -27,6 +27,14 @@ it('keeps the environment variable delete button compact', function () { expect($view)->not->toContain('buttonFullWidth="true"'); }); +it('aligns environment variable settings and actions in a responsive row', function () { + $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php')); + + expect($view) + ->toContain('class="flex w-full flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"') + ->toContain('class="flex w-full justify-end gap-2 lg:w-auto lg:shrink-0"'); +}); + it('uses sans font for the developer bulk environment variable editor', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php')); From 64667574f6fdf3251df95e50370710a29a862c12 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:29:20 +0200 Subject: [PATCH 11/15] fix(api): correct service and application OpenAPI schemas --- .../Controllers/Api/ServicesController.php | 7 +- app/Models/Application.php | 2 +- openapi.json | 778 +++++++++++++++++- openapi.yaml | 512 +++++++++++- 4 files changed, 1255 insertions(+), 44 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index aa9afe0ad..9fa512c6b 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1053,11 +1053,6 @@ class ServicesController extends Controller properties: [ 'name' => ['type' => 'string', 'description' => 'The service name.'], 'description' => ['type' => 'string', 'description' => 'The service description.'], - 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], - 'environment_name' => ['type' => 'string', 'description' => 'The environment name.'], - 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID.'], - 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], - 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the service should be deployed instantly.'], 'connect_to_docker_network' => ['type' => 'boolean', 'default' => false, 'description' => 'Connect the service to the predefined docker network.'], 'docker_compose_raw' => ['type' => 'string', 'description' => 'The base64 encoded Docker Compose content.'], @@ -1942,7 +1937,7 @@ class ServicesController extends Controller ), ] )] - public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + public function move_by_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { diff --git a/app/Models/Application.php b/app/Models/Application.php index ca247c8e8..732142b0d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -111,7 +111,7 @@ use Symfony\Component\Yaml\Yaml; 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], - 'settings' => new OA\Property(ref: '#/components/schemas/ApplicationSetting'), + new OA\Property(property: 'settings', ref: '#/components/schemas/ApplicationSetting'), ] )] diff --git a/openapi.json b/openapi.json index 4a3a932ca..093b61010 100644 --- a/openapi.json +++ b/openapi.json @@ -12731,6 +12731,76 @@ "bearerAuth": [] } ] + }, + "post": { + "tags": [ + "Service applications" + ], + "summary": "Get service application logs", + "description": "Get Docker logs for a single compose service container.", + "operationId": "post-service-application-logs-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lines", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Logs.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/services\/{uuid}\/applications\/{app_uuid}\/start": { @@ -12812,6 +12882,84 @@ "bearerAuth": [] } ] + }, + "post": { + "tags": [ + "Service applications" + ], + "summary": "Start or redeploy service application container", + "description": "Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.", + "operationId": "post-start-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "latest", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Deploy request queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/services\/{uuid}\/applications\/{app_uuid}\/restart": { @@ -12873,6 +13021,66 @@ "bearerAuth": [] } ] + }, + "post": { + "tags": [ + "Service applications" + ], + "summary": "Restart service application container", + "description": "Restarts a single compose service container.", + "operationId": "post-restart-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Restart queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/services\/{uuid}\/applications\/{app_uuid}\/stop": { @@ -12934,6 +13142,550 @@ "bearerAuth": [] } ] + }, + "post": { + "tags": [ + "Service applications" + ], + "summary": "Stop service application container", + "description": "Stops a single compose service container.", + "operationId": "post-stop-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Stop queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases": { + "get": { + "tags": [ + "Service databases" + ], + "summary": "List service databases", + "description": "List compose databases for a single service.", + "operationId": "list-service-databases-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Service databases.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}": { + "get": { + "tags": [ + "Service databases" + ], + "summary": "Get service database", + "description": "Get a compose database by service UUID and database UUID.", + "operationId": "get-service-database-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "description": "Service database UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Service database.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Service databases" + ], + "summary": "Update service database", + "description": "Update mutable fields for a compose service database.", + "operationId": "patch-service-database-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "description": "Service database UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application\/json": { + "schema": { + "properties": { + "human_name": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "image": { + "type": "string" + }, + "exclude_from_status": { + "type": "boolean" + }, + "is_log_drain_enabled": { + "type": "boolean" + }, + "is_public": { + "type": "boolean" + }, + "public_port": { + "type": [ + "integer", + "null" + ], + "maximum": 65535, + "minimum": 1 + }, + "public_port_timeout": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Updated service database.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/logs": { + "get": { + "tags": [ + "Service databases" + ], + "summary": "Get service database logs", + "description": "Get Docker logs for a compose database container.", + "operationId": "get-service-database-logs-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lines", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Logs.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/start": { + "post": { + "tags": [ + "Service databases" + ], + "summary": "Start or redeploy service database container", + "description": "Run docker compose up for a single compose database.", + "operationId": "start-service-database-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "latest", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Deploy request queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/restart": { + "post": { + "tags": [ + "Service databases" + ], + "summary": "Restart service database container", + "description": "Restart a compose database container.", + "operationId": "restart-service-database-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Restart queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/stop": { + "post": { + "tags": [ + "Service databases" + ], + "summary": "Stop service database container", + "description": "Stop a compose database container.", + "operationId": "stop-service-database-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Stop queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/services": { @@ -13338,26 +14090,6 @@ "type": "string", "description": "The service description." }, - "project_uuid": { - "type": "string", - "description": "The project UUID." - }, - "environment_name": { - "type": "string", - "description": "The environment name." - }, - "environment_uuid": { - "type": "string", - "description": "The environment UUID." - }, - "server_uuid": { - "type": "string", - "description": "The server UUID." - }, - "destination_uuid": { - "type": "string", - "description": "The destination UUID." - }, "instant_deploy": { "type": "boolean", "description": "The flag to indicate if the service should be deployed instantly." @@ -15514,7 +16246,7 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, - "": { + "settings": { "$ref": "#\/components\/schemas\/ApplicationSetting" } }, @@ -16568,6 +17300,10 @@ "name": "Service applications", "description": "Service applications" }, + { + "name": "Service databases", + "description": "Service databases" + }, { "name": "Services", "description": "Services" diff --git a/openapi.yaml b/openapi.yaml index 6dfe91e0a..061940991 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8173,6 +8173,53 @@ paths: security: - bearerAuth: [] + post: + tags: + - 'Service applications' + summary: 'Get service application logs' + description: 'Get Docker logs for a single compose service container.' + operationId: post-service-application-logs-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: app_uuid + in: path + required: true + schema: + type: string + - + name: lines + in: query + required: false + schema: + type: integer + format: int32 + default: 100 + responses: + '200': + description: Logs. + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] '/services/{uuid}/applications/{app_uuid}/start': get: tags: @@ -8229,6 +8276,59 @@ paths: security: - bearerAuth: [] + post: + tags: + - 'Service applications' + summary: 'Start or redeploy service application container' + description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.' + operationId: post-start-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: app_uuid + in: path + required: true + schema: + type: string + - + name: force + in: query + required: false + schema: + type: boolean + default: false + - + name: latest + in: query + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Deploy request queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] '/services/{uuid}/applications/{app_uuid}/restart': get: tags: @@ -8269,6 +8369,45 @@ paths: security: - bearerAuth: [] + post: + tags: + - 'Service applications' + summary: 'Restart service application container' + description: 'Restarts a single compose service container.' + operationId: post-restart-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: app_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Restart queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] '/services/{uuid}/applications/{app_uuid}/stop': get: tags: @@ -8309,6 +8448,359 @@ paths: security: - bearerAuth: [] + post: + tags: + - 'Service applications' + summary: 'Stop service application container' + description: 'Stops a single compose service container.' + operationId: post-stop-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: app_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Stop queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/databases': + get: + tags: + - 'Service databases' + summary: 'List service databases' + description: 'List compose databases for a single service.' + operationId: list-service-databases-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Service databases.' + content: + application/json: + schema: + type: array + items: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}': + get: + tags: + - 'Service databases' + summary: 'Get service database' + description: 'Get a compose database by service UUID and database UUID.' + operationId: get-service-database-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: database_uuid + in: path + description: 'Service database UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Service database.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Service databases' + summary: 'Update service database' + description: 'Update mutable fields for a compose service database.' + operationId: patch-service-database-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: database_uuid + in: path + description: 'Service database UUID.' + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + properties: + human_name: + type: [string, 'null'] + description: + type: [string, 'null'] + image: + type: string + exclude_from_status: + type: boolean + is_log_drain_enabled: + type: boolean + is_public: + type: boolean + public_port: + type: [integer, 'null'] + maximum: 65535 + minimum: 1 + public_port_timeout: + type: [integer, 'null'] + minimum: 1 + type: object + additionalProperties: false + responses: + '200': + description: 'Updated service database.' + content: + application/json: + schema: + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/logs': + get: + tags: + - 'Service databases' + summary: 'Get service database logs' + description: 'Get Docker logs for a compose database container.' + operationId: get-service-database-logs-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: database_uuid + in: path + required: true + schema: + type: string + - + name: lines + in: query + required: false + schema: + type: integer + format: int32 + default: 100 + responses: + '200': + description: Logs. + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/start': + post: + tags: + - 'Service databases' + summary: 'Start or redeploy service database container' + description: 'Run docker compose up for a single compose database.' + operationId: start-service-database-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: database_uuid + in: path + required: true + schema: + type: string + - + name: force + in: query + required: false + schema: + type: boolean + default: false + - + name: latest + in: query + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Deploy request queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/restart': + post: + tags: + - 'Service databases' + summary: 'Restart service database container' + description: 'Restart a compose database container.' + operationId: restart-service-database-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: database_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Restart queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/stop': + post: + tags: + - 'Service databases' + summary: 'Stop service database container' + description: 'Stop a compose database container.' + operationId: stop-service-database-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: database_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Stop queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] /services: get: tags: @@ -8550,21 +9042,6 @@ paths: description: type: string description: 'The service description.' - project_uuid: - type: string - description: 'The project UUID.' - environment_name: - type: string - description: 'The environment name.' - environment_uuid: - type: string - description: 'The environment UUID.' - server_uuid: - type: string - description: 'The server UUID.' - destination_uuid: - type: string - description: 'The destination UUID.' instant_deploy: type: boolean description: 'The flag to indicate if the service should be deployed instantly.' @@ -9967,7 +10444,7 @@ components: type: string nullable: true description: 'Password for HTTP Basic Authentication' - '': + settings: $ref: '#/components/schemas/ApplicationSetting' type: object ApplicationDeploymentQueue: @@ -10700,6 +11177,9 @@ tags: - name: 'Service applications' description: 'Service applications' + - + name: 'Service databases' + description: 'Service databases' - name: Services description: Services From 908b5cc09d2edcf2fcce6d775961d6f14f092b9b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:29:58 +0200 Subject: [PATCH 12/15] feat(api): add service database management endpoints Add service database CRUD, logs, and lifecycle actions, and document service application operations with OpenAPI tests. --- .../Service/DeployServiceApplication.php | 3 +- .../Service/RestartServiceApplication.php | 3 +- .../Service/StopServiceApplication.php | 3 +- .../Api/ServiceApplicationsController.php | 107 +++++ .../Api/ServiceDatabasesController.php | 452 ++++++++++++++++++ app/Models/ServiceDatabase.php | 7 + app/Policies/ServiceDatabasePolicy.php | 8 + routes/api.php | 9 + tests/Feature/ServiceDatabasesApiTest.php | 165 +++++++ tests/Unit/ApplicationOpenApiTest.php | 27 ++ tests/Unit/ServiceApplicationsOpenApiTest.php | 32 ++ tests/Unit/ServiceDatabasesOpenApiTest.php | 29 ++ tests/Unit/ServicesOpenApiTest.php | 28 ++ 13 files changed, 870 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/Api/ServiceDatabasesController.php create mode 100644 tests/Feature/ServiceDatabasesApiTest.php create mode 100644 tests/Unit/ApplicationOpenApiTest.php create mode 100644 tests/Unit/ServiceApplicationsOpenApiTest.php create mode 100644 tests/Unit/ServiceDatabasesOpenApiTest.php create mode 100644 tests/Unit/ServicesOpenApiTest.php diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php index 363166bcc..9181c537d 100644 --- a/app/Actions/Service/DeployServiceApplication.php +++ b/app/Actions/Service/DeployServiceApplication.php @@ -3,6 +3,7 @@ namespace App\Actions\Service; use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use Lorisleiva\Actions\Concerns\AsAction; use Spatie\Activitylog\Contracts\Activity; @@ -12,7 +13,7 @@ class DeployServiceApplication public string $jobQueue = 'high'; - public function handle(ServiceApplication $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity + public function handle(ServiceApplication|ServiceDatabase $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity { $service = $serviceApplication->service; $composeServiceName = $serviceApplication->name; diff --git a/app/Actions/Service/RestartServiceApplication.php b/app/Actions/Service/RestartServiceApplication.php index c83cbe660..ebee6e2f2 100644 --- a/app/Actions/Service/RestartServiceApplication.php +++ b/app/Actions/Service/RestartServiceApplication.php @@ -3,6 +3,7 @@ namespace App\Actions\Service; use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use Lorisleiva\Actions\Concerns\AsAction; class RestartServiceApplication @@ -11,7 +12,7 @@ class RestartServiceApplication public string $jobQueue = 'high'; - public function handle(ServiceApplication $serviceApplication): void + public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void { $service = $serviceApplication->service; $server = $service->destination->server; diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php index cc1afbb96..724e1a254 100644 --- a/app/Actions/Service/StopServiceApplication.php +++ b/app/Actions/Service/StopServiceApplication.php @@ -3,6 +3,7 @@ namespace App\Actions\Service; use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use Lorisleiva\Actions\Concerns\AsAction; class StopServiceApplication @@ -11,7 +12,7 @@ class StopServiceApplication public string $jobQueue = 'high'; - public function handle(ServiceApplication $serviceApplication): void + public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void { $service = $serviceApplication->service; $server = $service->destination->server; diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index a144b3ea6..74b6c9db1 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -424,6 +424,33 @@ class ServiceApplicationsController extends Controller ), ] )] + #[OA\Post( + summary: 'Get service application logs', + description: 'Get Docker logs for a single compose service container.', + path: '/services/{uuid}/applications/{app_uuid}/logs', + operationId: 'post-service-application-logs-by-service-and-app-uuid', + security: [['bearerAuth' => []]], + tags: ['Service applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Logs.', + content: new OA\JsonContent( + type: 'object', + properties: [new OA\Property(property: 'logs', type: 'string')], + ), + ), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] public function logs_by_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); @@ -540,6 +567,34 @@ class ServiceApplicationsController extends Controller ), ] )] + #[OA\Post( + summary: 'Start or redeploy service application container', + description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.', + path: '/services/{uuid}/applications/{app_uuid}/start', + operationId: 'post-start-service-application-by-service-and-app-uuid', + security: [['bearerAuth' => []]], + tags: ['Service applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)), + new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Deploy request queued.', + content: new OA\JsonContent( + type: 'object', + properties: [new OA\Property(property: 'message', type: 'string')], + ), + ), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] public function action_start(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); @@ -635,6 +690,32 @@ class ServiceApplicationsController extends Controller ), ] )] + #[OA\Post( + summary: 'Restart service application container', + description: 'Restarts a single compose service container.', + path: '/services/{uuid}/applications/{app_uuid}/restart', + operationId: 'post-restart-service-application-by-service-and-app-uuid', + security: [['bearerAuth' => []]], + tags: ['Service applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Restart queued.', + content: new OA\JsonContent( + type: 'object', + properties: [new OA\Property(property: 'message', type: 'string')], + ), + ), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] public function action_restart(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); @@ -727,6 +808,32 @@ class ServiceApplicationsController extends Controller ), ] )] + #[OA\Post( + summary: 'Stop service application container', + description: 'Stops a single compose service container.', + path: '/services/{uuid}/applications/{app_uuid}/stop', + operationId: 'post-stop-service-application-by-service-and-app-uuid', + security: [['bearerAuth' => []]], + tags: ['Service applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Stop queued.', + content: new OA\JsonContent( + type: 'object', + properties: [new OA\Property(property: 'message', type: 'string')], + ), + ), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] public function action_stop(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php new file mode 100644 index 000000000..7befc3273 --- /dev/null +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -0,0 +1,452 @@ +makeHidden([ + 'id', + 'service', + 'service_id', + 'resourceable', + 'resourceable_id', + 'resourceable_type', + ]); + + $serialized = serializeApiResponse($serviceDatabase); + + if ($serialized instanceof Collection) { + return $serialized->all(); + } + + return (array) $serialized; + } + + private function resolveService(Request $request, int $teamId): ?Service + { + return Service::whereRelation('environment.project.team', 'id', $teamId) + ->whereUuid($request->route('uuid')) + ->first(); + } + + private function resolveServiceDatabase(Request $request, Service $service): ?ServiceDatabase + { + return $service->databases() + ->where('uuid', $request->route('database_uuid')) + ->with(['service.destination.server']) + ->first(); + } + + private function swarmNotSupportedResponse(): JsonResponse + { + return response()->json([ + 'message' => 'This operation is not supported for Swarm servers yet.', + ], 501); + } + + #[OA\Get( + summary: 'List service databases', + description: 'List compose databases for a single service.', + path: '/services/{uuid}/databases', + operationId: 'list-service-databases-by-service-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Service databases.', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'object'))), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function index(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('view', $service); + + $databases = $service->databases() + ->get() + ->map(fn (ServiceDatabase $database) => $this->removeSensitiveData($database)); + + return response()->json($databases); + } + + #[OA\Get( + summary: 'Get service database', + description: 'Get a compose database by service UUID and database UUID.', + path: '/services/{uuid}/databases/{database_uuid}', + operationId: 'get-service-database-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Service database.', content: new OA\JsonContent(type: 'object')), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function show(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceDatabase = $this->resolveServiceDatabase($request, $service); + if (! $serviceDatabase) { + return response()->json(['message' => 'Service database not found.'], 404); + } + + $this->authorize('view', $serviceDatabase); + + return response()->json($this->removeSensitiveData($serviceDatabase)); + } + + #[OA\Patch( + summary: 'Update service database', + description: 'Update mutable fields for a compose service database.', + path: '/services/{uuid}/databases/{database_uuid}', + operationId: 'patch-service-database-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'human_name', type: 'string', nullable: true), + new OA\Property(property: 'description', type: 'string', nullable: true), + new OA\Property(property: 'image', type: 'string'), + new OA\Property(property: 'exclude_from_status', type: 'boolean'), + new OA\Property(property: 'is_log_drain_enabled', type: 'boolean'), + new OA\Property(property: 'is_public', type: 'boolean'), + new OA\Property(property: 'public_port', type: 'integer', nullable: true, minimum: 1, maximum: 65535), + new OA\Property(property: 'public_port_timeout', type: 'integer', nullable: true, minimum: 1), + ], + additionalProperties: false, + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Updated service database.', content: new OA\JsonContent(type: 'object')), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $invalidRequest = validateIncomingRequest($request); + if ($invalidRequest instanceof JsonResponse) { + return $invalidRequest; + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceDatabase = $this->resolveServiceDatabase($request, $service); + if (! $serviceDatabase) { + return response()->json(['message' => 'Service database not found.'], 404); + } + + $this->authorize('update', $serviceDatabase); + + $payload = $request->json()->all(); + if (empty($payload)) { + $payload = $request->request->all(); + } + + $allowedFields = [ + 'human_name', + 'description', + 'image', + 'exclude_from_status', + 'is_log_drain_enabled', + 'is_public', + 'public_port', + 'public_port_timeout', + ]; + $validator = Validator::make($payload, [ + 'human_name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'image' => 'sometimes|string', + 'exclude_from_status' => 'sometimes|boolean', + 'is_log_drain_enabled' => 'sometimes|boolean', + 'is_public' => 'sometimes|boolean', + 'public_port' => 'nullable|integer|min:1|max:65535', + 'public_port_timeout' => 'nullable|integer|min:1', + ]); + + $extraFields = array_diff(array_keys($payload), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $server = $serviceDatabase->service->destination->server; + if (($payload['is_log_drain_enabled'] ?? false) && ! $server->isLogDrainEnabled()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.']], + ], 422); + } + + $isPublic = $payload['is_public'] ?? $serviceDatabase->is_public; + $publicPort = $payload['public_port'] ?? $serviceDatabase->public_port; + if ($isPublic && ! $publicPort) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['public_port' => ['A public port is required when the database is public.']], + ], 422); + } + if ($isPublic && isPublicPortAlreadyUsed($server, $publicPort, $serviceDatabase->id)) { + return response()->json(['message' => 'Public port already used by another database.'], 400); + } + + $shouldStartProxy = ($payload['is_public'] ?? null) === true && ! $serviceDatabase->is_public; + $shouldStopProxy = ($payload['is_public'] ?? null) === false && $serviceDatabase->is_public; + + $serviceDatabase->fill($payload); + $serviceDatabase->save(); + $serviceDatabase->refresh(); + updateCompose($serviceDatabase); + + if ($shouldStartProxy) { + StartDatabaseProxy::dispatch($serviceDatabase); + } elseif ($shouldStopProxy) { + StopDatabaseProxy::dispatch($serviceDatabase); + } + + auditLog('api.service_database.updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_database_uuid' => $serviceDatabase->uuid, + 'changed_fields' => array_keys($payload), + ]); + + return response()->json($this->removeSensitiveData($serviceDatabase)); + } + + #[OA\Get( + summary: 'Get service database logs', + description: 'Get Docker logs for a compose database container.', + path: '/services/{uuid}/databases/{database_uuid}/logs', + operationId: 'get-service-database-logs-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)), + ], + responses: [ + new OA\Response(response: 200, description: 'Logs.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'logs', type: 'string')])), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] + public function logs(Request $request): JsonResponse + { + $resolved = $this->resolveDatabaseRequest($request, 'view'); + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + [$serviceDatabase, $server] = $resolved; + $containerName = $serviceDatabase->name.'-'.$serviceDatabase->service->uuid; + if (getContainerStatus($server, $containerName) !== 'running') { + return response()->json(['message' => 'Service database container is not running.'], 400); + } + + $lines = (int) ($request->query('lines', 100) ?: 100); + + return response()->json([ + 'logs' => getContainerLogs($server, $containerName, $lines), + ]); + } + + #[OA\Post( + summary: 'Start or redeploy service database container', + description: 'Run docker compose up for a single compose database.', + path: '/services/{uuid}/databases/{database_uuid}/start', + operationId: 'start-service-database-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)), + new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)), + ], + responses: [ + new OA\Response(response: 200, description: 'Deploy request queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] + public function start(Request $request): JsonResponse + { + $resolved = $this->resolveDatabaseRequest($request, 'deploy'); + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + [$serviceDatabase] = $resolved; + DeployServiceApplication::dispatch( + $serviceDatabase, + $request->boolean('latest'), + $request->boolean('force'), + ); + + return response()->json(['message' => 'Service database deploy request queued.']); + } + + #[OA\Post( + summary: 'Restart service database container', + description: 'Restart a compose database container.', + path: '/services/{uuid}/databases/{database_uuid}/restart', + operationId: 'restart-service-database-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Restart queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] + public function restart(Request $request): JsonResponse + { + $resolved = $this->resolveDatabaseRequest($request, 'deploy'); + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + [$serviceDatabase] = $resolved; + RestartServiceApplication::dispatch($serviceDatabase); + + return response()->json(['message' => 'Service database restart request queued.']); + } + + #[OA\Post( + summary: 'Stop service database container', + description: 'Stop a compose database container.', + path: '/services/{uuid}/databases/{database_uuid}/stop', + operationId: 'stop-service-database-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Stop queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] + public function stop(Request $request): JsonResponse + { + $resolved = $this->resolveDatabaseRequest($request, 'deploy'); + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + [$serviceDatabase] = $resolved; + StopServiceApplication::dispatch($serviceDatabase); + + return response()->json(['message' => 'Service database stop request queued.']); + } + + private function resolveDatabaseRequest(Request $request, string $ability): array|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceDatabase = $this->resolveServiceDatabase($request, $service); + if (! $serviceDatabase) { + return response()->json(['message' => 'Service database not found.'], 404); + } + + $this->authorize($ability, $serviceDatabase); + + $server = $serviceDatabase->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + if (! $server->isFunctional()) { + return response()->json(['message' => 'Server is not functional.'], 400); + } + + return [$serviceDatabase, $server]; + } +} diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 69801f985..603d11a7f 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -33,6 +33,13 @@ class ServiceDatabase extends BaseModel ]; protected $casts = [ + 'exclude_from_status' => 'boolean', + 'is_public' => 'boolean', + 'is_log_drain_enabled' => 'boolean', + 'is_include_timestamps' => 'boolean', + 'is_gzip_enabled' => 'boolean', + 'is_stripprefix_enabled' => 'boolean', + 'public_port' => 'integer', 'public_port_timeout' => 'integer', ]; diff --git a/app/Policies/ServiceDatabasePolicy.php b/app/Policies/ServiceDatabasePolicy.php index 88cb15115..8af168ab2 100644 --- a/app/Policies/ServiceDatabasePolicy.php +++ b/app/Policies/ServiceDatabasePolicy.php @@ -32,6 +32,14 @@ class ServiceDatabasePolicy return Gate::allows('update', $serviceDatabase->service); } + /** + * Determine whether the user can deploy or run lifecycle actions on the parent service stack. + */ + public function deploy(User $user, ServiceDatabase $serviceDatabase): bool + { + return Gate::allows('deploy', $serviceDatabase->service); + } + /** * Determine whether the user can delete the model. */ diff --git a/routes/api.php b/routes/api.php index e9fee3e55..a33af37c8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -16,6 +16,7 @@ use App\Http\Controllers\Api\SecurityController; use App\Http\Controllers\Api\SentinelController; use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServiceApplicationsController; +use App\Http\Controllers\Api\ServiceDatabasesController; use App\Http\Controllers\Api\ServicesController; use App\Http\Controllers\Api\TagsController; use App\Http\Controllers\Api\TeamController; @@ -245,6 +246,14 @@ Route::group([ Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/restart', [ServiceApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/stop', [ServiceApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); + Route::get('/services/{uuid}/databases', [ServiceDatabasesController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'update'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/databases/{database_uuid}/logs', [ServiceDatabasesController::class, 'logs'])->middleware(['api.ability:read']); + Route::post('/services/{uuid}/databases/{database_uuid}/start', [ServiceDatabasesController::class, 'start'])->middleware(['api.ability:deploy']); + Route::post('/services/{uuid}/databases/{database_uuid}/restart', [ServiceDatabasesController::class, 'restart'])->middleware(['api.ability:deploy']); + Route::post('/services/{uuid}/databases/{database_uuid}/stop', [ServiceDatabasesController::class, 'stop'])->middleware(['api.ability:deploy']); + Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); diff --git a/tests/Feature/ServiceDatabasesApiTest.php b/tests/Feature/ServiceDatabasesApiTest.php new file mode 100644 index 000000000..f0e033e92 --- /dev/null +++ b/tests/Feature/ServiceDatabasesApiTest.php @@ -0,0 +1,165 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->bearerToken = createBearerTokenForServiceDatabasesApiTest($this->user, $this->team); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $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 createBearerTokenForServiceDatabasesApiTest(User $user, Team $team, array $abilities = ['*']): string +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +function createServiceWithDatabaseForApiTest(object $context): object +{ + $service = Service::factory()->create([ + 'environment_id' => $context->environment->id, + 'server_id' => $context->server->id, + 'destination_id' => $context->destination->id, + 'destination_type' => $context->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n postgres:\n image: postgres:17-alpine\n", + ]); + + $serviceDatabase = ServiceDatabase::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'postgres', + 'service_id' => $service->id, + 'image' => 'postgres:17-alpine', + 'is_public' => false, + ]); + + return (object) ['service' => $service, 'serviceDatabase' => $serviceDatabase]; +} + +test('lists and gets databases belonging to a service', function () { + $context = createServiceWithDatabaseForApiTest($this); + $headers = ['Authorization' => 'Bearer '.$this->bearerToken]; + + $this->withHeaders($headers) + ->getJson("/api/v1/services/{$context->service->uuid}/databases") + ->assertOk() + ->assertJsonFragment(['uuid' => $context->serviceDatabase->uuid]); + + $this->withHeaders($headers) + ->getJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}") + ->assertOk() + ->assertJsonFragment(['name' => 'postgres']); +}); + +test('does not return a database from another service', function () { + $context = createServiceWithDatabaseForApiTest($this); + $otherContext = createServiceWithDatabaseForApiTest($this); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) + ->getJson("/api/v1/services/{$context->service->uuid}/databases/{$otherContext->serviceDatabase->uuid}") + ->assertNotFound() + ->assertJsonFragment(['message' => 'Service database not found.']); +}); + +test('updates supported service database fields and rejects unknown fields', function () { + $context = createServiceWithDatabaseForApiTest($this); + $url = "/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}"; + $headers = ['Authorization' => 'Bearer '.$this->bearerToken]; + + $this->withHeaders($headers)->patchJson($url, [ + 'human_name' => 'Primary database', + 'description' => 'Stores production data', + 'public_port' => 5432, + 'public_port_timeout' => 1800, + 'exclude_from_status' => true, + ])->assertOk()->assertJsonFragment([ + 'human_name' => 'Primary database', + 'public_port' => 5432, + ]); + + $context->serviceDatabase->refresh(); + expect($context->serviceDatabase->human_name)->toBe('Primary database') + ->and($context->serviceDatabase->public_port_timeout)->toBe(1800) + ->and($context->serviceDatabase->exclude_from_status)->toBeTrue(); + + $this->withHeaders($headers) + ->patchJson($url, ['unsupported' => true]) + ->assertUnprocessable() + ->assertJsonPath('errors.unsupported.0', 'This field is not allowed.'); +}); + +test('queues lifecycle actions for a service database', function () { + $context = createServiceWithDatabaseForApiTest($this); + $baseUrl = "/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}"; + $headers = ['Authorization' => 'Bearer '.$this->bearerToken]; + + $this->withHeaders($headers)->postJson("{$baseUrl}/start?latest=1&force=1") + ->assertOk() + ->assertJsonFragment(['message' => 'Service database deploy request queued.']); + DeployServiceApplication::assertPushed(); + + $this->withHeaders($headers)->postJson("{$baseUrl}/restart") + ->assertOk() + ->assertJsonFragment(['message' => 'Service database restart request queued.']); + RestartServiceApplication::assertPushed(); + + $this->withHeaders($headers)->postJson("{$baseUrl}/stop") + ->assertOk() + ->assertJsonFragment(['message' => 'Service database stop request queued.']); + StopServiceApplication::assertPushed(); +}); + +test('requires deploy ability for service database lifecycle actions', function () { + $context = createServiceWithDatabaseForApiTest($this); + $writeToken = createBearerTokenForServiceDatabasesApiTest($this->user, $this->team, ['write']); + + $this->withHeaders(['Authorization' => 'Bearer '.$writeToken]) + ->postJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}/restart") + ->assertForbidden(); +}); + +test('returns a clear error when logs cannot be read from the server', function () { + $context = createServiceWithDatabaseForApiTest($this); + $this->server->settings->update(['is_reachable' => false]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) + ->getJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}/logs") + ->assertBadRequest() + ->assertJsonFragment(['message' => 'Server is not functional.']); +}); diff --git a/tests/Unit/ApplicationOpenApiTest.php b/tests/Unit/ApplicationOpenApiTest.php new file mode 100644 index 000000000..549aee854 --- /dev/null +++ b/tests/Unit/ApplicationOpenApiTest.php @@ -0,0 +1,27 @@ +getAttributes(Schema::class)[0] + ->newInstance(); + + $openApi = json_decode( + file_get_contents(__DIR__.'/../../openapi.json'), + true, + flags: JSON_THROW_ON_ERROR, + ); + + $settingsProperty = collect($schema->properties) + ->first(fn (mixed $property): bool => $property instanceof Property + && $property->property === 'settings'); + + expect($settingsProperty) + ->not->toBeNull() + ->and($openApi['components']['schemas']['Application']['properties']) + ->toHaveKey('settings') + ->not->toHaveKey(''); +}); diff --git a/tests/Unit/ServiceApplicationsOpenApiTest.php b/tests/Unit/ServiceApplicationsOpenApiTest.php new file mode 100644 index 000000000..dec4d10c4 --- /dev/null +++ b/tests/Unit/ServiceApplicationsOpenApiTest.php @@ -0,0 +1,32 @@ +toHaveKeys(['get', 'post']) + ->and($openApi['paths'][$path]['post']['responses']) + ->toHaveKeys(['200', '400', '401', '404', '501']); + } + + expect($openApi['paths'][$actionPaths[0]]['post']['responses']['200']['content']['application/json']['schema']['properties']) + ->toHaveKey('logs') + ->and($openApi['paths'][$actionPaths[1]]['post']['responses']['200']['content']['application/json']['schema']['properties']) + ->toHaveKey('message') + ->and($openApi['paths'][$actionPaths[2]]['post']['responses']['200']['content']['application/json']['schema']['properties']) + ->toHaveKey('message') + ->and($openApi['paths'][$actionPaths[3]]['post']['responses']['200']['content']['application/json']['schema']['properties']) + ->toHaveKey('message'); +}); diff --git a/tests/Unit/ServiceDatabasesOpenApiTest.php b/tests/Unit/ServiceDatabasesOpenApiTest.php new file mode 100644 index 000000000..1fb2b143d --- /dev/null +++ b/tests/Unit/ServiceDatabasesOpenApiTest.php @@ -0,0 +1,29 @@ +toHaveKey('/services/{uuid}/databases') + ->toHaveKey('/services/{uuid}/databases/{database_uuid}') + ->toHaveKey('/services/{uuid}/databases/{database_uuid}/logs') + ->toHaveKey('/services/{uuid}/databases/{database_uuid}/start') + ->toHaveKey('/services/{uuid}/databases/{database_uuid}/restart') + ->toHaveKey('/services/{uuid}/databases/{database_uuid}/stop') + ->and($openApi['paths']['/services/{uuid}/databases']['get']) + ->toBeArray() + ->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}']) + ->toHaveKeys(['get', 'patch']) + ->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/logs']['get']) + ->toBeArray() + ->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/start']['post']) + ->toBeArray() + ->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/restart']['post']) + ->toBeArray() + ->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/stop']['post']) + ->toBeArray(); +}); diff --git a/tests/Unit/ServicesOpenApiTest.php b/tests/Unit/ServicesOpenApiTest.php new file mode 100644 index 000000000..1786ce1a6 --- /dev/null +++ b/tests/Unit/ServicesOpenApiTest.php @@ -0,0 +1,28 @@ +getAttributes(Patch::class)[0]->newInstance(); + $documentedProperties = $patch->requestBody->content[0]->schema->properties; + + $openApi = json_decode( + file_get_contents(__DIR__.'/../../openapi.json'), + true, + flags: JSON_THROW_ON_ERROR, + ); + $generatedProperties = $openApi['paths']['/services/{uuid}']['patch']['requestBody']['content']['application/json']['schema']['properties']; + + $unsupportedFields = [ + 'project_uuid', + 'environment_name', + 'environment_uuid', + 'server_uuid', + 'destination_uuid', + ]; + + expect(array_keys($generatedProperties))->toBe(array_keys($documentedProperties)) + ->and(array_intersect($unsupportedFields, array_keys($documentedProperties)))->toBeEmpty(); +}); From 2719d6604244208f4464542ad49df9470df92e9f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:57:52 +0200 Subject: [PATCH 13/15] feat: add ClickHouse backups and cloud ops tools Enable scheduled ClickHouse backups across the job, API, and UI, and guard unsupported database types via isBackupSolutionAvailable(). Convert Stripe subscription sync from a job to an action with clearer discrepancy resolution, and add cloud:export-users plus cloud:cleanup-unverified-users with tests. --- .../Stripe/SyncStripeSubscriptions.php} | 115 ++++--- .../Commands/Cloud/CleanupUnverifiedUsers.php | 83 +++++ app/Console/Commands/Cloud/ExportUsers.php | 127 ++++++++ .../Cloud/SyncStripeSubscriptions.php | 34 +- .../Controllers/Api/DatabasesController.php | 10 +- app/Jobs/DatabaseBackupJob.php | 50 ++- .../Project/Database/Backup/Index.php | 8 +- .../Database/CreateScheduledBackup.php | 8 + app/Models/StandaloneClickhouse.php | 2 +- app/Models/Team.php | 8 +- app/Support/ClickhouseBackupCommand.php | 37 +++ .../views/livewire/admin/index.blade.php | 2 +- .../project/database/backup-edit.blade.php | 4 + .../project/database/heading.blade.php | 13 +- tests/Feature/AdminSubscriptionStatusTest.php | 47 +++ .../Api/DatabaseBackupCreationApiTest.php | 54 +++- .../CleanupUnverifiedUsersCommandTest.php | 154 +++++++++ tests/Feature/CloudExportUsersCommandTest.php | 101 ++++++ .../CreateScheduledBackupValidationTest.php | 48 +++ tests/Feature/ScheduleOnOneServerTest.php | 10 + .../SyncStripeSubscriptionsActionTest.php | 296 ++++++++++++++++++ .../TeamSubscriptionEndedTest.php | 22 ++ tests/Unit/ClickhouseBackupCommandTest.php | 36 +++ 23 files changed, 1198 insertions(+), 71 deletions(-) rename app/{Jobs/SyncStripeSubscriptionsJob.php => Actions/Stripe/SyncStripeSubscriptions.php} (61%) create mode 100644 app/Console/Commands/Cloud/CleanupUnverifiedUsers.php create mode 100644 app/Console/Commands/Cloud/ExportUsers.php create mode 100644 app/Support/ClickhouseBackupCommand.php create mode 100644 tests/Feature/AdminSubscriptionStatusTest.php create mode 100644 tests/Feature/CleanupUnverifiedUsersCommandTest.php create mode 100644 tests/Feature/CloudExportUsersCommandTest.php create mode 100644 tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php create mode 100644 tests/Unit/ClickhouseBackupCommandTest.php diff --git a/app/Jobs/SyncStripeSubscriptionsJob.php b/app/Actions/Stripe/SyncStripeSubscriptions.php similarity index 61% rename from app/Jobs/SyncStripeSubscriptionsJob.php rename to app/Actions/Stripe/SyncStripeSubscriptions.php index 572d6e78c..977007961 100644 --- a/app/Jobs/SyncStripeSubscriptionsJob.php +++ b/app/Actions/Stripe/SyncStripeSubscriptions.php @@ -1,30 +1,18 @@ onQueue('high'); - } - - public function handle(?\Closure $onProgress = null): array + public function handle(bool $fix = false, ?\Closure $onProgress = null): array { if (! isCloud() || ! isStripe()) { return ['error' => 'Not running on Cloud or Stripe not configured']; @@ -34,7 +22,9 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue ->where('stripe_invoice_paid', true) ->get(); - $stripe = app(StripeClient::class); + $stripe = app()->bound(StripeClient::class) + ? app(StripeClient::class) + : new StripeClient(config('subscription.stripe_api_key')); // Bulk fetch all valid subscription IDs from Stripe (active + past_due) $validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress); @@ -43,13 +33,20 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue $staleSubscriptions = $subscriptions->filter( fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds) ); + $staleSubscriptionCount = $staleSubscriptions->count(); + + $onProgress?->__invoke('checking', 0, $staleSubscriptionCount); // For each stale subscription, get the exact Stripe status and check for resubscriptions $discrepancies = []; $resubscribed = []; $errors = []; + $fixedCount = 0; + $manualReviewCount = 0; + + foreach ($staleSubscriptions->values() as $index => $subscription) { + $onProgress?->__invoke('checking', $index + 1, $staleSubscriptionCount); - foreach ($staleSubscriptions as $subscription) { try { $stripeSubscription = $stripe->subscriptions->retrieve( $subscription->stripe_subscription_id @@ -66,8 +63,18 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue continue; } - // Check if this user resubscribed under a different customer/subscription + if (in_array($stripeStatus, self::VALID_STRIPE_STATUSES, true)) { + continue; + } + $activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer); + $validReplacement = Subscription::query() + ->where('team_id', $subscription->team_id) + ->where('id', '!=', $subscription->id) + ->where('stripe_invoice_paid', true) + ->whereIn('stripe_subscription_id', $validStripeIds) + ->first(); + if ($activeSub) { $resubscribed[] = [ 'subscription_id' => $subscription->id, @@ -78,33 +85,69 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue 'new_stripe_subscription_id' => $activeSub['subscription_id'], 'new_stripe_customer_id' => $activeSub['customer_id'], 'new_status' => $activeSub['status'], + 'linked_to_team' => $validReplacement?->stripe_subscription_id === $activeSub['subscription_id'], ]; - - continue; } + $inactiveSubscription = null; + if (! $validReplacement && ! $activeSub) { + $inactiveSubscription = Subscription::query() + ->where('team_id', $subscription->team_id) + ->where('id', '!=', $subscription->id) + ->where('stripe_invoice_paid', false) + ->first(); + } + + $resolution = match (true) { + (bool) $validReplacement => 'delete_stale', + (bool) $activeSub => 'manual_review', + (bool) $inactiveSubscription => 'delete_stale', + default => 'end_subscription', + }; + $discrepancies[] = [ 'subscription_id' => $subscription->id, 'team_id' => $subscription->team_id, 'stripe_subscription_id' => $subscription->stripe_subscription_id, 'stripe_status' => $stripeStatus, + 'resolution' => $resolution, ]; - if ($this->fix) { - $subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_past_due' => false, - ]); + if ($fix) { + $team = $subscription->team; - if ($stripeStatus === 'canceled') { - $subscription->team?->subscriptionEnded(); + if ($resolution === 'manual_review') { + $manualReviewCount++; + + continue; } + + if ($resolution === 'delete_stale') { + if (! $validReplacement && $inactiveSubscription && $team) { + $team->subscriptionEnded($inactiveSubscription); + } + + $subscription->delete(); + $fixedCount++; + + continue; + } + + if ($team) { + $team->subscriptionEnded($subscription); + } else { + $subscription->update([ + 'stripe_invoice_paid' => false, + 'stripe_past_due' => false, + ]); + } + $fixedCount++; } } - if ($this->fix && count($discrepancies) > 0) { + if ($fix && $fixedCount > 0) { send_internal_notification( - 'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n". + "SyncStripeSubscriptions: Fixed {$fixedCount} discrepancies:\n". json_encode($discrepancies, JSON_PRETTY_PRINT) ); } @@ -114,7 +157,9 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue 'discrepancies' => $discrepancies, 'resubscribed' => $resubscribed, 'errors' => $errors, - 'fixed' => $this->fix, + 'fixed' => $fix, + 'fixed_count' => $fixedCount, + 'manual_review_count' => $manualReviewCount, ]; } @@ -183,13 +228,13 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue $validIds = []; $fetched = 0; - foreach (['active', 'past_due'] as $status) { + foreach (self::VALID_STRIPE_STATUSES as $status) { foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) { $validIds[] = $sub->id; $fetched++; if ($onProgress) { - $onProgress($fetched); + $onProgress('fetching', $fetched, null); } } } diff --git a/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php b/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php new file mode 100644 index 000000000..bf63edce5 --- /dev/null +++ b/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php @@ -0,0 +1,83 @@ +error('This command can only be run on Coolify Cloud.'); + + return self::FAILURE; + } + + $eligibleUsers = $this->eligibleUsers(); + $eligibleCount = $eligibleUsers->count(); + + $this->info("Found {$eligibleCount} ".Str::plural('unverified user', $eligibleCount).' eligible for deletion.'); + $shouldDelete = (bool) $this->option('yes'); + + if (! $shouldDelete) { + $this->warn('Dry run only. Use --yes to delete eligible users.'); + } + + $deletedCount = 0; + + if ($eligibleCount > 0) { + $progressAction = $shouldDelete ? 'Deleting' : 'Checking'; + $progressBar = $this->output->createProgressBar($eligibleCount); + $progressBar->setFormat("{$progressAction} eligible users: %current%/%max% [%bar%] %percent:3s%%"); + $progressBar->start(); + + foreach ($eligibleUsers->lazyById(100) as $user) { + if ($shouldDelete && $user->delete()) { + $deletedCount++; + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + } + + if ($shouldDelete) { + $this->info("Deleted {$deletedCount} ".Str::plural('unverified user', $deletedCount).'.'); + } + + return self::SUCCESS; + } + + private function eligibleUsers(): Builder + { + return User::query() + ->where('id', '!=', 0) + ->whereNull('email_verified_at') + ->whereDoesntHave('teams', fn (Builder $query) => $query->whereKey(0)) + ->whereDoesntHave('teams.subscription') + ->whereDoesntHave('teams.servers') + ->whereDoesntHave('teams', function (Builder $query) { + $query->whereHas('projects.applications') + ->orWhereHas('projects.postgresqls') + ->orWhereHas('projects.redis') + ->orWhereHas('projects.mongodbs') + ->orWhereHas('projects.mysqls') + ->orWhereHas('projects.mariadbs') + ->orWhereHas('projects.keydbs') + ->orWhereHas('projects.dragonflies') + ->orWhereHas('projects.clickhouses') + ->orWhereHas('projects.services'); + }); + } +} diff --git a/app/Console/Commands/Cloud/ExportUsers.php b/app/Console/Commands/Cloud/ExportUsers.php new file mode 100644 index 000000000..5e383c4ce --- /dev/null +++ b/app/Console/Commands/Cloud/ExportUsers.php @@ -0,0 +1,127 @@ +error('This command can only be run on Coolify Cloud.'); + + return self::FAILURE; + } + + $backups = Storage::disk('backups'); + $backups->delete('cloud-users.csv'); + + $subscribedPath = $backups->path('cloud-users-subscribed.csv'); + $unsubscribedPath = $backups->path('cloud-users-unsubscribed.csv'); + $subscribedOutput = fopen($subscribedPath, 'wb'); + + if ($subscribedOutput === false) { + $this->error("Unable to open {$subscribedPath} for writing."); + + return self::FAILURE; + } + + $unsubscribedOutput = fopen($unsubscribedPath, 'wb'); + + if ($unsubscribedOutput === false) { + fclose($subscribedOutput); + $this->error("Unable to open {$unsubscribedPath} for writing."); + + return self::FAILURE; + } + + $subscribedCount = 0; + $unsubscribedCount = 0; + + try { + $header = [ + 'email', + 'first_name', + 'last_name', + 'lifetime_value_currency', + 'lifetime_value_amount', + 'utm_campaign', + 'utm_source', + 'utm_medium', + 'utm_content', + 'utm_term', + 'phone', + ]; + + $this->writeCsvRow($subscribedOutput, $header); + $this->writeCsvRow($unsubscribedOutput, $header); + + foreach (User::query() + ->select(['id', 'email', 'name']) + ->where('id', '!=', 0) + ->whereNotNull('email_verified_at') + ->withExists([ + 'teams as is_subscribed' => fn ($query) => $query + ->whereRelation('subscription', 'stripe_invoice_paid', true), + ]) + ->lazyById(500) as $user) { + $nameParts = preg_split('/\s+/u', trim((string) $user->name), 2) ?: []; + [$firstName, $lastName] = array_pad($nameParts, 2, ''); + + $row = [ + $user->email, + $firstName, + $lastName, + '', + '', + '', + '', + '', + '', + '', + '', + ]; + + if ($user->is_subscribed) { + $this->writeCsvRow($subscribedOutput, $row); + $subscribedCount++; + } else { + $this->writeCsvRow($unsubscribedOutput, $row); + $unsubscribedCount++; + } + } + } catch (Throwable $exception) { + $this->error("Unable to export users: {$exception->getMessage()}"); + + return self::FAILURE; + } finally { + fclose($subscribedOutput); + fclose($unsubscribedOutput); + } + + $this->info("Exported {$subscribedCount} subscribed verified users to {$subscribedPath}"); + $this->info("Exported {$unsubscribedCount} unsubscribed verified users to {$unsubscribedPath}"); + + return self::SUCCESS; + } + + /** + * @param resource $output + * @param array $fields + */ + private function writeCsvRow($output, array $fields): void + { + if (fputcsv($output, $fields, ',', '"', '') === false) { + throw new RuntimeException('Unable to write the CSV file.'); + } + } +} diff --git a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php index 46f6b4edd..cedacfbeb 100644 --- a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php +++ b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php @@ -2,7 +2,7 @@ namespace App\Console\Commands\Cloud; -use App\Jobs\SyncStripeSubscriptionsJob; +use App\Actions\Stripe\SyncStripeSubscriptions as SyncStripeSubscriptionsAction; use Illuminate\Console\Command; class SyncStripeSubscriptions extends Command @@ -35,14 +35,18 @@ class SyncStripeSubscriptions extends Command $this->newLine(); - $job = new SyncStripeSubscriptionsJob($fix); - $fetched = 0; - $result = $job->handle(function (int $count) use (&$fetched): void { - $fetched = $count; - $this->output->write("\r Fetching subscriptions from Stripe... {$fetched}"); + $progressShown = false; + $result = SyncStripeSubscriptionsAction::run($fix, function (string $stage, int $current, ?int $total) use (&$progressShown): void { + $progressShown = true; + $message = match ($stage) { + 'checking' => " Checking stale subscriptions against Stripe... {$current}/{$total}", + default => " Fetching valid subscriptions from Stripe... {$current}", + }; + + $this->output->write("\r".str_pad($message, 80)); }); - if ($fetched > 0) { - $this->output->write("\r".str_repeat(' ', 60)."\r"); + if ($progressShown) { + $this->output->write("\r".str_repeat(' ', 80)."\r"); } if (isset($result['error'])) { @@ -63,13 +67,22 @@ class SyncStripeSubscriptions extends Command $this->line(" Team ID: {$discrepancy['team_id']}"); $this->line(" Stripe ID: {$discrepancy['stripe_subscription_id']}"); $this->line(" Stripe Status: {$discrepancy['stripe_status']}"); + $resolution = match ($discrepancy['resolution']) { + 'delete_stale' => 'Delete stale local row', + 'manual_review' => 'Manual review required', + default => 'End local subscription', + }; + $this->line(" Resolution: {$resolution}"); $this->newLine(); } if ($fix) { - $this->info('All discrepancies have been fixed.'); + $this->info("Automatic corrections applied: {$result['fixed_count']}"); + if ($result['manual_review_count'] > 0) { + $this->warn("Skipped for manual review: {$result['manual_review_count']}"); + } } else { - $this->comment('Run with --fix to correct these discrepancies.'); + $this->comment('Run with --fix to apply automatic corrections.'); } } else { $this->info('No discrepancies found. All subscriptions are in sync.'); @@ -84,6 +97,7 @@ class SyncStripeSubscriptions extends Command $this->line(" - Team ID: {$resub['team_id']} | Email: {$resub['email']}"); $this->line(" Old: {$resub['old_stripe_subscription_id']} (cus: {$resub['old_stripe_customer_id']})"); $this->line(" New: {$resub['new_stripe_subscription_id']} (cus: {$resub['new_stripe_customer_id']}) [{$resub['new_status']}]"); + $this->line(' Linked to this team: '.($resub['linked_to_team'] ? 'Yes' : 'No')); $this->newLine(); } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 3761effd7..d06e2eac5 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -850,6 +850,12 @@ class DatabasesController extends Controller $this->authorize('manageBackups', $database); + if (! $database->isBackupSolutionAvailable()) { + return response()->json([ + 'message' => 'Scheduled backups are not supported for this database type.', + ], 422); + } + // Validate frequency is a valid cron expression $isValid = validate_cron_expression($request->frequency); if (! $isValid) { @@ -915,6 +921,8 @@ class DatabasesController extends Controller $backupData['databases_to_backup'] = $database->mysql_database; } elseif ($database->type() === 'standalone-mariadb') { $backupData['databases_to_backup'] = $database->mariadb_database; + } elseif ($database->type() === 'standalone-clickhouse') { + $backupData['databases_to_backup'] = $database->clickhouse_db; } } @@ -3016,7 +3024,7 @@ class DatabasesController extends Controller ), ] )] - public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + public function move_by_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index e9e1f9105..f25158d34 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -8,6 +8,7 @@ use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; use App\Models\Server; use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneMariadb; use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; @@ -17,6 +18,7 @@ use App\Notifications\Database\BackupFailed; use App\Notifications\Database\BackupSuccess; use App\Notifications\Database\BackupSuccessWithS3Warning; use App\Rules\SafeWebhookUrl; +use App\Support\ClickhouseBackupCommand; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -39,7 +41,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public Server $server; - public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|ServiceDatabase $database; + public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneClickhouse|ServiceDatabase $database; public ?string $container_name = null; @@ -271,6 +273,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $databasesToBackup = [$this->database->mysql_database]; } elseif (str($databaseType)->contains('mariadb')) { $databasesToBackup = [$this->database->mariadb_database]; + } elseif ($this->database instanceof StandaloneClickhouse) { + $databasesToBackup = [$this->database->clickhouse_db]; } else { return; } @@ -294,6 +298,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue // Format: db1,db2,db3 $databasesToBackup = explode(',', $databasesToBackup); $databasesToBackup = array_map('trim', $databasesToBackup); + } elseif ($this->database instanceof StandaloneClickhouse) { + // Format: db1,db2,db3 + $databasesToBackup = explode(',', $databasesToBackup); + $databasesToBackup = array_map('trim', $databasesToBackup); } else { return; } @@ -386,6 +394,17 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'local_storage_deleted' => false, ]); $this->backup_standalone_mariadb($database); + } elseif ($this->database instanceof StandaloneClickhouse) { + $this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip"; + $this->backup_location = $this->backup_dir.$this->backup_file; + $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, + 'database_name' => $database, + 'filename' => $this->backup_location, + 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, + ]); + $this->backup_standalone_clickhouse($database); } else { throw new \Exception('Unsupported database type'); } @@ -400,6 +419,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } catch (Throwable $e) { // Local backup failed + if ($this->database instanceof StandaloneClickhouse) { + deleteBackupsLocally($this->backup_location, $this->server); + } if ($this->backup_log) { $this->backup_log->update([ 'status' => 'failed', @@ -642,6 +664,32 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } + private function backup_standalone_clickhouse(string $database): void + { + $archiveName = ltrim($this->backup_file, '/'); + + try { + $commands = ClickhouseBackupCommand::make( + containerName: $this->container_name, + database: $database, + archiveName: $archiveName, + backupDirectory: $this->backup_dir, + ); + + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = trim($this->backup_output); + if ($this->backup_output === '') { + $this->backup_output = null; + } + } catch (Throwable $e) { + $this->add_to_error_output($e->getMessage()); + throw $e; + } finally { + $cleanupCommand = ClickhouseBackupCommand::cleanup($this->container_name, $archiveName); + instant_remote_process([$cleanupCommand], $this->server, false, false, null, disableMultiplexing: true); + } + } + private function add_to_backup_output($output): void { if ($this->backup_output) { diff --git a/app/Livewire/Project/Database/Backup/Index.php b/app/Livewire/Project/Database/Backup/Index.php index 2df32ec7b..71380754f 100644 --- a/app/Livewire/Project/Database/Backup/Index.php +++ b/app/Livewire/Project/Database/Backup/Index.php @@ -22,13 +22,7 @@ class Index extends Component if (! $database) { return redirect()->route('dashboard'); } - // No backups - if ( - $database->getMorphClass() === \App\Models\StandaloneRedis::class || - $database->getMorphClass() === \App\Models\StandaloneKeydb::class || - $database->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $database->getMorphClass() === \App\Models\StandaloneClickhouse::class - ) { + if (! $database->isBackupSolutionAvailable()) { return redirect()->route('project.database.configuration', [ 'project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 7384adcff..49065711b 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -48,6 +48,12 @@ class CreateScheduledBackup extends Component try { $this->authorize('manageBackups', $this->database); + if (! $this->database->isBackupSolutionAvailable()) { + $this->dispatch('error', 'Scheduled backups are not supported for this database type.'); + + return; + } + $this->validate(); if ($this->saveToS3) { @@ -87,6 +93,8 @@ class CreateScheduledBackup extends Component $payload['databases_to_backup'] = $this->database->mysql_database; } elseif ($this->database->type() === 'standalone-mariadb') { $payload['databases_to_backup'] = $this->database->mariadb_database; + } elseif ($this->database->type() === 'standalone-clickhouse') { + $payload['databases_to_backup'] = $this->database->clickhouse_db; } $databaseBackup = ScheduledDatabaseBackup::create($payload); diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 9db5f21b7..7ca45cc3b 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -370,6 +370,6 @@ class StandaloneClickhouse extends BaseModel public function isBackupSolutionAvailable() { - return false; + return true; } } diff --git a/app/Models/Team.php b/app/Models/Team.php index a979b44fb..2b468bf4c 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -219,13 +219,15 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen $this->getNotificationSettings('webhook')?->isEnabled(); } - public function subscriptionEnded() + public function subscriptionEnded(?Subscription $subscription = null): void { - if (! $this->subscription) { + $subscription ??= $this->subscription; + + if (! $subscription) { return; } - $this->subscription->update([ + $subscription->update([ 'stripe_subscription_id' => null, 'stripe_cancel_at_period_end' => false, 'stripe_invoice_paid' => false, diff --git a/app/Support/ClickhouseBackupCommand.php b/app/Support/ClickhouseBackupCommand.php new file mode 100644 index 000000000..69e93d5c3 --- /dev/null +++ b/app/Support/ClickhouseBackupCommand.php @@ -0,0 +1,37 @@ + */ + public static function make( + string $containerName, + string $database, + string $archiveName, + string $backupDirectory, + ): array { + validateShellSafePath($database, 'database name'); + validateFilenameSafe($archiveName, 'ClickHouse backup archive'); + + $backupDirectory = rtrim($backupDirectory, '/'); + $containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName; + $backupLocation = $backupDirectory.'/'.$archiveName; + $query = "BACKUP DATABASE `{$database}` TO File('{$archiveName}')"; + + return [ + 'mkdir -p '.escapeshellarg($backupDirectory), + 'docker exec '.escapeshellarg($containerName).' clickhouse-client --query '.escapeshellarg($query), + 'docker cp '.escapeshellarg($containerName.':'.$containerBackupPath).' '.escapeshellarg($backupLocation), + ]; + } + + public static function cleanup(string $containerName, string $archiveName): string + { + validateFilenameSafe($archiveName, 'ClickHouse backup archive'); + + $containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName; + + return 'docker exec '.escapeshellarg($containerName).' rm -f '.escapeshellarg($containerBackupPath); + } +} diff --git a/resources/views/livewire/admin/index.blade.php b/resources/views/livewire/admin/index.blade.php index acba3acce..8abb92413 100644 --- a/resources/views/livewire/admin/index.blade.php +++ b/resources/views/livewire/admin/index.blade.php @@ -22,7 +22,7 @@
{{ $user->name }}
{{ $user->email }}
Active: - {{ $user->teams()->whereRelation('subscription', 'stripe_subscription_id', '!=', null)->exists() ? 'Yes' : 'No' }} + {{ $user->teams()->whereRelation('subscription', 'stripe_invoice_paid', true)->exists() ? 'Yes' : 'No' }}
diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index 4f810d755..515241364 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -97,6 +97,10 @@ helper="Comma separated list of databases to backup. Empty will include the default one." id="databasesToBackup" /> @endif + @elseif($backup->database_type === 'App\Models\StandaloneClickhouse') + @endif
diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index ca4fcf1ea..66af4895f 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -8,12 +8,7 @@ 'label' => 'Backups', 'route' => 'project.database.backup.index', 'active' => request()->routeIs('project.database.backup.index', 'project.database.backup.execution'), - 'visible' => in_array($database->getMorphClass(), [ - 'App\Models\StandalonePostgresql', - 'App\Models\StandaloneMongodb', - 'App\Models\StandaloneMysql', - 'App\Models\StandaloneMariadb', - ]), + 'visible' => $database->isBackupSolutionAvailable(), ], ]; @@ -200,11 +195,7 @@ Terminal @endcan - @if ( - $database->getMorphClass() === 'App\Models\StandalonePostgresql' || - $database->getMorphClass() === 'App\Models\StandaloneMongodb' || - $database->getMorphClass() === 'App\Models\StandaloneMysql' || - $database->getMorphClass() === 'App\Models\StandaloneMariadb') + @if ($database->isBackupSolutionAvailable()) Backups diff --git a/tests/Feature/AdminSubscriptionStatusTest.php b/tests/Feature/AdminSubscriptionStatusTest.php new file mode 100644 index 000000000..94e160935 --- /dev/null +++ b/tests/Feature/AdminSubscriptionStatusTest.php @@ -0,0 +1,47 @@ +set('cache.default', 'array'); + config()->set('constants.coolify.self_hosted', false); + + InstanceSettings::unguarded( + fn () => InstanceSettings::query()->firstOrCreate(['id' => 0]) + ); + + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + $rootUser = User::find(0) ?? User::factory()->create(['id' => 0]); + $rootTeam->members()->syncWithoutDetaching([ + $rootUser->id => ['role' => 'admin'], + ]); + + $inactiveUser = User::factory()->create(['email' => 'inactive@example.com']); + $inactiveTeam = Team::factory()->create(); + $inactiveTeam->members()->attach($inactiveUser->id, ['role' => 'owner']); + Subscription::create([ + 'team_id' => $inactiveTeam->id, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_invoice_paid' => false, + ]); + + $this->actingAs($rootUser); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(AdminIndex::class) + ->set([ + 'foundUsers' => collect(), + 'search' => 'inactive@example.com', + ]) + ->call('submitSearch') + ->assertSee('No') + ->assertDontSee('Yes'); +}); diff --git a/tests/Feature/Api/DatabaseBackupCreationApiTest.php b/tests/Feature/Api/DatabaseBackupCreationApiTest.php index 8882fec39..cb0c5cf73 100644 --- a/tests/Feature/Api/DatabaseBackupCreationApiTest.php +++ b/tests/Feature/Api/DatabaseBackupCreationApiTest.php @@ -5,8 +5,10 @@ use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -15,7 +17,7 @@ use Illuminate\Support\Str; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::updateOrCreate(['id' => 0]); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); @@ -77,6 +79,56 @@ function backupHeaders(): array } describe('POST /api/v1/databases/{uuid}/backups', function () { + test('rejects backup configurations for unsupported database types', function () { + $database = StandaloneRedis::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Redis DB', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$database->uuid}/backups", [ + 'frequency' => 'daily', + ]); + + $response->assertUnprocessable() + ->assertJson([ + 'message' => 'Scheduled backups are not supported for this database type.', + ]); + + expect(ScheduledDatabaseBackup::count())->toBe(0); + }); + + test('defaults clickhouse backups to its configured database', function () { + $database = StandaloneClickhouse::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'ClickHouse DB', + 'clickhouse_admin_user' => 'default', + 'clickhouse_admin_password' => 'password', + 'clickhouse_db' => 'analytics', + 'image' => 'clickhouse/clickhouse-server:25.11', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$database->uuid}/backups", [ + 'frequency' => 'daily', + ]); + + $response->assertCreated(); + + $backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($backup->databases_to_backup)->toBe('analytics') + ->and($backup->database_type)->toBe(StandaloneClickhouse::class); + }); + test('creates backup configuration with valid frequency', function () { $response = $this->withHeaders(backupHeaders()) ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ diff --git a/tests/Feature/CleanupUnverifiedUsersCommandTest.php b/tests/Feature/CleanupUnverifiedUsersCommandTest.php new file mode 100644 index 000000000..39d5c67ba --- /dev/null +++ b/tests/Feature/CleanupUnverifiedUsersCommandTest.php @@ -0,0 +1,154 @@ +set('constants.coolify.self_hosted', false); +}); + +test('it previews eligible unverified users without deleting them', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'unverified@example.com', + ]); + + $this->artisan('cloud:cleanup-unverified-users') + ->expectsOutput('Found 1 unverified user eligible for deletion.') + ->expectsOutput('Dry run only. Use --yes to delete eligible users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it reports dry run progress', function () { + $users = User::factory()->unverified()->count(2)->create(); + + $exitCode = Artisan::call('cloud:cleanup-unverified-users'); + + expect($exitCode)->toBe(0) + ->and(Artisan::output())->toContain('Checking eligible users: 2/2'); + + $users->each(fn (User $user) => $this->assertModelExists($user)); +}); + +test('it deletes eligible unverified users with the yes option', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'unverified@example.com', + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 1 unverified user.') + ->assertSuccessful(); + + $this->assertModelMissing($user); +}); + +test('it reports deletion progress', function () { + User::factory()->unverified()->count(2)->create(); + + $exitCode = Artisan::call('cloud:cleanup-unverified-users', ['--yes' => true]); + + expect($exitCode)->toBe(0) + ->and(Artisan::output())->toContain('Deleting eligible users: 2/2'); +}); + +test('it keeps verified users', function () { + $user = User::factory()->create([ + 'email' => 'verified@example.com', + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified users with a Stripe subscription record', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'subscribed@example.com', + ]); + + Subscription::create([ + 'team_id' => $user->teams()->firstOrFail()->id, + 'stripe_invoice_paid' => false, + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified users with defined resources', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'resource-owner@example.com', + ]); + + $project = Project::factory()->create([ + 'team_id' => $user->teams()->firstOrFail()->id, + ]); + $environment = Environment::factory()->create([ + 'project_id' => $project->id, + ]); + Application::factory()->create([ + 'environment_id' => $environment->id, + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified users with servers', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'server-owner@example.com', + ]); + + Server::factory()->create([ + 'team_id' => $user->teams()->firstOrFail()->id, + ]); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it keeps unverified root team members', function () { + $user = User::factory()->unverified()->create([ + 'email' => 'root-member@example.com', + ]); + $rootTeam = Team::factory()->create([ + 'id' => 0, + 'name' => 'Root Team', + ]); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->artisan('cloud:cleanup-unverified-users', ['--yes' => true]) + ->expectsOutput('Deleted 0 unverified users.') + ->assertSuccessful(); + + $this->assertModelExists($user); +}); + +test('it only runs on Coolify Cloud', function () { + config()->set('constants.coolify.self_hosted', true); + + $this->artisan('cloud:cleanup-unverified-users') + ->expectsOutput('This command can only be run on Coolify Cloud.') + ->assertFailed(); +}); diff --git a/tests/Feature/CloudExportUsersCommandTest.php b/tests/Feature/CloudExportUsersCommandTest.php new file mode 100644 index 000000000..45dda4959 --- /dev/null +++ b/tests/Feature/CloudExportUsersCommandTest.php @@ -0,0 +1,101 @@ +set('constants.coolify.self_hosted', false); +}); + +test('it exports subscribed and unsubscribed verified users to separate files without tags', function () { + User::factory()->create([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'root@example.com', + ]); + + $subscribedUser = User::factory()->create([ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + ]); + + Subscription::create([ + 'team_id' => $subscribedUser->teams()->firstOrFail()->id, + 'stripe_invoice_paid' => true, + 'stripe_subscription_id' => 'sub_active', + ]); + + User::factory()->create([ + 'name' => 'Grace', + 'email' => 'grace@example.com', + ]); + + User::factory()->unverified()->create([ + 'name' => 'Unverified User', + 'email' => 'unverified@example.com', + ]); + + Storage::disk('backups')->put('cloud-users.csv', 'old export'); + + $this->artisan('cloud:export-users')->assertSuccessful(); + + Storage::disk('backups')->assertExists([ + 'cloud-users-subscribed.csv', + 'cloud-users-unsubscribed.csv', + ]); + Storage::disk('backups')->assertMissing('cloud-users.csv'); + + $readCsv = function (string $filename): array { + $output = fopen(Storage::disk('backups')->path($filename), 'rb'); + $rows = []; + + while (($row = fgetcsv($output, null, ',', '"', '')) !== false) { + $rows[] = $row; + } + + fclose($output); + + return $rows; + }; + + $header = [ + 'email', + 'first_name', + 'last_name', + 'lifetime_value_currency', + 'lifetime_value_amount', + 'utm_campaign', + 'utm_source', + 'utm_medium', + 'utm_content', + 'utm_term', + 'phone', + ]; + + expect($readCsv('cloud-users-subscribed.csv'))->toBe([ + $header, + ['ada@example.com', 'Ada', 'Lovelace', '', '', '', '', '', '', '', ''], + ])->and($readCsv('cloud-users-unsubscribed.csv'))->toBe([ + $header, + ['grace@example.com', 'Grace', '', '', '', '', '', '', '', '', ''], + ]); +}); + +test('it only runs on Coolify Cloud', function () { + config()->set('constants.coolify.self_hosted', true); + + $this->artisan('cloud:export-users') + ->expectsOutput('This command can only be run on Coolify Cloud.') + ->assertFailed(); + + Storage::disk('backups')->assertMissing([ + 'cloud-users.csv', + 'cloud-users-subscribed.csv', + 'cloud-users-unsubscribed.csv', + ]); +}); diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php index 4b5eec24c..56a4cf479 100644 --- a/tests/Feature/CreateScheduledBackupValidationTest.php +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -6,8 +6,10 @@ use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -136,3 +138,49 @@ it('creates a scheduled backup with a valid team-owned S3 storage', function () expect($backup->save_s3)->toBeTruthy(); expect($backup->s3_storage_id)->toBe($s3->id); }); + +it('creates a clickhouse backup for its configured database', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandaloneClickhouse::create([ + 'name' => 'clickhouse-scheduled-backup', + 'clickhouse_admin_user' => 'default', + 'clickhouse_admin_password' => 'password', + 'clickhouse_db' => 'analytics', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', 'daily') + ->call('submit') + ->assertDispatched('refreshScheduledBackups'); + + $backup = ScheduledDatabaseBackup::firstOrFail(); + + expect($backup->database_type)->toBe(StandaloneClickhouse::class) + ->and($backup->databases_to_backup)->toBe('analytics'); +}); + +it('rejects scheduled backups for unsupported database types', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandaloneRedis::create([ + 'name' => 'redis-without-backups', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', 'daily') + ->call('submit') + ->assertDispatched('error'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); +}); diff --git a/tests/Feature/ScheduleOnOneServerTest.php b/tests/Feature/ScheduleOnOneServerTest.php index 167d16bfa..49d592689 100644 --- a/tests/Feature/ScheduleOnOneServerTest.php +++ b/tests/Feature/ScheduleOnOneServerTest.php @@ -48,3 +48,13 @@ it('schedules every production job with onOneServer', function () { ); }); }); + +it('does not schedule Stripe subscription reconciliation automatically', function () { + $schedule = app(Schedule::class); + + $event = collect($schedule->events())->first( + fn ($event) => str_contains((string) $event->description, 'SyncStripeSubscriptions') + ); + + expect($event)->toBeNull(); +}); diff --git a/tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php b/tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php new file mode 100644 index 000000000..31070053d --- /dev/null +++ b/tests/Feature/Subscription/SyncStripeSubscriptionsActionTest.php @@ -0,0 +1,296 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + + $this->team = Team::factory()->create(); + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_customer_id' => 'cus_stale', + 'stripe_invoice_paid' => true, + ]); +}); + +afterEach(function () { + SyncStripeSubscriptions::clearFake(); +}); + +function stripeSubscriptionCollection(array $subscriptions = []): StripeCollection +{ + return StripeCollection::constructFrom([ + 'data' => $subscriptions, + 'has_more' => false, + 'url' => '/v1/subscriptions', + ]); +} + +test('fix mode fully ends a locally active subscription with an invalid Stripe status', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'unreachable_count' => 0, + 'unreachable_notification_sent' => false, + ]); + + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'unpaid', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable')); + + $this->instance(StripeClient::class, $stripe); + + $progress = []; + + SyncStripeSubscriptions::run( + fix: true, + onProgress: function (string $stage, int $current, ?int $total) use (&$progress): void { + $progress[] = [$stage, $current, $total]; + }, + ); + + $this->subscription->refresh(); + $server->refresh(); + + expect($this->subscription->stripe_invoice_paid)->toBeFalsy() + ->and($this->subscription->stripe_subscription_id)->toBeNull() + ->and($server->unreachable_count)->toBe(3) + ->and($server->unreachable_notification_sent)->toBeTrue() + ->and($progress)->toContain(['checking', 0, 1]) + ->and($progress)->toContain(['checking', 1, 1]); +}); + +test('a same-email subscription not linked to the team is left for manual review', function () { + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all') + ->with(Mockery::on(fn (array $parameters) => isset($parameters['status']))) + ->twice() + ->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'canceled', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [ + 'email' => 'customer@example.com', + ]); + $customers->shouldReceive('all')->andReturn((object) [ + 'data' => [(object) ['id' => 'cus_active']], + ]); + $subscriptions->shouldReceive('all')->with([ + 'customer' => 'cus_active', + 'limit' => 10, + ])->andReturn((object) [ + 'data' => [(object) [ + 'id' => 'sub_active_elsewhere', + 'status' => 'active', + ]], + ]); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + $this->subscription->refresh(); + + expect($result['resubscribed'])->toHaveCount(1) + ->and($result['discrepancies'])->toHaveCount(1) + ->and($result['discrepancies'][0]['resolution'])->toBe('manual_review') + ->and($this->subscription->stripe_invoice_paid)->toBeTruthy() + ->and($this->subscription->stripe_subscription_id)->toBe('sub_stale'); +}); + +test('a stale local subscription is deleted when its team has a valid replacement', function () { + $replacement = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_active_elsewhere', + 'stripe_customer_id' => 'cus_active', + 'stripe_invoice_paid' => true, + ]); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all') + ->with(['status' => 'active', 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection([ + ['id' => 'sub_active_elsewhere'], + ])); + $subscriptions->shouldReceive('all') + ->with(['status' => 'past_due', 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'canceled', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andReturn((object) [ + 'email' => 'customer@example.com', + ]); + $customers->shouldReceive('all')->andReturn((object) [ + 'data' => [(object) ['id' => 'cus_active']], + ]); + $subscriptions->shouldReceive('all')->with([ + 'customer' => 'cus_active', + 'limit' => 10, + ])->andReturn((object) [ + 'data' => [(object) [ + 'id' => 'sub_active_elsewhere', + 'status' => 'active', + ]], + ]); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale') + ->and($this->subscription->fresh())->toBeNull() + ->and($replacement->fresh())->not->toBeNull() + ->and($replacement->fresh()->stripe_invoice_paid)->toBeTruthy() + ->and($server->settings->fresh()->is_reachable)->toBeTruthy() + ->and($server->settings->fresh()->is_usable)->toBeTruthy(); +}); + +test('a stale duplicate is deleted when its team already has an inactive subscription row', function () { + $inactiveSubscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_customer_id' => 'cus_stale', + 'stripe_invoice_paid' => false, + ]); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $customers = Mockery::mock(CustomerService::class); + $stripe->subscriptions = $subscriptions; + $stripe->customers = $customers; + + $subscriptions->shouldReceive('all')->twice()->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldReceive('retrieve')->with('sub_stale')->andReturn((object) [ + 'status' => 'canceled', + 'customer' => 'cus_stale', + ]); + $customers->shouldReceive('retrieve')->with('cus_stale')->andThrow(new RuntimeException('Customer unavailable')); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + expect($result['discrepancies'][0]['resolution'])->toBe('delete_stale') + ->and($this->subscription->fresh())->toBeNull() + ->and($inactiveSubscription->fresh())->not->toBeNull() + ->and($server->settings->fresh()->is_reachable)->toBeFalsy() + ->and($server->settings->fresh()->is_usable)->toBeFalsy(); +}); + +test('valid Stripe subscriptions remain active', function (string $status) { + $stripe = Mockery::mock(StripeClient::class); + $subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions = $subscriptions; + + $subscriptions->shouldReceive('all') + ->with(['status' => $status, 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection([ + ['id' => 'sub_stale'], + ])); + $subscriptions->shouldReceive('all') + ->with(['status' => $status === 'active' ? 'past_due' : 'active', 'limit' => 100]) + ->andReturn(stripeSubscriptionCollection()); + $subscriptions->shouldNotReceive('retrieve'); + + $this->instance(StripeClient::class, $stripe); + + $result = SyncStripeSubscriptions::run(fix: true); + + $this->subscription->refresh(); + + expect($result['discrepancies'])->toBeEmpty() + ->and($this->subscription->stripe_invoice_paid)->toBeTruthy() + ->and($this->subscription->stripe_subscription_id)->toBe('sub_stale'); +})->with(['active', 'past_due']); + +test('the terminal command runs the reconciliation action synchronously', function () { + SyncStripeSubscriptions::shouldRun() + ->once() + ->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === false && $onProgress instanceof Closure) + ->andReturnUsing(function (bool $fix, Closure $onProgress): array { + $onProgress('checking', 2, 10); + + return [ + 'total_checked' => 0, + 'discrepancies' => [], + 'resubscribed' => [], + 'errors' => [], + 'fixed' => false, + ]; + }); + + $this->artisan('cloud:sync-stripe-subscriptions') + ->expectsOutputToContain('Checking stale subscriptions against Stripe... 2/10') + ->expectsOutput('Total subscriptions checked: 0') + ->assertSuccessful(); +}); + +test('the terminal command passes the fix option to the reconciliation action', function () { + SyncStripeSubscriptions::shouldRun() + ->once() + ->withArgs(fn (bool $fix, ?Closure $onProgress) => $fix === true && $onProgress instanceof Closure) + ->andReturn([ + 'total_checked' => 1, + 'discrepancies' => [[ + 'subscription_id' => 1, + 'team_id' => 2, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_status' => 'canceled', + 'resolution' => 'manual_review', + ]], + 'resubscribed' => [], + 'errors' => [], + 'fixed' => true, + 'fixed_count' => 0, + 'manual_review_count' => 1, + ]); + + $this->artisan('cloud:sync-stripe-subscriptions', ['--fix' => true]) + ->expectsOutput('Total subscriptions checked: 1') + ->expectsOutput(' Resolution: Manual review required') + ->expectsOutput('Automatic corrections applied: 0') + ->expectsOutput('Skipped for manual review: 1') + ->assertSuccessful(); +}); diff --git a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php index 55d59e0e6..bc0f0a6f2 100644 --- a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php +++ b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php @@ -1,5 +1,6 @@ toBeTrue(); }); + +test('subscriptionEnded updates the exact subscription when duplicate rows exist', function () { + $team = Team::factory()->create(); + $otherSubscription = Subscription::create([ + 'team_id' => $team->id, + 'stripe_subscription_id' => 'sub_other', + 'stripe_invoice_paid' => true, + ]); + $subscriptionToEnd = Subscription::create([ + 'team_id' => $team->id, + 'stripe_subscription_id' => 'sub_stale', + 'stripe_invoice_paid' => true, + ]); + + $team->subscriptionEnded($subscriptionToEnd); + + expect($subscriptionToEnd->fresh()->stripe_subscription_id)->toBeNull() + ->and($subscriptionToEnd->fresh()->stripe_invoice_paid)->toBeFalsy() + ->and($otherSubscription->fresh()->stripe_subscription_id)->toBe('sub_other') + ->and($otherSubscription->fresh()->stripe_invoice_paid)->toBeTruthy(); +}); diff --git a/tests/Unit/ClickhouseBackupCommandTest.php b/tests/Unit/ClickhouseBackupCommandTest.php new file mode 100644 index 000000000..0130a9dd6 --- /dev/null +++ b/tests/Unit/ClickhouseBackupCommandTest.php @@ -0,0 +1,36 @@ +isBackupSolutionAvailable())->toBeTrue(); +}); + +test('builds a native clickhouse backup command using container credentials', function () { + $commands = ClickhouseBackupCommand::make( + containerName: 'clickhouse-uuid', + database: 'analytics', + archiveName: 'backup-uuid.zip', + backupDirectory: '/data/coolify/backups/clickhouse', + ); + + expect($commands)->toHaveCount(3) + ->and($commands[0])->toBe("mkdir -p '/data/coolify/backups/clickhouse'") + ->and($commands[1])->toContain("docker exec 'clickhouse-uuid' clickhouse-client") + ->and($commands[1])->not->toContain('--password') + ->and($commands[1])->toContain("BACKUP DATABASE `analytics` TO File('") + ->and($commands[1])->toContain('backup-uuid.zip') + ->and($commands[2])->toBe("docker cp 'clickhouse-uuid:/var/lib/clickhouse/backups/backup-uuid.zip' '/data/coolify/backups/clickhouse/backup-uuid.zip'") + ->and(ClickhouseBackupCommand::cleanup('clickhouse-uuid', 'backup-uuid.zip')) + ->toBe("docker exec 'clickhouse-uuid' rm -f '/var/lib/clickhouse/backups/backup-uuid.zip'"); +}); + +test('rejects unsafe clickhouse database names', function () { + expect(fn () => ClickhouseBackupCommand::make( + containerName: 'clickhouse-uuid', + database: 'analytics`; DROP DATABASE default; --', + archiveName: 'backup-uuid.zip', + backupDirectory: '/data/coolify/backups/clickhouse', + ))->toThrow(Exception::class); +}); From 7d699818e8f7395ab95471980bcbd889cf9dcf28 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:42:53 +0200 Subject: [PATCH 14/15] fix: harden Vultr create, Gmail identity, and provider retries Wrap Vultr server creation in DB transactions and delete the remote instance when local persistence fails (API and Livewire). Scope plus/dot email normalization to gmail.com/googlemail.com only. Use throw:false on DigitalOcean/Vultr HTTP retries, and normalize service log line counts via normalizeLogLines. --- .../Api/ServiceApplicationsController.php | 2 +- .../Api/ServiceDatabasesController.php | 2 +- app/Http/Controllers/Api/VultrController.php | 90 +++++++++++------ app/Livewire/Server/New/ByVultr.php | 60 ++++++++---- app/Services/DigitalOceanService.php | 2 +- app/Services/VultrService.php | 2 +- bootstrap/helpers/email.php | 9 +- tests/Feature/ForgotPasswordRateLimitTest.php | 25 ++++- tests/Feature/RegistrationRateLimitTest.php | 33 ++++++- tests/Feature/VultrApiTest.php | 98 +++++++++++++++++++ tests/Feature/VultrServerCreationTest.php | 51 ++++++++++ tests/Unit/Api/LogEndpointHelpersTest.php | 14 +++ tests/Unit/DigitalOceanServiceTest.php | 48 +++++++++ tests/Unit/EmailIdentityHelperTest.php | 8 +- tests/Unit/VultrServiceTest.php | 48 +++++++++ 15 files changed, 429 insertions(+), 63 deletions(-) diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index 74b6c9db1..e1df34903 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -490,7 +490,7 @@ class ServiceApplicationsController extends Controller ], 400); } - $lines = (int) ($request->query('lines', 100) ?: 100); + $lines = normalizeLogLines($request->query('lines')); $logs = getContainerLogs($server, $containerName, $lines); return response()->json([ diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index 7befc3273..480ff4e55 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -311,7 +311,7 @@ class ServiceDatabasesController extends Controller return response()->json(['message' => 'Service database container is not running.'], 400); } - $lines = (int) ($request->query('lines', 100) ?: 100); + $lines = normalizeLogLines($request->query('lines')); return response()->json([ 'logs' => getContainerLogs($server, $containerName, $lines), diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php index 60b9d0a0a..51fad6a0b 100644 --- a/app/Http/Controllers/Api/VultrController.php +++ b/app/Http/Controllers/Api/VultrController.php @@ -15,6 +15,7 @@ use App\Rules\ValidHostname; use App\Services\VultrService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; class VultrController extends Controller @@ -286,6 +287,10 @@ class VultrController extends Controller return response()->json(['message' => 'Private key not found.'], 404); } + $vultrService = null; + $vultrInstanceId = null; + $server = null; + try { $vultrService = new VultrService($token->token); $publicKey = $privateKey->getPublicKey(); @@ -317,33 +322,41 @@ class VultrController extends Controller } $vultrInstance = $vultrService->createInstance($params); + $vultrInstanceId = (string) $vultrInstance['id']; $ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP; - $server = Server::create([ - 'name' => $normalizedServerName, - 'ip' => $ipAddress, - 'user' => 'root', - 'port' => 22, - 'team_id' => $teamId, - 'private_key_id' => $privateKey->id, - 'cloud_provider_token_id' => $token->id, - 'vultr_instance_id' => $vultrInstance['id'], - 'vultr_instance_status' => $vultrInstance['status'] ?? null, - ]); - - $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); - $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); - if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { - $ipAddress = $assignedIpAddress; - $server->update([ - 'ip' => $assignedIpAddress, - 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + $server = DB::transaction(function () use ($normalizedServerName, $ipAddress, $teamId, $privateKey, $token, $vultrInstanceId, $vultrInstance): Server { + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'vultr_instance_id' => $vultrInstanceId, + 'vultr_instance_status' => $vultrInstance['status'] ?? null, ]); - } - $server->proxy->set('status', 'exited'); - $server->proxy->set('type', ProxyTypes::TRAEFIK->value); - $server->save(); + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return $server; + }); + + try { + $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); + $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); + if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { + $server->update([ + 'ip' => $assignedIpAddress, + 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + ]); + } + } catch (\Throwable $e) { + report($e); + } if ($request->instant_validate) { ValidateServer::dispatch($server); @@ -353,27 +366,48 @@ class VultrController extends Controller 'team_id' => $teamId, 'server_uuid' => $server->uuid, 'server_name' => $server->name, - 'vultr_instance_id' => $vultrInstance['id'], - 'ip' => $ipAddress, + 'vultr_instance_id' => $vultrInstanceId, + 'ip' => $server->ip, ]); return response()->json([ 'uuid' => $server->uuid, - 'vultr_instance_id' => $vultrInstance['id'], - 'ip' => $ipAddress, + 'vultr_instance_id' => $vultrInstanceId, + 'ip' => $server->ip, ])->setStatusCode(201); } catch (RateLimitException $e) { + $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); + $response = response()->json(['message' => $e->getMessage()], 429); if ($e->retryAfter !== null) { $response->header('Retry-After', $e->retryAfter); } return $response; - } catch (\Throwable) { + } catch (\Throwable $e) { + $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); + + logger()->error('Failed to create Vultr server', [ + 'error' => $e->getMessage(), + ]); + return response()->json(['message' => 'Failed to create Vultr server.'], 500); } } + private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void + { + if (! $vultrService || ! $vultrInstanceId || $server) { + return; + } + + try { + $vultrService->deleteInstance($vultrInstanceId); + } catch (\Throwable $e) { + report($e); + } + } + private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array { $normalizedPublicKey = $this->normalizePublicKey($publicKey); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index fd0aa4c34..4246d5836 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -14,6 +14,7 @@ use App\Services\VultrService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Attributes\Locked; use Livewire\Component; @@ -377,9 +378,8 @@ class ByVultr extends Component return "{$providerName} API error: {$details}"; } - private function createVultrServer(string $token): array + private function createVultrServer(VultrService $vultrService): array { - $vultrService = new VultrService($token); $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); $publicKey = $privateKey->getPublicKey(); $existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey); @@ -419,6 +419,10 @@ class ByVultr extends Component return null; } + $vultrService = null; + $vultrInstanceId = null; + $server = null; + try { $this->authorize('create', Server::class); @@ -437,20 +441,29 @@ class ByVultr extends Component } $vultrService = new VultrService($this->getVultrToken()); - $vultrInstance = $this->createVultrServer($this->getVultrToken()); + $vultrInstance = $this->createVultrServer($vultrService); + $vultrInstanceId = (string) $vultrInstance['id']; $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP; - $server = Server::create([ - 'name' => strtolower(trim($this->server_name)), - 'ip' => $ipAddress, - 'user' => 'root', - 'port' => 22, - 'team_id' => currentTeam()->id, - 'private_key_id' => $this->private_key_id, - 'cloud_provider_token_id' => $this->selected_token_id, - 'vultr_instance_id' => $vultrInstance['id'], - 'vultr_instance_status' => $vultrInstance['status'] ?? null, - ]); + $server = DB::transaction(function () use ($ipAddress, $vultrInstanceId, $vultrInstance): Server { + $server = Server::create([ + 'name' => strtolower(trim($this->server_name)), + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'vultr_instance_id' => $vultrInstanceId, + 'vultr_instance_status' => $vultrInstance['status'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return $server; + }); try { $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); @@ -466,10 +479,6 @@ class ByVultr extends Component report($e); } - $server->proxy->set('status', 'exited'); - $server->proxy->set('type', ProxyTypes::TRAEFIK->value); - $server->save(); - if ($this->from_onboarding) { currentTeam()->update([ 'show_boarding' => false, @@ -479,10 +488,25 @@ class ByVultr extends Component return redirectRoute($this, 'server.show', [$server->uuid]); } catch (\Throwable $e) { + $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); + return handleError($e, $this); } } + private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void + { + if (! $vultrService || ! $vultrInstanceId || $server) { + return; + } + + try { + $vultrService->deleteInstance($vultrInstanceId); + } catch (\Throwable $e) { + report($e); + } + } + public function render() { return view('livewire.server.new.by-vultr'); diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php index c8292e864..da1af942f 100644 --- a/app/Services/DigitalOceanService.php +++ b/app/Services/DigitalOceanService.php @@ -28,7 +28,7 @@ class DigitalOceanService } return $attempt * 100; - }) + }, throw: false) ->{$method}($this->baseUrl.$endpoint, $data); if (! $response->successful()) { diff --git a/app/Services/VultrService.php b/app/Services/VultrService.php index 0e335d3e0..347b7e3fb 100644 --- a/app/Services/VultrService.php +++ b/app/Services/VultrService.php @@ -17,7 +17,7 @@ class VultrService 'Authorization' => 'Bearer '.$this->token, ]) ->timeout(30) - ->retry(3, fn (int $attempt) => $attempt * 100) + ->retry(3, fn (int $attempt) => $attempt * 100, throw: false) ->{$method}($this->baseUrl.$endpoint, $data); if (! $response->successful()) { diff --git a/bootstrap/helpers/email.php b/bootstrap/helpers/email.php index a0b8ba67f..a4a311e04 100644 --- a/bootstrap/helpers/email.php +++ b/bootstrap/helpers/email.php @@ -8,9 +8,12 @@ function normalize_email_identity(?string $email): ?string return null; } - [$localPart, $domain] = explode('@', Str::lower($email), 2); - $localPart = Str::before($localPart, '+'); - $localPart = str_replace('.', '', $localPart); + [$localPart, $domain] = explode('@', Str::lower(trim($email)), 2); + + if (in_array($domain, ['gmail.com', 'googlemail.com'], true)) { + $localPart = Str::before($localPart, '+'); + $localPart = str_replace('.', '', $localPart); + } if (blank($localPart) || blank($domain)) { return null; diff --git a/tests/Feature/ForgotPasswordRateLimitTest.php b/tests/Feature/ForgotPasswordRateLimitTest.php index aaff953ba..02dbce650 100644 --- a/tests/Feature/ForgotPasswordRateLimitTest.php +++ b/tests/Feature/ForgotPasswordRateLimitTest.php @@ -27,9 +27,9 @@ it('rate limits repeated forgot password attempts from the same ip', function () it('rate limits dotted plus-address forgot password variants of the same email identity across ips', function () { $emails = [ - 'ke.vinmcfadden+one@btinternet.com', - 'kevin.mcfadden+two@btinternet.com', - 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com', + 'ke.vinmcfadden+one@gmail.com', + 'kevin.mcfadden+two@gmail.com', + 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com', ]; foreach ($emails as $index => $email) { @@ -42,7 +42,24 @@ it('rate limits dotted plus-address forgot password variants of the same email i $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99']) ->post('/forgot-password', [ - 'email' => 'k.evin.mcfadden+four@btinternet.com', + 'email' => 'k.evin.mcfadden+four@gmail.com', ]) ->assertTooManyRequests(); }); + +it('keeps distinct dotted and plus-addressed mailboxes in separate forgot password buckets on ordinary domains', function () { + $emails = [ + 'john.smith@example.com', + 'johnsmith@example.com', + 'johnsmith+one@example.com', + 'johnsmith+two@example.com', + ]; + + foreach ($emails as $index => $email) { + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 120)]) + ->post('/forgot-password', [ + 'email' => $email, + ]) + ->assertSessionHasNoErrors(); + } +}); diff --git a/tests/Feature/RegistrationRateLimitTest.php b/tests/Feature/RegistrationRateLimitTest.php index 6288a85b8..7130ed36e 100644 --- a/tests/Feature/RegistrationRateLimitTest.php +++ b/tests/Feature/RegistrationRateLimitTest.php @@ -3,6 +3,7 @@ use App\Models\InstanceSettings; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\RateLimiter; uses(RefreshDatabase::class); @@ -42,9 +43,9 @@ it('rate limits repeated registration attempts from the same ip', function () { it('rate limits dotted plus-address variants of the same email identity across ips', function () { $emails = [ - 'ke.vinmcfadden+one@btinternet.com', - 'kevin.mcfadden+two@btinternet.com', - 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com', + 'ke.vinmcfadden+one@gmail.com', + 'kevin.mcfadden+two@gmail.com', + 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com', ]; foreach ($emails as $index => $email) { @@ -64,9 +65,33 @@ it('rate limits dotted plus-address variants of the same email identity across i $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99']) ->post('/register', [ 'name' => 'Blocked User', - 'email' => 'k.evin.mcfadden+four@btinternet.com', + 'email' => 'k.evin.mcfadden+four@gmail.com', 'password' => 'Password1!@', 'password_confirmation' => 'Password1!@', ]) ->assertTooManyRequests(); }); + +it('keeps distinct dotted and plus-addressed mailboxes in separate rate limit buckets on ordinary domains', function () { + $registrationIpKey = 'registration:ip:'.sha1('127.0.0.1'); + $emails = [ + 'john.smith@example.com', + 'johnsmith@example.com', + 'johnsmith+one@example.com', + 'johnsmith+two@example.com', + ]; + + foreach ($emails as $index => $email) { + $this->post('/register', [ + 'name' => "Distinct User {$index}", + 'email' => $email, + 'password' => 'Password1!@', + 'password_confirmation' => 'Password1!@', + ]) + ->assertRedirect(); + + auth()->logout(); + $this->flushSession(); + RateLimiter::clear($registrationIpKey); + } +}); diff --git a/tests/Feature/VultrApiTest.php b/tests/Feature/VultrApiTest.php index 9eb135baf..e9c0ee24a 100644 --- a/tests/Feature/VultrApiTest.php +++ b/tests/Feature/VultrApiTest.php @@ -301,6 +301,104 @@ describe('POST /api/v1/servers/vultr', function () { ]); }); + test('deletes the Vultr instance when local server persistence fails', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-persistence-fails', + 'main_ip' => '203.0.113.10', + 'status' => 'pending', + ], + ], 202), + 'https://api.vultr.com/v2/instances/instance-persistence-fails' => Http::response(null, 204), + ]); + + $eventDispatcher = Server::getEventDispatcher(); + Server::setEventDispatcher(clone $eventDispatcher); + Server::created(function (): void { + throw new RuntimeException('local persistence failed'); + }); + + try { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/vultr', [ + 'cloud_provider_token_id' => $this->vultrToken->uuid, + 'region' => 'ewr', + 'plan' => 'vc2-1c-1gb', + 'os_id' => 2284, + 'name' => 'persistence-fails', + 'private_key_uuid' => $this->privateKey->uuid, + ]); + } finally { + Server::setEventDispatcher($eventDispatcher); + } + + $response->assertServerError(); + $response->assertJson(['message' => 'Failed to create Vultr server.']); + $this->assertDatabaseMissing('servers', [ + 'vultr_instance_id' => 'instance-persistence-fails', + ]); + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://api.vultr.com/v2/instances/instance-persistence-fails'); + }); + + test('keeps the tracked Vultr server when public IP polling fails', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-polling-fails', + 'main_ip' => '0.0.0.0', + 'status' => 'pending', + ], + ], 202), + 'https://api.vultr.com/v2/instances/instance-polling-fails' => Http::response([ + 'error' => 'temporary polling failure', + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/vultr', [ + 'cloud_provider_token_id' => $this->vultrToken->uuid, + 'region' => 'ewr', + 'plan' => 'vc2-1c-1gb', + 'os_id' => 2284, + 'name' => 'polling-fails', + 'private_key_uuid' => $this->privateKey->uuid, + ]); + + $response->assertCreated(); + $response->assertJsonFragment([ + 'vultr_instance_id' => 'instance-polling-fails', + 'ip' => Server::PLACEHOLDER_IP, + ]); + $this->assertDatabaseHas('servers', [ + 'name' => 'polling-fails', + 'ip' => Server::PLACEHOLDER_IP, + 'team_id' => $this->team->id, + 'vultr_instance_id' => 'instance-polling-fails', + 'vultr_instance_status' => 'pending', + ]); + Http::assertNotSent(fn ($request) => $request->method() === 'DELETE'); + }); + test('server creation authorizes access to the stored Vultr token', function () { $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']); diff --git a/tests/Feature/VultrServerCreationTest.php b/tests/Feature/VultrServerCreationTest.php index d8e5af321..9474b697f 100644 --- a/tests/Feature/VultrServerCreationTest.php +++ b/tests/Feature/VultrServerCreationTest.php @@ -8,6 +8,7 @@ use App\Models\Server; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Livewire\Livewire; @@ -170,6 +171,56 @@ it('persists the server with a placeholder IP when Vultr has not assigned one ye 'vultr_instance_id' => 'instance-1', 'vultr_instance_status' => 'pending', ]); + + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'DELETE' + && $request->url() === 'https://api.vultr.com/v2/instances/instance-1'); +}); + +it('deletes the Vultr instance when local server persistence fails', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'label' => 'persistence-fails', + 'main_ip' => '192.0.2.10', + 'v6_main_ip' => '2001:db8::1', + 'status' => 'pending', + ], + ], 202), + 'https://api.vultr.com/v2/instances/instance-1' => Http::response(null, 204), + ]); + + $eventDispatcher = Server::getEventDispatcher(); + Server::setEventDispatcher(clone $eventDispatcher); + Server::created(function (): void { + throw new RuntimeException('local persistence failed'); + }); + + try { + Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid]) + ->set('server_name', 'persistence-fails') + ->set('selected_region', 'ewr') + ->set('selected_plan', 'vc2-1c-1gb') + ->set('selected_os_id', 2284) + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertDispatched('error', 'local persistence failed'); + } finally { + Server::setEventDispatcher($eventDispatcher); + } + + $this->assertDatabaseMissing('servers', [ + 'vultr_instance_id' => 'instance-1', + ]); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' + && $request->url() === 'https://api.vultr.com/v2/instances/instance-1'); }); it('requires IPv6 when public IPv4 is disabled', function () { diff --git a/tests/Unit/Api/LogEndpointHelpersTest.php b/tests/Unit/Api/LogEndpointHelpersTest.php index 0b0107e73..a899669d6 100644 --- a/tests/Unit/Api/LogEndpointHelpersTest.php +++ b/tests/Unit/Api/LogEndpointHelpersTest.php @@ -18,6 +18,20 @@ it('normalizes requested log line counts', function () { ->and(normalizeLogLines('50000'))->toBe(10000); }); +it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) { + $source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php"); + + expect($source)->toContain('$lines = normalizeLogLines($request->query(\'lines\'));') + ->and(normalizeLogLines($lines))->toBe($expectedLines); +})->with([ + 'application invalid value' => ['ServiceApplicationsController', 'invalid', 100], + 'application negative value' => ['ServiceApplicationsController', '-5', 100], + 'application value above limit' => ['ServiceApplicationsController', '50000', 10000], + 'database invalid value' => ['ServiceDatabasesController', 'invalid', 100], + 'database negative value' => ['ServiceDatabasesController', '-5', 100], + 'database value above limit' => ['ServiceDatabasesController', '50000', 10000], +]); + it('parses show_timestamps query values as booleans', function () { if (! function_exists('parseLogTimestampFlag')) { expect(function_exists('parseLogTimestampFlag'))->toBeTrue(); diff --git a/tests/Unit/DigitalOceanServiceTest.php b/tests/Unit/DigitalOceanServiceTest.php index 784926838..ad687b546 100644 --- a/tests/Unit/DigitalOceanServiceTest.php +++ b/tests/Unit/DigitalOceanServiceTest.php @@ -1,11 +1,59 @@ Http::response(['message' => $message], $status), + ]); + + $exception = null; + + try { + (new DigitalOceanService('test-token'))->createDroplet([]); + } catch (Throwable $caught) { + $exception = $caught; + } + + expect($exception)->toBeInstanceOf(Exception::class) + ->and($exception)->not->toBeInstanceOf(RequestException::class) + ->and($exception->getMessage())->toBe('DigitalOcean API error: '.$message) + ->and($exception->getCode())->toBe($status); + + Http::assertSentCount(3); +})->with([ + 'unauthorized' => [401, 'Unable to authenticate you'], + 'unprocessable entity' => [422, 'Droplet name is invalid'], +]); + +it('maps a final DigitalOcean rate-limit response with Retry-After', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/droplets' => Http::sequence() + ->push(['message' => 'Temporary provider error'], 500) + ->push(['message' => 'Temporary provider error'], 500) + ->push(['message' => 'Too many requests'], 429, ['Retry-After' => '45']), + ]); + + try { + (new DigitalOceanService('test-token'))->createDroplet([]); + } catch (RateLimitException $exception) { + expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.') + ->and($exception->retryAfter)->toBe(45); + + Http::assertSentCount(3); + + return; + } + + test()->fail('Expected a RateLimitException to be thrown.'); +}); + it('fetches paginated regions from DigitalOcean', function () { Http::fake([ 'https://api.digitalocean.com/v2/regions?page=1&per_page=50' => Http::response([ diff --git a/tests/Unit/EmailIdentityHelperTest.php b/tests/Unit/EmailIdentityHelperTest.php index 475c51009..3b27f6ff6 100644 --- a/tests/Unit/EmailIdentityHelperTest.php +++ b/tests/Unit/EmailIdentityHelperTest.php @@ -1,8 +1,12 @@ toBe('kevinmcfadden@btinternet.com'); - expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@btinternet.com'))->toBe('kevinmcfadden@btinternet.com'); + expect(normalize_email_identity('Ke.VinMcFadden+one@Gmail.com'))->toBe('kevinmcfadden@gmail.com'); + expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@googlemail.com'))->toBe('kevinmcfadden@googlemail.com'); +}); + +it('preserves dots and plus suffixes for ordinary email providers', function () { + expect(normalize_email_identity(' John.Smith+alerts@Example.com '))->toBe('john.smith+alerts@example.com'); }); it('returns null for blank or malformed email identities', function (?string $email) { diff --git a/tests/Unit/VultrServiceTest.php b/tests/Unit/VultrServiceTest.php index a912b8907..dc778e1b2 100644 --- a/tests/Unit/VultrServiceTest.php +++ b/tests/Unit/VultrServiceTest.php @@ -1,6 +1,8 @@ Http::response(['error' => $message], $status), + ]); + + $exception = null; + + try { + (new VultrService('fake-token'))->createInstance([]); + } catch (Throwable $caught) { + $exception = $caught; + } + + expect($exception)->toBeInstanceOf(Exception::class) + ->and($exception)->not->toBeInstanceOf(RequestException::class) + ->and($exception->getMessage())->toBe('Vultr API error: '.$message) + ->and($exception->getCode())->toBe($status); + + Http::assertSentCount(3); +})->with([ + 'unauthorized' => [401, 'Unauthorized'], + 'unprocessable entity' => [422, 'Invalid instance parameters'], +]); + +it('maps a final Vultr rate-limit response with Retry-After', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances' => Http::sequence() + ->push(['error' => 'Temporary provider error'], 500) + ->push(['error' => 'Temporary provider error'], 500) + ->push(['error' => 'Too many requests'], 429, ['Retry-After' => '30']), + ]); + + try { + (new VultrService('fake-token'))->createInstance([]); + } catch (RateLimitException $exception) { + expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.') + ->and($exception->retryAfter)->toBe(30); + + Http::assertSentCount(3); + + return; + } + + test()->fail('Expected a RateLimitException to be thrown.'); +}); + it('gets instances from Vultr API', function () { Http::fake([ 'https://api.vultr.com/v2/instances*' => Http::response([ From 913d033c75b310db7784ccb51b6effd84f0f93a4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:35:23 +0200 Subject: [PATCH 15/15] fix(resources): clarify build server hosting restrictions (#10961) --- .../Api/ApplicationsController.php | 6 + .../Controllers/Api/DatabasesController.php | 6 + .../Controllers/Api/ServersController.php | 7 + .../Controllers/Api/ServicesController.php | 15 +- app/Livewire/Project/CloneMe.php | 20 +- app/Livewire/Project/New/DockerCompose.php | 5 +- app/Livewire/Project/New/DockerImage.php | 5 +- .../Project/New/GithubPrivateRepository.php | 5 +- .../New/GithubPrivateRepositoryDeployKey.php | 5 +- .../Project/New/PublicGitRepository.php | 5 +- app/Livewire/Project/New/Select.php | 14 +- app/Livewire/Project/New/SimpleDockerfile.php | 5 +- app/Livewire/Project/Resource/Create.php | 5 +- app/Livewire/Project/Shared/Destination.php | 5 +- .../Project/Shared/ResourceOperations.php | 19 +- app/Livewire/Server/Show.php | 8 +- app/Models/Server.php | 24 +- app/Models/ServerSetting.php | 1 + bootstrap/helpers/applications.php | 1 + bootstrap/helpers/shared.php | 11 + .../views/livewire/project/clone-me.blade.php | 6 +- .../livewire/project/new/select.blade.php | 40 +- .../shared/resource-operations.blade.php | 8 +- .../ResourceOperationsCrossTenantTest.php | 25 +- tests/Feature/ResourceHostingServerTest.php | 440 ++++++++++++++++++ 25 files changed, 621 insertions(+), 70 deletions(-) create mode 100644 tests/Feature/ResourceHostingServerTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 593207735..468589a1d 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1237,6 +1237,12 @@ class ApplicationsController extends Controller if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + if (! $server->canHostResources()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']], + ], 422); + } $destinations = $server->destinations(); if ($destinations->count() == 0) { return response()->json(['message' => 'Server has no destinations.'], 400); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index d06e2eac5..f2c7f2226 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1813,6 +1813,12 @@ class DatabasesController extends Controller if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + if (! $server->canHostResources()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']], + ], 422); + } $destinations = $server->destinations(); if ($destinations->count() == 0) { return response()->json(['message' => 'Server has no destinations.'], 400); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index fa0017f87..4343971ed 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -736,6 +736,13 @@ class ServersController extends Controller ], 422); } + if ($request->boolean('is_build_server') && ! $server->isBuildServer() && ! $server->isEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['is_build_server' => ['A server with existing resources cannot be configured as a build server.']], + ], 422); + } + $server->update($updateFields); if ($request->has('is_build_server')) { $server->settings()->update([ diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 9fa512c6b..9c074c9a1 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -444,6 +444,12 @@ class ServicesController extends Controller if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + if (! $server->canHostResources()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']], + ], 422); + } $destinations = $server->destinations(); if ($destinations->count() == 0) { return response()->json(['message' => 'Server has no destinations.'], 400); @@ -501,7 +507,8 @@ class ServicesController extends Controller if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) { data_set($servicePayload, 'connect_to_docker_network', true); } - $service = Service::create($servicePayload); + $service = new Service($servicePayload); + $service->save(); $service->name = $request->name ?? "$oneClickServiceName-".$service->uuid; $service->description = $request->description; if ($request->has('is_container_label_escape_enabled')) { @@ -639,6 +646,12 @@ class ServicesController extends Controller if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + if (! $server->canHostResources()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']], + ], 422); + } $destinations = $server->destinations(); if ($destinations->count() == 0) { return response()->json(['message' => 'Server has no destinations.'], 400); diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 0a6e3d8ec..fff2b7fbf 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -34,7 +34,7 @@ class CloneMe extends Component public ?int $selectedServer = null; - public ?int $selectedDestination = null; + public ?string $selectedDestination = null; public ?Server $server = null; @@ -76,9 +76,9 @@ class CloneMe extends Component return view('livewire.project.clone-me'); } - public function selectServer($server_id, $destination_id) + public function selectServer($server_id, $destination_uuid) { - if ($server_id == $this->selectedServer && $destination_id == $this->selectedDestination) { + if ($server_id == $this->selectedServer && $destination_uuid === $this->selectedDestination) { $this->selectedServer = null; $this->selectedDestination = null; $this->server = null; @@ -86,7 +86,7 @@ class CloneMe extends Component return; } $this->selectedServer = $server_id; - $this->selectedDestination = $destination_id; + $this->selectedDestination = $destination_uuid; $this->server = $this->servers->where('id', $server_id)->first(); } @@ -98,6 +98,10 @@ class CloneMe extends Component 'selectedDestination' => 'required', 'newName' => ValidationPatterns::nameRules(), ]); + $selectedDestination = find_resource_destination_for_current_team($this->selectedDestination); + if (! $selectedDestination) { + throw new \Exception('Destination not found.'); + } if ($type === 'project') { $foundProject = Project::where('name', $this->newName)->first(); if ($foundProject) { @@ -130,7 +134,6 @@ class CloneMe extends Component $databases = $this->environment->databases(); $services = $this->environment->services; foreach ($applications as $application) { - $selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first(); clone_application($application, $selectedDestination, [ 'environment_id' => $environment->id, ], $this->cloneVolumeData); @@ -147,7 +150,8 @@ class CloneMe extends Component 'status' => 'exited', 'started_at' => null, 'environment_id' => $environment->id, - 'destination_id' => $this->selectedDestination, + 'destination_id' => $selectedDestination->id, + 'destination_type' => $selectedDestination->getMorphClass(), ]); $newDatabase->save(); @@ -265,7 +269,9 @@ class CloneMe extends Component ])->fill([ 'uuid' => $uuid, 'environment_id' => $environment->id, - 'destination_id' => $this->selectedDestination, + 'destination_id' => $selectedDestination->id, + 'destination_type' => $selectedDestination->getMorphClass(), + 'server_id' => $selectedDestination->server_id, ]); $newService->save(); diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 55ed8941c..2f9264730 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -47,19 +47,20 @@ class DockerCompose extends Component $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); $destination_uuid = $this->query['destination'] ?? null; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); - $service = Service::create([ + $service = new Service([ 'docker_compose_raw' => $this->dockerComposeRaw, 'environment_id' => $environment->id, 'server_id' => $destination->server_id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, ]); + $service->save(); $variables = parseEnvFormatToArray($this->envFile); foreach ($variables as $key => $data) { diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 68ee0d055..ab6063e09 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -115,7 +115,7 @@ class DockerImage extends Component $parser->parse($dockerImage); $destination_uuid = $this->query['destination'] ?? null; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } @@ -133,7 +133,7 @@ class DockerImage extends Component // Determine the image tag based on whether it's a hash or regular tag $imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag(); - $application = Application::create([ + $application = new Application([ 'name' => 'docker-image-'.new_public_id(), 'repository_project_id' => 0, 'git_repository' => 'coollabsio/coolify', @@ -147,6 +147,7 @@ class DockerImage extends Component 'destination_type' => $destination_class, 'health_check_enabled' => false, ]); + $application->save(); $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->update([ diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 35e9b186e..479c2a1f5 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -192,7 +192,7 @@ class GithubPrivateRepository extends Component } $destination_uuid = $this->query['destination'] ?? null; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } @@ -201,7 +201,7 @@ class GithubPrivateRepository extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); - $application = Application::create([ + $application = new Application([ 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), 'repository_project_id' => $this->selected_repository_id, 'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(), @@ -216,6 +216,7 @@ class GithubPrivateRepository extends Component 'source_id' => $this->github_app->id, 'source_type' => $this->github_app->getMorphClass(), ]); + $application->save(); $application->settings->is_static = $this->is_static; $application->settings->save(); diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index d5b4bbef8..fb3c0b2c3 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -136,7 +136,7 @@ class GithubPrivateRepositoryDeployKey extends Component $this->validate(); try { $destination_uuid = $this->query['destination'] ?? null; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } @@ -185,7 +185,8 @@ class GithubPrivateRepositoryDeployKey extends Component $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; } - $application = Application::create($application_init); + $application = new Application($application_init); + $application->save(); $application->settings->is_static = $this->is_static; $application->settings->save(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 4fddd744b..fdae52f7c 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -290,7 +290,7 @@ class PublicGitRepository extends Component $project_uuid = $this->parameters['project_uuid']; $environment_uuid = $this->parameters['environment_uuid']; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } @@ -336,7 +336,8 @@ class PublicGitRepository extends Component $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; } - $application = Application::create($application_init); + $application = new Application($application_init); + $application->save(); $application->settings->is_static = $this->isStatic; $application->settings->save(); diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 34601f5dd..08047fc79 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -24,6 +24,8 @@ class Select extends Component public Collection|null|Server $servers; + public ?Collection $buildServers = null; + public bool $onlyBuildServerAvailable = false; public ?Collection $standaloneDockers; @@ -380,7 +382,7 @@ class Select extends Component return; } - if (count($this->servers) === 1) { + if (count($this->servers) === 1 && $this->buildServers?->isEmpty()) { $server = $this->servers->first(); if ($server instanceof Server) { $this->setServer($server); @@ -452,12 +454,8 @@ class Select extends Component public function loadServers() { $this->servers = Server::isUsable()->get()->sortBy('name'); - $this->allServers = $this->servers; - - if ($this->allServers && $this->allServers->isNotEmpty()) { - $this->onlyBuildServerAvailable = $this->allServers->every(function ($server) { - return $server->isBuildServer(); - }); - } + $this->buildServers = Server::isUsableBuildServer()->get()->sortBy('name'); + $this->allServers = $this->servers->concat($this->buildServers); + $this->onlyBuildServerAvailable = $this->servers->isEmpty() && $this->buildServers->isNotEmpty(); } } diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index 3328c5db3..24f21b4cb 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -38,7 +38,7 @@ CMD ["nginx", "-g", "daemon off;"] 'dockerfile' => 'required', ]); $destination_uuid = $this->query['destination'] ?? null; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } @@ -51,7 +51,7 @@ CMD ["nginx", "-g", "daemon off;"] if (! $port) { $port = 80; } - $application = Application::create([ + $application = new Application([ 'name' => 'dockerfile-'.new_public_id(), 'repository_project_id' => 0, 'git_repository' => 'coollabsio/coolify', @@ -66,6 +66,7 @@ CMD ["nginx", "-g", "daemon off;"] 'source_id' => 0, 'source_type' => GithubApp::class, ]); + $application->save(); $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->update([ diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index e0b45eea0..19ffad55c 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -33,7 +33,7 @@ class Create extends Component return redirect()->route('dashboard'); } if (isset($type) && isset($destination_uuid)) { - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { return redirect()->route('dashboard'); } @@ -96,7 +96,8 @@ class Create extends Component if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) { data_set($service_payload, 'connect_to_docker_network', true); } - $service = Service::create($service_payload); + $service = new Service($service_payload); + $service->save(); $service->name = "$oneClickServiceName-".$service->uuid; $service->save(); if ($oneClickDotEnvs?->count() > 0) { diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 4f3e659da..94fb4b4eb 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -118,9 +118,8 @@ class Destination extends Component $server = Server::ownedByCurrentTeam()->findOrFail($server_id); $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); $this->authorize('update', $this->resource); - $this->resource->getConnection()->transaction(function () use ($network, $server) { - $main_destination = $this->resource->destination; + $mainDestination = $this->resource->destination; $this->resource->update([ 'destination_id' => $network->id, 'destination_type' => StandaloneDocker::class, @@ -128,7 +127,7 @@ class Destination extends Component $this->resource->additional_networks() ->wherePivot('server_id', $server->id) ->detach($network->id); - $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); + $this->resource->additional_networks()->attach($mainDestination->id, ['server_id' => $mainDestination->server->id]); }); $this->resource->refresh(); $this->refreshServers(); diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 02171af8d..ba6a6e03d 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -11,7 +11,6 @@ use App\Models\Application; use App\Models\Environment; use App\Models\Project; use App\Models\StandaloneClickhouse; -use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; use App\Models\StandaloneMariadb; @@ -19,7 +18,6 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; -use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -37,6 +35,8 @@ class ResourceOperations extends Component public $servers; + public $buildServers; + public bool $cloneVolumeData = false; public function mount() @@ -45,7 +45,9 @@ class ResourceOperations extends Component $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentUuid = data_get($parameters, 'environment_uuid'); $this->projects = Project::ownedByCurrentTeamCached(); - $this->servers = currentTeam()->servers->filter(fn ($server) => ! $server->isBuildServer()); + $servers = currentTeam()->servers()->get(); + $this->servers = $servers->reject(fn ($server) => $server->isBuildServer()); + $this->buildServers = $servers->filter(fn ($server) => $server->isBuildServer()); } public function toggleVolumeCloning(bool $value) @@ -53,20 +55,20 @@ class ResourceOperations extends Component $this->cloneVolumeData = $value; } - public function cloneTo($destination_id) + public function cloneTo($destination_uuid) { try { $this->authorize('update', $this->resource); - $new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id); - if (! $new_destination) { - $new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id); - } + $new_destination = find_resource_destination_for_current_team($destination_uuid); if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); } $uuid = new_public_id(); $server = $new_destination->server; + if (! $server->canHostResources()) { + return $this->addError('destination_id', 'The selected server cannot host resources.'); + } if ($this->resource->getMorphClass() === Application::class) { $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); @@ -99,6 +101,7 @@ class ResourceOperations extends Component 'status' => 'exited', 'started_at' => null, 'destination_id' => $new_destination->id, + 'destination_type' => $new_destination->getMorphClass(), ]); $new_resource->save(); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 6cd004362..1090e9892 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -202,7 +202,7 @@ class Show extends Component try { $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); $this->syncData(); - if (! $this->server->isEmpty()) { + if (! $this->server->isBuildServer() && ! $this->server->isEmpty()) { $this->isBuildServerLocked = true; } // Load saved Hetzner status and validation state @@ -409,6 +409,12 @@ class Show extends Component { try { $this->authorize('update', $this->server); + if ($value === true && ! $this->server->isEmpty()) { + $this->isBuildServer = false; + $this->dispatch('error', 'A server with existing resources cannot be configured as a build server.'); + + return; + } if ($value === true && $this->isSentinelEnabled) { $this->isSentinelEnabled = false; $this->isMetricsEnabled = false; diff --git a/app/Models/Server.php b/app/Models/Server.php index d55df0179..3acaf3507 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -509,9 +509,29 @@ class Server extends BaseModel }); } - public static function isUsable() + public static function isUsable(): Builder { - return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_swarm_worker', false)->whereRelation('settings', 'is_build_server', false)->whereRelation('settings', 'force_disabled', false); + return self::usableByBuildServerStatus(false); + } + + public static function isUsableBuildServer(): Builder + { + return self::usableByBuildServerStatus(true); + } + + private static function usableByBuildServerStatus(bool $isBuildServer): Builder + { + return Server::ownedByCurrentTeam() + ->whereRelation('settings', 'is_reachable', true) + ->whereRelation('settings', 'is_usable', true) + ->whereRelation('settings', 'is_swarm_worker', false) + ->whereRelation('settings', 'is_build_server', $isBuildServer) + ->whereRelation('settings', 'force_disabled', false); + } + + public function canHostResources(): bool + { + return ! $this->isBuildServer(); } public function settings() diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index e96aab4a3..0453dc793 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -109,6 +109,7 @@ class ServerSetting extends Model 'sentinel_token' => 'encrypted', 'is_reachable' => 'boolean', 'is_usable' => 'boolean', + 'is_build_server' => 'boolean', 'is_terminal_enabled' => 'boolean', 'disable_application_image_retention' => 'boolean', 'connection_timeout' => 'integer', diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index b7e4af7ab..339a0bcf7 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -220,6 +220,7 @@ function clone_application(Application $source, $destination, array $overrides = 'fqdn' => $url, 'status' => 'exited', 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), ], $overrides)); $newApplication->save(); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 10de1f86f..8900c0cd3 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -535,6 +535,17 @@ function find_destination_for_current_team(?string $uuid): StandaloneDocker|Swar ?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first(); } +function find_resource_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null +{ + $destination = find_destination_for_current_team($uuid); + + if (! $destination?->server?->canHostResources()) { + return null; + } + + return $destination; +} + function showBoarding(): bool { if (isDev()) { diff --git a/resources/views/livewire/project/clone-me.blade.php b/resources/views/livewire/project/clone-me.blade.php index 3c7f874ce..f150b5525 100644 --- a/resources/views/livewire/project/clone-me.blade.php +++ b/resources/views/livewire/project/clone-me.blade.php @@ -25,13 +25,13 @@ @foreach ($servers->sortBy('id') as $server) @foreach ($server->destinations() as $destination) + wire:click="selectServer('{{ $server->id }}', '{{ $destination->uuid }}')"> uuid }}' ? 'bg-coollabs text-white' : 'dark:bg-coolgray-100 bg-white'"> {{ $server->name }} uuid }}' ? 'bg-coollabs text-white' : 'dark:bg-coolgray-100 bg-white'"> {{ $destination->name }} diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php index 2d3750a8a..83a9198af 100644 --- a/resources/views/livewire/project/new/select.blade.php +++ b/resources/views/livewire/project/new/select.blade.php @@ -433,28 +433,40 @@ server. Go to servers page
- @else - @forelse($servers as $server) -
-
-
- {{ $server->name }} -
-
- {{ $server->description }} -
+ @endif + @forelse($servers as $server) +
+
+
+ {{ $server->name }} +
+
+ {{ $server->description }}
- @empty +
+ @empty + @if ($buildServers?->isEmpty() && ! $onlyBuildServerAvailable)
-
No validated & reachable servers found. Go to servers page
- @endforelse - @endif + @endif + @endforelse + @foreach($buildServers ?? [] as $buildServer) +
+
+
{{ $buildServer->name }}
+
+ This server is configured as a build server and cannot host resources. + Change server settings +
+
+
+ @endforeach
@endif @if ($current_step === 'destinations') diff --git a/resources/views/livewire/project/shared/resource-operations.blade.php b/resources/views/livewire/project/shared/resource-operations.blade.php index 0c3c8885c..769757dc1 100644 --- a/resources/views/livewire/project/shared/resource-operations.blade.php +++ b/resources/views/livewire/project/shared/resource-operations.blade.php @@ -18,6 +18,7 @@ 'destinations' => $s->destinations()->map( fn($d) => [ 'id' => $d->id, + 'uuid' => $d->uuid, 'name' => $d->name, 'server_id' => $s->id, ], @@ -77,6 +78,9 @@ + @foreach ($buildServers as $buildServer) + + @endforeach
@@ -84,8 +88,8 @@