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 Illuminate\Database\QueryException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use OpenApi\Attributes as OA; class DestinationsController extends Controller { @@ -59,6 +60,22 @@ private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDo ?? 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 @@ public function index(Request $request): JsonResponse ); } + #[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 @@ public function index_by_server(Request $request, string $server_uuid): JsonResp 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 @@ public function show(Request $request, string $uuid): JsonResponse 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 @@ private function isUniqueConstraintViolation(QueryException $exception): bool || 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 @@ private function shouldDispatchUpdate(Server $server, array $data): bool * 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 @@ private function containerStateHash(array $data): string ->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 @@ public function handle() } } + if (! $this->isCompleteSnapshot()) { + return; + } + $this->updateProxyStatus(); $this->updateNotFoundApplicationStatus(); @@ -329,6 +333,11 @@ public function handle() $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 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta $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 @@ public function clearCloudInitScript(): void } /** - * @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 @@ private function createDigitalOceanDroplet(string $token): array $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 @@ public function submit() ]); } - $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 @@ public function submit() $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 @@ public function submit() $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 @@ public function submit(): mixed $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 @@ public function submit(): mixed '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 @@ public function checkHetznerServerStatus(bool $manual = false) $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 @@ public function type() 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 @@ public function refreshVultrState(): ?string $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 @@ public function refreshDigitalOceanState(): ?string $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 @@ public function isProxyShouldRun() 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 @@ public function skipServer() 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\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 @@ protected static function boot() { 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 @@ public function up(): void 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 @@ function destinationsApiToken(User $user, Team $team, array $abilities): string }); 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 @@ 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 @@ function expectMcpAuditLog(array $expected): void $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 @@ 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 @@ function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): arra 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\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 @@ function vultrLivewireTestPublicKey(): string '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 @@ function vultrLivewireTestPublicKey(): string $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 @@ function vultrLivewireTestPublicKey(): string }); }); +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 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ ])); 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 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat '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 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat '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 @@ 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 @@ function createDigitalOceanServerForStateTest(string $status = 'active'): Server 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 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide } 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 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide 'vultr_instance_id' => $vultrInstanceId, 'team_id' => $teamId, ]); + + throw $e; } } @@ -165,12 +162,7 @@ private function deleteFromDigitalOceanById(int $digitalOceanDropletId, ?int $cl } 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 @@ private function deleteFromDigitalOceanById(int $digitalOceanDropletId, ?int $cl '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\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 @@ public function createServer(Request $request): JsonResponse 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 @@ public function createServer(Request $request): JsonResponse $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 @@ public function createServer(Request $request): JsonResponse '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 @@ public function createServer(Request $request): JsonResponse return $response; } catch (\Throwable $e) { + $this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server); + logger()->error('Failed to create DigitalOcean server', [ 'error' => $e->getMessage(), ]); @@ -365,6 +386,19 @@ public function createServer(Request $request): JsonResponse } } + 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 @@ private function getVultrToken(Request $request): CloudProviderToken|JsonRespons return response()->json(['message' => 'Vultr cloud provider token not found.'], 404); } + $this->authorize('view', $token); + return $token; } @@ -277,6 +279,8 @@ public function createServer(Request $request): JsonResponse 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 @@ public function createServer(Request $request): JsonResponse } $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\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; /** @@ -53,11 +54,13 @@ public function handle(): void /** * 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 @@ private function cleanupOrphanedContainersOnServer(Server $server): void /** * 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 @@ private function processDockerCleanup(Server $server): void 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\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 @@ private function disableSshMux(): void $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 @@ public function handle() 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 @@ public function handle() 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 @@ private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $w } } - 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 @@ public function handle(): void // 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 @@ public function handle(): void 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 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 @@ public function submit() { $this->validate(); + $digitalOceanService = null; + $dropletId = null; + $server = null; + try { $this->authorize('create', Server::class); @@ -468,24 +473,29 @@ public function submit() $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 @@ public function submit() 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 @@ public function startHetznerServer() 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\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 @@ public function type() 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 @@ public function hasPlaceholderIp(): bool */ 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 @@ public function refreshVultrState(): ?string } 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 @@ public function refreshVultrState(): ?string 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 @@ public function refreshDigitalOceanState(): ?string $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 @@ public function refreshDigitalOceanState(): ?string 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 @@ public function refreshDigitalOceanState(): ?string $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 @@ && $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 @@ function submitDigitalOceanServer(): void ]); }); +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 @@ function testPublicKey(): string }); 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 @@ function testPublicKey(): string $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 @@ function testPublicKey(): string ]); }); + 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 @@ function vultrLifecycleTestPrivateKey(): string }); 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 @@ function vultrLifecycleTestPrivateKey(): string 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 @@ function vultrLifecycleTestPrivateKey(): string 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\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 @@ 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 @@ function createDigitalOceanServerForStateTest(string $status = 'active', array $ 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 @@ function createDigitalOceanServerForStateTest(string $status = 'active', array $ ]); $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('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 @@ $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 @@ $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\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 @@ function vultrDeleteTestPrivateKey(): string 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 @@ public function saveStopGracePeriod() $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 @@ public function instantSave() 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 @@ public function changeSource($sourceId, $sourceType) '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 @@ public function instantSave() $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 @@ public function submit() $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 @@ public function instantSave() $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 @@ public function instantSave() $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 @@ public function submit() $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 @@ public function toggleHealthcheck() } 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 @@ private function sourceItems(): array $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 @@ private function buildItems(): array { 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 @@ private function buildItems(): array // 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 @@ private function buildItems(): array 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 @@ private function domainItems(): array $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 @@ private function environmentDisplayValue(EnvironmentVariable $environmentVariabl 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 @@ public function diff(array $previousSnapshot, array $currentSnapshot): Configura $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\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 @@ function makePrivateKey(string $name, string $material, string $fingerprint, int 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 @@ 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 @@ function createApplicationForAdvancedStopGracePeriodTest(): Application ->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 @@ ->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\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 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat ->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 @@ private function removeSensitiveData($application) $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 @@ public function applications(Request $request) ], '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 @@ public function create_public_application(Request $request) ], '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 @@ public function create_private_gh_app_application(Request $request) ], '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 @@ public function create_private_deploy_key_application(Request $request) '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 @@ public function create_dockerfile_application(Request $request) '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 @@ private function create_application(Request $request, $type) 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 @@ private function create_application(Request $request, $type) $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 @@ private function create_application(Request $request, $type) $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 @@ private function create_application(Request $request, $type) $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 @@ private function create_application(Request $request, $type) $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 @@ private function create_application(Request $request, $type) $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 @@ private function create_application(Request $request, $type) $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 @@ private function create_application(Request $request, $type) $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 @@ private function create_application(Request $request, $type) $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 @@ private function create_application(Request $request, $type) $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 @@ private function create_application(Request $request, $type) $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 @@ private function create_application(Request $request, $type) $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 @@ private function create_application(Request $request, $type) $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 @@ public function application_by_uuid(Request $request) 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 @@ public function delete_by_uuid(Request $request) ], '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 @@ public function update_by_uuid(Request $request) $this->authorize('update', $application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', '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 @@ public function update_by_uuid(Request $request) ], 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 @@ public function update_by_uuid(Request $request) $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 @@ public function update_by_uuid(Request $request) $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 @@ public function update_by_uuid(Request $request) $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 @@ public function action_restart(Request $request) ), ] )] - 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 @@ '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 @@ 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 @@ class="input italic !text-neutral-500 dark:!text-neutral-500" /> Lock @@ -284,7 +284,7 @@ class="input italic !text-neutral-500 dark:!text-neutral-500" /> 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 @@ ->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 @@ class="input italic !text-neutral-500 dark:!text-neutral-500" />
@endcan @can('update', $this->env) -
-
+
+
+
@if (!$is_redis_credential) @if ($type === 'service') @if (!$isMagicVariable) @@ -266,10 +267,11 @@ class="input italic !text-neutral-500 dark:!text-neutral-500" /> @endif @endif @endif +
+
- @if (!$isMagicVariable) -
+
@if ($isDisabled) Update Lock @@ -291,7 +293,7 @@ class="input italic !text-neutral-500 dark:!text-neutral-500" /> @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 @@ 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 @@ public function delete_by_uuid(Request $request) 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 @@ public function delete_env_by_uuid(Request $request) ), ] )] - 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 @@ '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 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update ), ] )] + #[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 @@ public function logs_by_uuid(Request $request): JsonResponse ), ] )] + #[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 @@ public function action_start(Request $request): JsonResponse ), ] )] + #[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 @@ public function action_restart(Request $request): JsonResponse ), ] )] + #[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 @@ public function update(User $user, ServiceDatabase $serviceDatabase): bool 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\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::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 @@ public function handle(?\Closure $onProgress = null): array ->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 @@ public function handle(?\Closure $onProgress = null): array $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 @@ public function handle(?\Closure $onProgress = null): array 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 @@ public function handle(?\Closure $onProgress = null): array '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 @@ public function handle(?\Closure $onProgress = null): array 'discrepancies' => $discrepancies, 'resubscribed' => $resubscribed, 'errors' => $errors, - 'fixed' => $this->fix, + 'fixed' => $fix, + 'fixed_count' => $fixedCount, + 'manual_review_count' => $manualReviewCount, ]; } @@ -183,13 +228,13 @@ private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $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 @@ public function handle(): int $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 @@ public function handle(): int $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 @@ public function handle(): int $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 @@ public function create_backup(Request $request) $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 @@ public function create_backup(Request $request) $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 @@ public function list_backup_executions(Request $request) ), ] )] - 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\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\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 @@ public function handle(): void $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 @@ public function handle(): void // 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 @@ public function handle(): void '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 @@ public function handle(): void } } 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 @@ private function backup_standalone_mariadb(string $database): void } } + 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 @@ public function mount() 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 @@ public function submit() 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 @@ public function submit() $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 @@ public function scheduledBackups() 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 @@ public function isAnyNotificationEnabled() $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 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow- 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\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 @@ 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\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 @@ function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage 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('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 @@ public function logs_by_uuid(Request $request): JsonResponse ], 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 @@ public function logs(Request $request): JsonResponse 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\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 @@ public function createServer(Request $request): JsonResponse 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 @@ public function createServer(Request $request): JsonResponse } $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 @@ public function createServer(Request $request): JsonResponse '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 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 @@ private function providerDataErrorMessage(string $providerName, \Throwable $e, s 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 @@ public function submit(): mixed return null; } + $vultrService = null; + $vultrInstanceId = null; + $server = null; + try { $this->authorize('create', Server::class); @@ -437,20 +441,29 @@ public function submit(): mixed } $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 @@ public function submit(): mixed 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 @@ public function submit(): mixed 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 @@ private function request(string $method, string $endpoint, array $data = []): ar } 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 @@ private function request(string $method, string $endpoint, array $data = []): ar '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 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 @@ $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 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 @@ $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 @@ function testPublicKey(): string ]); }); + 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\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 @@ function vultrLivewireTestPublicKey(): string '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 @@ ->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 @@ private function create_application(Request $request, $type) 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 @@ public function create_database(Request $request, NewDatabaseTypes $type) 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 @@ public function update_server(Request $request) ], 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 @@ public function create_service(Request $request) 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 @@ public function create_service(Request $request) 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 @@ public function create_service(Request $request) 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 @@ public function render() 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 @@ public function selectServer($server_id, $destination_id) 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 @@ public function clone(string $type) '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 @@ public function clone(string $type) $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 @@ public function clone(string $type) '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 @@ public function clone(string $type) ])->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 @@ public function submit() $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 @@ public function submit() $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 @@ public function submit() // 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 @@ public function submit() '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 @@ public function submit() } $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 @@ public function submit() $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 @@ public function submit() '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 @@ public function submit() $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 @@ public function submit() $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 @@ public function submit() $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 @@ public function submit() $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 @@ public function setType(string $type) 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 @@ public function whatToDoNext() 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 @@ public function submit() '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 @@ public function submit() 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 @@ public function submit() '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 @@ public function mount() 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 @@ public function mount() 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 @@ public function promote(int $network_id, int $server_id) $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 @@ public function promote(int $network_id, int $server_id) $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\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\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 @@ public function mount() $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 @@ public function toggleVolumeCloning(bool $value) $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 @@ public function cloneTo($destination_id) '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 @@ public function mount(string $server_uuid) 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 @@ public function updatedIsBuildServer($value) { 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 @@ public static function ownedByCurrentTeamCached() }); } - 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 @@ function searchResources() { 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 @@