From 5848b07fc24499c611584b58328965b5e856c2bc Mon Sep 17 00:00:00 2001 From: Yanluis Fermin <32645451+Jacxk@users.noreply.github.com> Date: Tue, 29 Jul 2025 21:42:47 -0400 Subject: [PATCH 001/211] feat(api): add endpoint to retrieve database logs by UUID --- .../Controllers/Api/DatabasesController.php | 101 ++++++++++++++++++ bootstrap/helpers/docker.php | 13 +++ routes/api.php | 1 + 3 files changed, 115 insertions(+) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 504665f6a..fc2b7b6d0 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1535,6 +1535,107 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(['message' => 'Invalid database type requested.'], 400); } + #[OA\Get( + summary: 'Get database logs.', + description: 'Get database logs by UUID.', + path: '/databases/{uuid}/logs', + operationId: 'get-database-logs-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema( + type: 'integer', + format: 'int32', + default: 100, + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get database logs by UUID.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function logs_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $containers = getCurrentDatabaseContainerStatus($database->destination->server, $database->id); + + if ($containers->count() == 0) { + return response()->json([ + 'message' => 'Database is not running.', + ], 400); + } + + $container = $containers->first(); + + $status = getContainerStatus($database->destination->server, $container['Names']); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Database is not running.', + ], 400); + } + + $lines = $request->query->get('lines', 100) ?: 100; + $logs = getContainerLogs($database->destination->server, $container['ID'], $lines); + + return response()->json([ + 'logs' => $logs, + ]); + } #[OA\Delete( summary: 'Delete', diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 944c51e3c..cac8ffb6c 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -53,6 +53,19 @@ function getCurrentServiceContainerStatus(Server $server, int $id): Collection return $containers; } +function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection +{ + $containers = collect([]); + if (! $server->isSwarm()) { + $containers = instant_remote_process(["docker ps -a --filter='label=coolify.databaseId={$id}' --format '{{json .}}' "], $server); + $containers = format_docker_command_output_to_json($containers); + + return $containers->filter(); + } + + return $containers; +} + function format_docker_command_output_to_json($rawOutput): Collection { $outputLines = explode(PHP_EOL, $rawOutput); diff --git a/routes/api.php b/routes/api.php index d63e3ee0e..958c88fda 100644 --- a/routes/api.php +++ b/routes/api.php @@ -112,6 +112,7 @@ Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']); Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/logs', [DatabasesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']); From bc9bfaefc78cb0436795f59ab3a6f98c5d1e7039 Mon Sep 17 00:00:00 2001 From: Yanluis Fermin <32645451+Jacxk@users.noreply.github.com> Date: Tue, 29 Jul 2025 22:40:02 -0400 Subject: [PATCH 002/211] feat(api): add endpoints to retrieve service logs by UUID for each container --- .../Controllers/Api/ServicesController.php | 115 ++++++++++++++++++ routes/api.php | 1 + 2 files changed, 116 insertions(+) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 542be83de..61ff80a60 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -448,6 +448,121 @@ public function service_by_uuid(Request $request) return response()->json($this->removeSensitiveData($service)); } + #[OA\Get( + summary: 'Get service logs.', + description: 'Get service logs by UUID.', + path: '/services/{uuid}/containers/{container_id}/logs', + operationId: 'get-service-logs-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'container_id', + in: 'path', + description: 'Container ID.', + required: true, + schema: new OA\Schema(type: 'string'), + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema( + type: 'integer', + format: 'int32', + default: 100, + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get service logs by UUID.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function logs_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $containers = getCurrentServiceContainerStatus($service->destination->server, $service->id); + + if ($containers->count() == 0) { + return response()->json([ + 'message' => 'Service is not running.', + ], 400); + } + + $container = $containers->first(function ($container) use ($request) { + return $container['ID'] === $request->container_id; + }); + + if (! $container) { + return response()->json(['message' => 'Container not found.'], 404); + } + + $status = getContainerStatus($service->destination->server, $container['Names']); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Container is not running.', + ], 400); + } + + $lines = $request->query->get('lines', 100) ?: 100; + $logs = getContainerLogs($service->destination->server, $container['ID'], $lines); + + return response()->json([ + 'logs' => $logs, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete service by UUID.', diff --git a/routes/api.php b/routes/api.php index 958c88fda..c790627b8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -130,6 +130,7 @@ Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/containers/{container_id}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']); From 28e20473da9abd641dfc6dd6306f01808c36beac Mon Sep 17 00:00:00 2001 From: Yanluis Fermin <32645451+Jacxk@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:29:27 -0400 Subject: [PATCH 003/211] refactor(api): update service logs endpoint to use sub service name --- .../Controllers/Api/ServicesController.php | 18 ++++++++++-------- bootstrap/helpers/docker.php | 13 +++++++++++++ routes/api.php | 2 +- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 61ff80a60..299af54e0 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -451,7 +451,7 @@ public function service_by_uuid(Request $request) #[OA\Get( summary: 'Get service logs.', description: 'Get service logs by UUID.', - path: '/services/{uuid}/containers/{container_id}/logs', + path: '/services/{uuid}/logs', operationId: 'get-service-logs-by-uuid', security: [ ['bearerAuth' => []], @@ -469,9 +469,9 @@ public function service_by_uuid(Request $request) ) ), new OA\Parameter( - name: 'container_id', - in: 'path', - description: 'Container ID.', + name: 'sub_service_name', + in: 'query', + description: 'Sub service name.', required: true, schema: new OA\Schema(type: 'string'), ), @@ -527,12 +527,16 @@ public function logs_by_uuid(Request $request) if (! $uuid) { return response()->json(['message' => 'UUID is required.'], 400); } + $subServiceName = $request->query->get('sub_service_name'); + if (! $subServiceName) { + return response()->json(['message' => 'Sub service name is required.'], 400); + } $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - $containers = getCurrentServiceContainerStatus($service->destination->server, $service->id); + $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $subServiceName); if ($containers->count() == 0) { return response()->json([ @@ -540,9 +544,7 @@ public function logs_by_uuid(Request $request) ], 400); } - $container = $containers->first(function ($container) use ($request) { - return $container['ID'] === $request->container_id; - }); + $container = $containers->first(); if (! $container) { return response()->json(['message' => 'Container not found.'], 404); diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index cac8ffb6c..26704060c 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -66,6 +66,19 @@ function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection return $containers; } +function getCurrentServiceSubContainerStatus(Server $server, int $id, string $subName): Collection +{ + $containers = collect([]); + if (! $server->isSwarm()) { + $containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --filter='label=coolify.service.subName={$subName}' --format '{{json .}}' "], $server); + $containers = format_docker_command_output_to_json($containers); + + return $containers->filter(); + } + + return $containers; +} + function format_docker_command_output_to_json($rawOutput): Collection { $outputLines = explode(PHP_EOL, $rawOutput); diff --git a/routes/api.php b/routes/api.php index c790627b8..8fec3e3a5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -130,7 +130,7 @@ Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); - Route::get('/services/{uuid}/containers/{container_id}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']); From c239b8bbba07c0f9b6f9f5507581f2d07853a11a Mon Sep 17 00:00:00 2001 From: Yanluis Fermin <32645451+Jacxk@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:41:17 -0400 Subject: [PATCH 004/211] refactor(api): modify service sub container retrieval filter to use coolify.name --- app/Http/Controllers/Api/ServicesController.php | 10 ++-------- bootstrap/helpers/docker.php | 4 ++-- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 299af54e0..edec32db5 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -536,14 +536,8 @@ public function logs_by_uuid(Request $request) return response()->json(['message' => 'Service not found.'], 404); } - $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $subServiceName); - - if ($containers->count() == 0) { - return response()->json([ - 'message' => 'Service is not running.', - ], 400); - } - + $name = "{$subServiceName}-{$service->uuid}"; + $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name); $container = $containers->first(); if (! $container) { diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 26704060c..87dc47336 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -66,11 +66,11 @@ function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection return $containers; } -function getCurrentServiceSubContainerStatus(Server $server, int $id, string $subName): Collection +function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection { $containers = collect([]); if (! $server->isSwarm()) { - $containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --filter='label=coolify.service.subName={$subName}' --format '{{json .}}' "], $server); + $containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --filter='label=coolify.name={$name}' --format '{{json .}}' "], $server); $containers = format_docker_command_output_to_json($containers); return $containers->filter(); From 0eb2ea86e85693199e89606afeb960acbba2b5cf Mon Sep 17 00:00:00 2001 From: Yanluis Fermin <32645451+Jacxk@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:32:11 -0400 Subject: [PATCH 005/211] feat(api): add 'show_timestamps' parameter to logs endpoints --- .../Controllers/Api/ApplicationsController.php | 12 ++++++++++-- app/Http/Controllers/Api/DatabasesController.php | 12 ++++++++++-- app/Http/Controllers/Api/ServicesController.php | 12 ++++++++++-- bootstrap/helpers/docker.php | 16 ++++++++-------- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 0860c7133..f2015de67 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1553,6 +1553,13 @@ public function application_by_uuid(Request $request) default: 100, ) ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), ], responses: [ new OA\Response( @@ -1616,8 +1623,9 @@ public function logs_by_uuid(Request $request) ], 400); } - $lines = $request->query->get('lines', 100) ?: 100; - $logs = getContainerLogs($application->destination->server, $container['ID'], $lines); + $lines = $request->query->get('lines', 100); + $showTimestamps = $request->query->get('show_timestamps', false); + $logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps); return response()->json([ 'logs' => $logs, diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index fc2b7b6d0..dd4165c90 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1566,6 +1566,13 @@ public function create_database(Request $request, NewDatabaseTypes $type) default: 100, ) ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), ], responses: [ new OA\Response( @@ -1629,8 +1636,9 @@ public function logs_by_uuid(Request $request) ], 400); } - $lines = $request->query->get('lines', 100) ?: 100; - $logs = getContainerLogs($database->destination->server, $container['ID'], $lines); + $lines = $request->query->get('lines', 100); + $showTimestamps = $request->query->get('show_timestamps', false); + $logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps); return response()->json([ 'logs' => $logs, diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index edec32db5..1fc4bb765 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -486,6 +486,13 @@ public function service_by_uuid(Request $request) default: 100, ) ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), ], responses: [ new OA\Response( @@ -551,8 +558,9 @@ public function logs_by_uuid(Request $request) ], 400); } - $lines = $request->query->get('lines', 100) ?: 100; - $logs = getContainerLogs($service->destination->server, $container['ID'], $lines); + $lines = $request->query->get('lines', 100); + $showTimestamps = $request->query->get('show_timestamps', false); + $logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps); return response()->json([ 'logs' => $logs, diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 87dc47336..771937ce0 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1115,18 +1115,18 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable } } -function getContainerLogs(Server $server, string $container_id, int $lines = 100): string +function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string { + $command = "docker logs -n {$lines} {$container_id}"; if ($server->isSwarm()) { - $output = instant_remote_process([ - "docker service logs -n {$lines} {$container_id}", - ], $server); - } else { - $output = instant_remote_process([ - "docker logs -n {$lines} {$container_id}", - ], $server); + $command = "docker service logs -n {$lines} {$container_id}"; } + if ($showTimestamps) { + $command .= ' --timestamps'; + } + + $output = instant_remote_process([$command], $server); $output .= removeAnsiColors($output); return $output; From 555b46a7d84d22a0f9ff0f30f22d3797ff8b3171 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:45:33 +0530 Subject: [PATCH 006/211] chore(repo): improve contributor guidelines --- CONTRIBUTING.md | 463 +++++++++++++++++++++--------------------------- 1 file changed, 205 insertions(+), 258 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9aec08420..af8c7503c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,298 +1,245 @@ # Contributing to Coolify +We’re happy that you’re interested in contributing to Coolify! -> "First, thanks for considering contributing to my project. It really means a lot!" - [@andrasbacsai](https://github.com/andrasbacsai) +There are many ways to help: +- Answer questions in GitHub Discussions or Discord +- Report reproducible bugs +- Submit pull requests to fix issues +- Add new one-click services +- Improve documentation -You can ask for guidance anytime on our [Discord server](https://coollabs.io/discord) in the `#contribute` channel. +Coolify is a PaaS used by 400,000+ people worldwide and maintained by two active maintainers. Contributions are welcome — but **alignment matters more than quantity**. -To understand the tech stack, please refer to the [Tech Stack](TECH_STACK.md) document. - -## Table of Contents - -1. [Setup Development Environment](#1-setup-development-environment) -2. [Verify Installation](#2-verify-installation-optional) -3. [Fork and Setup Local Repository](#3-fork-and-setup-local-repository) -4. [Set up Environment Variables](#4-set-up-environment-variables) -5. [Start Coolify](#5-start-coolify) -6. [Start Development](#6-start-development) -7. [Create a Pull Request](#7-create-a-pull-request) -8. [Development Notes](#development-notes) -9. [Resetting Development Environment](#resetting-development-environment) -10. [Additional Contribution Guidelines](#additional-contribution-guidelines) - -## 1. Setup Development Environment - -Follow the steps below for your operating system: - -
-Windows - -1. Install `docker-ce`, Docker Desktop (or similar): - - Docker CE (recommended): - - Install Windows Subsystem for Linux v2 (WSL2) by following this guide: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install?ref=coolify) - - After installing WSL2, install Docker CE for your Linux distribution by following this guide: [Install Docker Engine](https://docs.docker.com/engine/install/?ref=coolify) - - Make sure to choose the appropriate Linux distribution (e.g., Ubuntu) when following the Docker installation guide - - Install Docker Desktop (easier): - - Download and install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/?ref=coolify) - - Ensure WSL2 backend is enabled in Docker Desktop settings - -2. Install Spin: - - Follow the instructions to install Spin on Windows from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-windows#download-and-install-spin-into-wsl2?ref=coolify) - -
- -
-MacOS - -1. Install Orbstack, Docker Desktop (or similar): - - Orbstack (recommended, as it is a faster and lighter alternative to Docker Desktop): - - Download and install [Orbstack](https://docs.orbstack.dev/quick-start#installation?ref=coolify) - - Docker Desktop: - - Download and install [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/?ref=coolify) - -2. Install Spin: - - Follow the instructions to install Spin on MacOS from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-macos/#download-and-install-spin?ref=coolify) - -
- -
-Linux - -1. Install Docker Engine, Docker Desktop (or similar): - - Docker Engine (recommended, as there is no VM overhead): - - Follow the official [Docker Engine installation guide](https://docs.docker.com/engine/install/?ref=coolify) for your Linux distribution - - Docker Desktop: - - If you want a GUI, you can use [Docker Desktop for Linux](https://docs.docker.com/desktop/install/linux-install/?ref=coolify) - -2. Install Spin: - - Follow the instructions to install Spin on Linux from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-linux#configure-docker-permissions?ref=coolify) - -
- -## 2. Verify Installation (Optional) - -After installing Docker (or Orbstack) and Spin, verify the installation: - -1. Open a terminal or command prompt -2. Run the following commands: - ```bash - docker --version - spin --version - ``` - You should see version information for both Docker and Spin. - -## 3. Fork and Setup Local Repository - -1. Fork the [Coolify](https://github.com/coollabsio/coolify) repository to your GitHub account. - -2. Install a code editor on your machine (choose one): - - | Editor | Platform | Download Link | - |--------|----------|---------------| - | Visual Studio Code (recommended free) | Windows/macOS/Linux | [Download](https://code.visualstudio.com/download?ref=coolify) | - | Cursor (recommended but paid) | Windows/macOS/Linux | [Download](https://www.cursor.com/?ref=coolify) | - | Zed (very fast) | macOS/Linux | [Download](https://zed.dev/download?ref=coolify) | - -3. Clone the Coolify Repository from your fork to your local machine - - Use `git clone` in the command line, or - - Use GitHub Desktop (recommended): - - Download and install from [https://desktop.github.com/](https://desktop.github.com/?ref=coolify) - - Open GitHub Desktop and login with your GitHub account - - Click on `File` -> `Clone Repository` select `github.com` as the repository location, then select your forked Coolify repository, choose the local path and then click `Clone` - -4. Open the cloned Coolify Repository in your chosen code editor. - -## 4. Set up Environment Variables - -1. In the Code Editor, locate the `.env.development.example` file in the root directory of your local Coolify repository. -2. Duplicate the `.env.development.example` file and rename the copy to `.env`. -3. Open the new `.env` file and review its contents. Adjust any environment variables as needed for your development setup. -4. If you encounter errors during database migrations, update the database connection settings in your `.env` file. Use the IP address or hostname of your PostgreSQL database container. You can find this information by running `docker ps` after executing `spin up`. -5. Save the changes to your `.env` file. - -## 5. Start Coolify - -1. Open a terminal in the local Coolify directory. -2. Run the following command in the terminal (leave that terminal open): - ```bash - spin up - ``` - -> [!NOTE] -> You may see some errors, but don't worry; this is expected. - -3. If you encounter permission errors, especially on macOS, use: - ```bash - sudo spin up - ``` - -> [!NOTE] -> If you change environment variables afterwards or anything seems broken, press Ctrl + C to stop the process and run `spin up` again. - -## 6. Start Development - -1. Access your Coolify instance: - - URL: `http://localhost:8000` - - Login: `test@example.com` - - Password: `password` - -2. Additional development tools: - - | Tool | URL | Note | - |------|-----|------| - | Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user | - | Mailpit (email catcher) | `http://localhost:8025` | | - | Telescope (debugging tool) | `http://localhost:8000/telescope` | Disabled by default | - -> [!NOTE] -> To enable Telescope, add the following to your `.env` file: -> ```env -> TELESCOPE_ENABLED=true -> ``` - -## 7. Create a Pull Request +This guide explains **what kind of contributions are likely to be accepted** and how to submit them properly. Following it saves time for both you and the maintainers. > [!IMPORTANT] -> Please read the [Pull Request Guidelines](#pull-request-guidelines) carefully before creating your PR. +> These guidelines may feel stricter than in many open-source projects. That is intentional. +> Clear structure and boundaries prevent maintainer burnout and keep the project sustainable long-term. -1. After making changes or adding a new service: - - Commit your changes to your forked repository. - - Push the changes to your GitHub account. -2. Creating the Pull Request (PR): - - Navigate to the main Coolify repository on GitHub. - - Click the "Pull requests" tab. - - Click the green "New pull request" button. - - Choose your fork and `next` branch as the compare branch. - - Click "Create pull request". +## High-Level Expectations +- Coolify has a clear product direction. +- Ownership and decisions are centralized. +- Review capacity is limited. +- Not every contribution will be accepted — even if technically correct. -3. Filling out the PR details: - - Give your PR a descriptive title. - - Use the Pull Request Template provided and fill in the details. +This is normal for a two-maintainer project. -> [!IMPORTANT] -> Always set the base branch for your PR to the `next` branch of the Coolify repository, not the `v4.x` branch. -4. Submit your PR: - - Review your changes one last time. - - Click "Create pull request" to submit. +## State of the Project +Coolify is currently at v4 and is still in beta. While v4 is stable, it has some limitations, including: +- Limited scaling support +- A more complex user experience +- Other smaller issues that need refinement -> [!NOTE] -> Make sure your PR is out of draft mode as soon as it's ready for review. PRs that are in draft mode for a long time may be closed by maintainers. +These limitations will be addressed in Coolify v5, which is in the planning stage. However, the maintainers are focused on releasing a stable v4 version before dedicating time to v5 development. Because of this, major features, architectural changes, or significant UI changes will not be accepted for v4 at this stage. -After submission, maintainers will review your PR and may request changes or provide feedback. +We welcome contributions that help stabilize v4, but larger changes will be saved for v5 once we have a stable v4 release. -#### Pull Request Guidelines -To maintain high-quality contributions and efficient review process: -- **Target Branch**: Always target the `next` branch, never `v4.x` or any other branch. PRs targeting incorrect branches will be closed without review. -- **Descriptive Titles**: Use clear, concise PR titles that describe the change (e.g., "fix: one click postgresql database stuck in restart loop" instead of "Fix database"). -- **PR Descriptions**: Provide detailed, meaningful descriptions. Avoid generic or AI-generated fluff. Include: - - What the change does - - Why it's needed - - How to test it - - Any breaking changes - - Screenshot or video recording of your changes working without any issues - - Links to related issues -- **Link to Issues**: All PRs must link to an existing GitHub issue. If no issue exists, create one first. Unrelated PRs may be closed. -- **Single Responsibility**: Each PR should address one issue or feature. Do not bundle unrelated changes. -- **Draft Mode**: Use draft PRs for work-in-progress. Convert to ready-for-review only when complete and tested. -- **Review Readiness**: Ensure your PR is ready for review within a reasonable timeframe (max 7 days in draft). Stale drafts may be closed. -- **Current Focus**: We are currently prioritizing stability and bug fixes over new features. PRs adding new features may not be reviewed, or may be closed without review to maintain focus. -- **Language Translations**: Coolify currently supports only English. Pull requests for new language translations will not be accepted. Multi-language support may be considered in the next major version (v5). -- **AI Usage Policy**: We are not against AI tools—we use them ourselves. However, AI discourse is mandatory: You must fully understand the changes in your PR and be able to explain them clearly. Many PRs using AI lack this understanding, leading to untested or incorrect submissions. If you use AI, ensure you can articulate what the code does, why it was changed, and how it was tested. -#### Review Process -- **Response Time**: Maintainers will review PRs promptly, but complex changes may take time. Be patient and responsive to feedback. -- **Revisions**: Address all review comments. Unresolved feedback may lead to PR closure. -- **Merge Criteria**: PRs are merged only after: - - All tests pass (including CI) - - Code review approval -- **Closing PRs**: PRs may be closed for: - - Inactivity (>7 days without response) - - Failure to meet guidelines - - Duplicate or superseded work - - Security or quality concerns +## What Makes a Strong Contribution +The following types of contributions are most likely to be accepted: -#### Code Quality, Testing, and Bounty Submissions -All contributions must adhere to the highest standards of code quality and testing: +- **Bug fixes** (with clear reproduction steps) +- **Documentation improvements** (typos, clarifications, examples, guides) +- **Features discussed and aligned beforehand** +- **New service templates** requested by the community +- **Small, focused pull requests** -- **Testing Required**: Every PR must include steps to test your changes. Untested code will not be reviewed or merged. -- **Local Verification**: Ensure your changes work in the development environment. Test all affected features thoroughly. -- **Code Standards**: Follow the existing code style, conventions, and patterns in the codebase. -- **No AI-Generated Code**: Do not submit code generated by AI tools without fully understanding and verifying it. AI-generated submissions that are untested or incorrect will be rejected immediately. +If your change is small and obvious (typo fix, small bug, minor docs update), you may open a pull request directly. -**For PRs that claim bounties:** -- **Eligibility**: Bounty PRs must strictly follow all guidelines above. Untested, poorly described, or non-compliant PRs will not qualify for bounty rewards. -- **Original Work**: Bounties are for genuine contributions. Submitting AI-generated or copied code solely for bounty claims will result in disqualification and potential removal from contributing. -- **Quality Standards**: Bounty submissions are held to even higher standards. Ensure comprehensive testing, clear documentation, and alignment with project goals. When maintainers review the changes, they should work as expected (the things mentioned in the PR description plus what the bounty issuer needs). -- **Claim Process**: Only successfully merged PRs that pass all reviews (core maintainers + bounty issuer) and meet bounty criteria will be awarded. Follow the issue's bounty guidelines precisely. -- **Prioritization**: Contributor PRs are prioritized over first-time or new contributors. -- **Developer Experience**: We highly advise beginners to avoid participating in bug bounties for our codebase. Most of the time, they don't know what they are changing, how it affects other parts of the system, or if their changes are even correct. -- **Review Comments**: When maintainers ask questions, you should be able to respond properly without generic or AI-generated fluff. +## Keep Changes Focused +Only modify what is necessary to achieve your goal. -## Development Notes +If you are fixing a bug in `file.yaml`, do not: +- Reformat unrelated files +- Refactor unrelated code +- Fix style issues elsewhere +- Combine multiple unrelated changes -When working on Coolify, keep the following in mind: +Even “improvements” increase review complexity. -1. **Database Migrations**: After switching branches or making changes to the database structure, always run migrations: - ```bash - docker exec -it coolify php artisan migrate - ``` +**One pull request = one logical change.** -2. **Resetting Development Setup**: To reset your development setup to a clean database with default values: - ```bash - docker exec -it coolify php artisan migrate:fresh --seed - ``` +If you want to refactor or clean up code, discuss it first and submit it separately. -3. **Troubleshooting**: If you encounter unexpected behavior, ensure your database is up-to-date with the latest migrations and if possible reset the development setup to eliminate any environment-specific issues. -> [!IMPORTANT] -> Forgetting to migrate the database can cause problems, so make it a habit to run migrations after pulling changes or switching branches. +## Discussion Is Required for Larger Changes +For anything beyond a small fix, you must discuss it before opening a pull request. -## Resetting Development Environment +This includes: +- New features +- UI/UX changes +- Changes to default behavior +- Refactors or cleanup work +- Performance rewrites +- Architectural changes +- Changes touching many files -If you encounter issues or break your database or something else, follow these steps to start from a clean slate (works since `v4.0.0-beta.342`): +Discussion happens in GitHub Discussions: https://github.com/coollabsio/coolify/discussions/categories/general -1. Stop all running containers `ctrl + c`. +Pull requests introducing major changes without prior discussion will be closed without review. -2. Remove all Coolify containers: - ```bash - docker rm coolify coolify-db coolify-redis coolify-realtime coolify-testing-host coolify-minio coolify-vite-1 coolify-mail - ``` +This ensures alignment before significant work is done. -3. Remove Coolify volumes (it is possible that the volumes have no `coolify` prefix on your machine, in that case remove the prefix from the command): - ```bash - docker volume rm coolify_dev_backups_data coolify_dev_postgres_data coolify_dev_redis_data coolify_dev_coolify_data coolify_dev_minio_data - ``` -4. Remove unused images: - ```bash - docker image prune -a - ``` +## What This Project Is Not +To set clear expectations: +- Coolify is not optimized for first-time open-source contributors +- We do not provide beginner-focused mentorship issues +- Large unsolicited changes are unlikely to be accepted +- Broad refactors or style rewrites are not helpful +- Low-effort AI-generated pull requests will be closed -5. Start Coolify again: - ```bash - spin up - ``` +AI usage is allowed. However, contributors must fully understand what their changes do and why. -6. Run database migrations and seeders: - ```bash - docker exec -it coolify php artisan migrate:fresh --seed - ``` +Clear expectations help everyone use their time effectively. -After completing these steps, you'll have a fresh development setup. -> [!IMPORTANT] -> Always run database migrations and seeders after switching branches or pulling updates to ensure your local database structure matches the current codebase and includes necessary seed data. +# Ways to Contribute +## 1. Support Contributions +We use Discord for most support requests and GitHub Discussions for help. -## Additional Contribution Guidelines +### Requesting Support +If you need help: +- Provide complete and detailed information +- Include logs, screenshots, and steps to reproduce +- Be respectful — support is voluntary -### Contributing a New Service +Do not ping people for attention. They respond when available. -To add a new service to Coolify, please refer to our documentation: -[Adding a New Service](https://coolify.io/docs/get-started/contribute/service) +### Providing Support +If you help others: +- Verify your information before sharing +- Be patient and respectful +- Remember that not everyone has the same experience level -### Contributing to Documentation -To contribute to the Coolify documentation, please refer to this guide: -[Contributing to the Coolify Documentation](https://github.com/coollabsio/documentation-coolify/blob/main/readme.md) +## 2. Bug Report Contributions +Create a GitHub issue **only** if: +- The bug is reproducible +- You have confirmed no existing issue already covers it + +For questions or general help, use GitHub Discussions or the Discord support channel. + +Bug reports must include: +- Clear reproduction steps +- Expected result +- Actual result + +Incomplete reports may be closed. + + +## 3. Code Contributions +Maintainers may close pull requests at their discretion, without explanation. + +### Issue Requirement +Every pull request should reference and close an Issue or Discussion. + +If none exists, create one first. + +Pull requests without linked issue or discussions may not be reviewed and can be closed at any time. + + +## Commit Message Format +All commits must start with an action and category: +- `fix(ui):` — UI-related fixes +- `feat(api):` — API-related changes +- `feat(service):` — One-click service changes + +Examples: +- `fix(api): version endpoint returns wrong data` +- `feat(service): add supabase` + +Use the commit description only for concise context. + +Walls of text listing every change in description will be rejected. + + +## Pull Request Title Format +Pull request titles follow the same format: +- `fix(ui):` +- `feat(api):` +- `feat(service):` + +Examples: +- `fix(api): version endpoint returns wrong data` +- `feat(service): add supabase` + + +## AI Usage Disclosure +If AI tools were used at any stage, mention it in the pull request description. + +AI is allowed. + +However: +- You must understand every change +- You must verify correctness +- You must ensure it follows project patterns + +AI-generated pull requests without clear understanding will be closed. + + +## Test Before Submitting +Before submitting a pull request: +- Test your changes thoroughly +- Verify they work in a clean environment +- Provide detailed testing steps in the PR description + +If maintainers cannot reproduce working behavior, the PR will be closed without further review. + + +## Submitting a Pull Request +- GitHub will auto-populate the PR template +- The contributor agreement must remain intact +- Pull requests without the contributor agreement will be closed +- All pull requests must target the `next` branch +- PRs targeting other branches will be closed without review + + +## Bounty Issues +Community members may create bounty issues for features or improvements. + +Important: +- Only existing contributors are eligible +- First-time contributors targeting bounty issues will be automatically closed and maybe excluded from contributing. + +This policy exists to prevent spam. We close 30+ bounty-targeted spam PRs per week. + + +## FAQ +**Q: Should I ask before fixing a typo or a small bug?** +A: No, small, obvious fixes like typos or narrowly-scoped bug fixes can be submitted as a PR directly. + +**Q: I have an idea for a new feature.** +A: Awesome! Discuss it first in GitHub Discussions or Discord. **Do not** open a PR for new features without prior alignment. + +**Q: My PR was closed without detailed feedback.** +A: This usually means it didn’t align with the project’s direction, required more review bandwidth than available, or targeted major changes not allowed in v4. This is normal for a two-maintainer project. + +**Q: Can I work on an open issue?** +A: Comment on the issue first to confirm it’s still relevant and that no one else is actively working on it. For anything beyond a small fix, discuss your approach before implementing. + +**Q: I noticed code that could be cleaned up while working on my change.** +A: Focus only on your stated goal. Cleanups or refactors should be submitted as separate PRs after discussion. + +**Q: Can I use AI to help with my PR?** +A: Yes, AI-assisted contributions are allowed. But you must fully understand and verify the changes. PRs that appear to be generated by AI without context understanding will be closed. + +**Q: Can I work on a bounty issue as a first-time contributor?** +A: No, bounty issues are reserved for existing contributors. First-time contributors targeting bounty issues will have their PRs closed without review. This prevents spam and ensures bounties go to contributors familiar with the project. + +**Q: My PR was closed without review. Can I submit a new one?** +A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effort. It usually means the PR didn’t match the project goals or guidelines. Address these issues first — repeating the same approach may hurt your standing with maintainers. + + +# Development Guides +## Local Development +To build and run Coolify locally, see: [Development](./DEVELOPMENT.md) + +## Adding a New Service +To add a new one-click service, follow: https://coolify.io/docs/get-started/contribute/service + +## Contributing to Documentation +To contribute to documentation, see: https://coolify.io/docs/get-started/contribute/documentation \ No newline at end of file From 4776a572317cc1afc362c40a09dcad41b7fecfd8 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:46:28 +0530 Subject: [PATCH 007/211] chore(repo): improve development guide --- DEVELOPMENT.md | 212 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 DEVELOPMENT.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 000000000..69f2dc760 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,212 @@ +# Contributing to Coolify +> "First, thanks for considering contributing to my project. It really means a lot!" - [@andrasbacsai](https://github.com/andrasbacsai) + +You can ask for guidance anytime on our [Discord server](https://coollabs.io/discord) in the `#contribute` channel. + +To understand the tech stack, please refer to the [Tech Stack](TECH_STACK.md) document. + + +## Table of Contents +1. [Setup Development Environment](#1-setup-development-environment) +2. [Verify Installation](#2-verify-installation-optional) +3. [Fork and Setup Local Repository](#3-fork-and-setup-local-repository) +4. [Set up Environment Variables](#4-set-up-environment-variables) +5. [Start Coolify](#5-start-coolify) +6. [Start Development](#6-start-development) +7. [Create a Pull Request](#7-create-a-pull-request) +8. [Development Notes](#development-notes) +9. [Resetting Development Environment](#resetting-development-environment) +10. [Additional Contribution Guidelines](#additional-contribution-guidelines) + + +## 1. Setup Development Environment +Follow the steps below for your operating system: + +
+Windows + +1. Install `docker-ce`, Docker Desktop (or similar): + - Docker CE (recommended): + - Install Windows Subsystem for Linux v2 (WSL2) by following this guide: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install?ref=coolify) + - After installing WSL2, install Docker CE for your Linux distribution by following this guide: [Install Docker Engine](https://docs.docker.com/engine/install/?ref=coolify) + - Make sure to choose the appropriate Linux distribution (e.g., Ubuntu) when following the Docker installation guide + - Install Docker Desktop (easier): + - Download and install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/?ref=coolify) + - Ensure WSL2 backend is enabled in Docker Desktop settings + +2. Install Spin: + - Follow the instructions to install Spin on Windows from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-windows#download-and-install-spin-into-wsl2?ref=coolify) + +
+ +
+MacOS + +1. Install Orbstack, Docker Desktop (or similar): + - Orbstack (recommended, as it is a faster and lighter alternative to Docker Desktop): + - Download and install [Orbstack](https://docs.orbstack.dev/quick-start#installation?ref=coolify) + - Docker Desktop: + - Download and install [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/?ref=coolify) + +2. Install Spin: + - Follow the instructions to install Spin on MacOS from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-macos/#download-and-install-spin?ref=coolify) + +
+ +
+Linux + +1. Install Docker Engine, Docker Desktop (or similar): + - Docker Engine (recommended, as there is no VM overhead): + - Follow the official [Docker Engine installation guide](https://docs.docker.com/engine/install/?ref=coolify) for your Linux distribution + - Docker Desktop: + - If you want a GUI, you can use [Docker Desktop for Linux](https://docs.docker.com/desktop/install/linux-install/?ref=coolify) + +2. Install Spin: + - Follow the instructions to install Spin on Linux from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-linux#configure-docker-permissions?ref=coolify) + +
+ + +## 2. Verify Installation (Optional) +After installing Docker (or Orbstack) and Spin, verify the installation: + +1. Open a terminal or command prompt +2. Run the following commands: + ```bash + docker --version + spin --version + ``` + You should see version information for both Docker and Spin. + + +## 3. Fork and Setup Local Repository +1. Fork the [Coolify](https://github.com/coollabsio/coolify) repository to your GitHub account. + +2. Install a code editor on your machine (choose one): + + | Editor | Platform | Download Link | + |--------|----------|---------------| + | Visual Studio Code (recommended free) | Windows/macOS/Linux | [Download](https://code.visualstudio.com/download?ref=coolify) | + | Cursor (recommended but paid) | Windows/macOS/Linux | [Download](https://www.cursor.com/?ref=coolify) | + | Zed (very fast) | Windows/macOS/Linux | [Download](https://zed.dev/download?ref=coolify) | + +3. Clone the Coolify Repository from your fork to your local machine + - Use `git clone` in the command line, or + - Use GitHub Desktop (recommended): + - Download and install from [https://desktop.github.com/](https://desktop.github.com/?ref=coolify) + - Open GitHub Desktop and login with your GitHub account + - Click on `File` -> `Clone Repository` select `github.com` as the repository location, then select your forked Coolify repository, choose the local path and then click `Clone` + +4. Open the cloned Coolify Repository in your chosen code editor. + + +## 4. Set up Environment Variables +1. In the Code Editor, locate the `.env.development.example` file in the root directory of your local Coolify repository. +2. Duplicate the `.env.development.example` file and rename the copy to `.env`. +3. Open the new `.env` file and review its contents. Adjust any environment variables as needed for your development setup. +4. If you encounter errors during database migrations, update the database connection settings in your `.env` file. Use the IP address or hostname of your PostgreSQL database container. You can find this information by running `docker ps` after executing `spin up`. +5. Save the changes to your `.env` file. + + +## 5. Start Coolify +1. Open a terminal in the local Coolify directory. +2. Run the following command in the terminal (leave that terminal open): + ```bash + spin up + ``` + +> [!NOTE] +> You may see some errors, but don't worry; this is expected. + +3. If you encounter permission errors, especially on macOS, use: + ```bash + sudo spin up + ``` + +> [!NOTE] +> If you change environment variables afterwards or anything seems broken, press Ctrl + C to stop the process and run `spin up` again. + + +## 6. Start Development +1. Access your Coolify instance: + - URL: `http://localhost:8000` + - Login: `test@example.com` + - Password: `password` + +2. Additional development tools: + + | Tool | URL | Note | + |------|-----|------| + | Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user | + | Mailpit (email catcher) | `http://localhost:8025` | | + | Telescope (debugging tool) | `http://localhost:8000/telescope` | Disabled by default | + +> [!NOTE] +> To enable Telescope, add the following to your `.env` file: +> ```env +> TELESCOPE_ENABLED=true +> ``` + + +## Development Notes +When working on Coolify, keep the following in mind: + +1. **Database Migrations**: After switching branches or making changes to the database structure, always run migrations: + ```bash + docker exec -it coolify php artisan migrate + ``` + +2. **Resetting Development Setup**: To reset your development setup to a clean database with default values: + ```bash + docker exec -it coolify php artisan migrate:fresh --seed + ``` + +3. **Troubleshooting**: If you encounter unexpected behavior, ensure your database is up-to-date with the latest migrations and if possible reset the development setup to eliminate any environment-specific issues. + +> [!IMPORTANT] +> Forgetting to migrate the database can cause problems, so make it a habit to run migrations after pulling changes or switching branches. + + +## Resetting Development Environment +If you encounter issues or break your database or something else, follow these steps to start from a clean slate (works since `v4.0.0-beta.342`): + +1. Stop all running containers `ctrl + c`. + +2. Remove all Coolify containers: + ```bash + docker rm coolify coolify-db coolify-redis coolify-realtime coolify-testing-host coolify-minio coolify-vite-1 coolify-mail + ``` + +3. Remove Coolify volumes (it is possible that the volumes have no `coolify` prefix on your machine, in that case remove the prefix from the command): + ```bash + docker volume rm coolify_dev_backups_data coolify_dev_postgres_data coolify_dev_redis_data coolify_dev_coolify_data coolify_dev_minio_data + ``` + +4. Remove unused images: + ```bash + docker image prune -a + ``` + +5. Start Coolify again: + ```bash + spin up + ``` + +6. Run database migrations and seeders: + ```bash + docker exec -it coolify php artisan migrate:fresh --seed + ``` + +After completing these steps, you'll have a fresh development setup. + +> [!IMPORTANT] +> Always run database migrations and seeders after switching branches or pulling updates to ensure your local database structure matches the current codebase and includes necessary seed data. + + +## Additional Development Guidelines +### Adding a New Service +To add a new service to Coolify, please refer to our documentation: [Adding a New Service](https://coolify.io/docs/get-started/contribute/service) + +### Development for Documentation +To contribute to the Coolify documentation, please refer to this guide: [Contributing to the Coolify Documentation](https://coolify.io/docs/get-started/contribute/documentation) \ No newline at end of file From 84224d63666a3d66ebc25fb3f79843dcfaff7099 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:49:45 +0530 Subject: [PATCH 008/211] chore(repo): improve bug report issue template --- .github/ISSUE_TEMPLATE/01_BUG_REPORT.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml index 42df4785e..b785ba5be 100644 --- a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml +++ b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml @@ -1,7 +1,7 @@ name: 🐞 Bug Report description: "File a new bug report." title: "[Bug]: " -labels: ["🐛 Bug", "🔍 Triage"] +labels: ["🔍 Triage"] body: - type: markdown attributes: @@ -14,10 +14,22 @@ body: - type: textarea attributes: - label: Error Message and Logs + label: Description and Error Message description: Provide a detailed description of the error or exception you encountered, along with any relevant log output. validations: required: true + + - type: textarea + attributes: + label: Expected Behavior + description: Please describe what you expected to happen instead of the issue. Be as detailed as possible. + value: | + 1. + 2. + 3. + 4. + validations: + required: true - type: textarea attributes: @@ -58,6 +70,12 @@ body: label: Operating System and Version (self-hosted) description: Run `cat /etc/os-release` or `lsb_release -a` in your terminal and provide the operating system and version. placeholder: "Ubuntu 22.04" + + - type: textarea + attributes: + label: Screenshots / Visuals + description: If possible, provide screenshots, screen recordings, or diagrams to help illustrate the issue. + placeholder: "Attach images or provide links to recordings demonstrating the problem." - type: textarea attributes: From 2b04153dece1a131910bf2fcd4cfeb97f87e4e1f Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:50:46 +0530 Subject: [PATCH 009/211] chore(repo): improve enhancement bounty issue template --- .../ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml b/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml index ef26125e0..b71f32bd2 100644 --- a/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml +++ b/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml @@ -20,6 +20,7 @@ body: - New Feature - New Service - Improvement + - Bug Fix validations: required: true @@ -29,3 +30,25 @@ body: description: Provide a detailed description of the feature, improvement, or service you are proposing. validations: required: true + + - type: textarea + attributes: + label: Requirements / How It Should Work + description: Describe in detail how the feature, service, or improvement should function. Include user flow, expected behavior, or technical implementation notes if applicable. + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + attributes: + label: Criteria to Satisfy Bounty + description: Define the conditions that must be met for the PR or contribution to be considered complete and eligible for the bounty. Be specific about functionality, tests, or documentation requirements. + value: | + 1. + 2. + 3. + validations: + required: true \ No newline at end of file From 86b05b902aedbbb074e73bfe233b3ed006f19b39 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:20:29 +0100 Subject: [PATCH 010/211] fix(auth): enforce authorization checks across API and Livewire components - Add authorization checks to API controller endpoints (view, create, update, delete) - Wrap Livewire component methods with try-catch for consistent error handling - Add AuthorizesRequests trait to components requiring authorization checks - Ensure all sensitive operations verify user permissions before execution - Implement unified error handling with handleError() helper function --- .../Api/CloudProviderTokensController.php | 4 + app/Http/Controllers/Api/GithubController.php | 3 + .../Controllers/Api/HetznerController.php | 1 + .../Controllers/Api/ProjectController.php | 6 + .../Controllers/Api/SecurityController.php | 4 + .../Controllers/Api/ServersController.php | 5 + app/Http/Controllers/Api/TeamController.php | 2 + app/Livewire/Project/Application/Heading.php | 166 +++--- app/Livewire/Project/Application/Previews.php | 24 +- app/Livewire/Project/Database/BackupNow.php | 10 +- app/Livewire/Project/Database/Heading.php | 20 +- .../Project/Database/ScheduledBackups.php | 28 +- app/Livewire/Project/DeleteEnvironment.php | 24 +- app/Livewire/Project/DeleteProject.php | 24 +- app/Livewire/Project/Service/Heading.php | 56 +- app/Livewire/Project/Shared/Destination.php | 38 +- app/Livewire/Project/Shared/HealthChecks.php | 8 +- .../Project/Shared/ResourceOperations.php | 494 +++++++++--------- app/Livewire/Security/ApiTokens.php | 14 + app/Livewire/Security/CloudInitScriptForm.php | 20 +- .../Security/CloudProviderTokenForm.php | 6 +- app/Livewire/Security/CloudProviderTokens.php | 8 +- app/Livewire/Security/PrivateKey/Index.php | 10 +- app/Livewire/Server/New/ByHetzner.php | 18 +- app/Livewire/Server/Proxy.php | 12 +- .../Proxy/DynamicConfigurationNavbar.php | 50 +- app/Livewire/Server/Resources.php | 33 +- app/Livewire/Server/Security/Patches.php | 24 +- app/Livewire/Server/Show.php | 26 +- app/Livewire/Server/ValidateAndInstall.php | 46 +- app/Livewire/SharedVariables/Project/Show.php | 10 +- app/Livewire/SharedVariables/Team/Index.php | 10 +- app/Livewire/Storage/Show.php | 6 +- app/Livewire/Team/Index.php | 34 +- app/Models/Team.php | 2 +- app/Policies/ApiTokenPolicy.php | 44 +- app/Policies/ApplicationPolicy.php | 91 ++-- app/Policies/ApplicationPreviewPolicy.php | 51 +- app/Policies/ApplicationSettingPolicy.php | 29 +- app/Policies/DatabasePolicy.php | 60 ++- app/Policies/EnvironmentPolicy.php | 29 +- app/Policies/EnvironmentVariablePolicy.php | 33 +- app/Policies/GithubAppPolicy.php | 22 +- app/Policies/NotificationPolicy.php | 17 +- app/Policies/ProjectPolicy.php | 18 +- app/Policies/ResourceCreatePolicy.php | 6 +- app/Policies/ServerPolicy.php | 21 +- app/Policies/ServiceApplicationPolicy.php | 15 +- app/Policies/ServiceDatabasePolicy.php | 23 +- app/Policies/ServicePolicy.php | 72 ++- .../SharedEnvironmentVariablePolicy.php | 18 +- app/Policies/StandaloneDockerPolicy.php | 7 +- app/Policies/SwarmDockerPolicy.php | 7 +- .../applications/advanced.blade.php | 4 +- .../components/notification/navbar.blade.php | 2 +- .../components/services/advanced.blade.php | 8 +- .../project/application/heading.blade.php | 154 +++--- .../project/database/heading.blade.php | 128 ++--- .../project/service/heading.blade.php | 14 +- .../project/shared/health-checks.blade.php | 12 +- .../livewire/security/api-tokens.blade.php | 9 +- .../views/livewire/server/navbar.blade.php | 2 +- .../views/livewire/server/resources.blade.php | 32 +- tasks/lessons.md | 11 + tests/Feature/TeamPolicyTest.php | 48 ++ tests/Unit/Policies/ApiTokenPolicyTest.php | 167 ++++++ tests/Unit/Policies/ApplicationPolicyTest.php | 237 +++++++++ .../Policies/ApplicationPreviewPolicyTest.php | 239 +++++++++ .../Policies/ApplicationSettingPolicyTest.php | 178 +++++++ tests/Unit/Policies/DatabasePolicyTest.php | 221 ++++++++ tests/Unit/Policies/EnvironmentPolicyTest.php | 155 ++++++ .../EnvironmentVariablePolicyTest.php | 199 +++++++ tests/Unit/Policies/GithubAppPolicyTest.php | 189 +++++++ .../Unit/Policies/NotificationPolicyTest.php | 175 +++++++ tests/Unit/Policies/PrivateKeyPolicyTest.php | 73 +-- tests/Unit/Policies/ProjectPolicyTest.php | 122 +++++ .../Policies/ResourceCreatePolicyTest.php | 61 +++ tests/Unit/Policies/ServerPolicyTest.php | 157 ++++++ .../Policies/ServiceApplicationPolicyTest.php | 37 ++ .../Policies/ServiceDatabasePolicyTest.php | 37 ++ tests/Unit/Policies/ServicePolicyTest.php | 233 +++++++++ .../SharedEnvironmentVariablePolicyTest.php | 144 +++++ .../Policies/StandaloneDockerPolicyTest.php | 122 +++++ tests/Unit/Policies/SwarmDockerPolicyTest.php | 122 +++++ 84 files changed, 4046 insertions(+), 1055 deletions(-) create mode 100644 tasks/lessons.md create mode 100644 tests/Unit/Policies/ApiTokenPolicyTest.php create mode 100644 tests/Unit/Policies/ApplicationPolicyTest.php create mode 100644 tests/Unit/Policies/ApplicationPreviewPolicyTest.php create mode 100644 tests/Unit/Policies/ApplicationSettingPolicyTest.php create mode 100644 tests/Unit/Policies/DatabasePolicyTest.php create mode 100644 tests/Unit/Policies/EnvironmentPolicyTest.php create mode 100644 tests/Unit/Policies/EnvironmentVariablePolicyTest.php create mode 100644 tests/Unit/Policies/GithubAppPolicyTest.php create mode 100644 tests/Unit/Policies/NotificationPolicyTest.php create mode 100644 tests/Unit/Policies/ProjectPolicyTest.php create mode 100644 tests/Unit/Policies/ResourceCreatePolicyTest.php create mode 100644 tests/Unit/Policies/ServerPolicyTest.php create mode 100644 tests/Unit/Policies/ServiceApplicationPolicyTest.php create mode 100644 tests/Unit/Policies/ServiceDatabasePolicyTest.php create mode 100644 tests/Unit/Policies/ServicePolicyTest.php create mode 100644 tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php create mode 100644 tests/Unit/Policies/StandaloneDockerPolicyTest.php create mode 100644 tests/Unit/Policies/SwarmDockerPolicyTest.php diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index 5be82a31c..5ca7212fd 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -176,6 +176,7 @@ public function show(Request $request) if (is_null($token)) { return response()->json(['message' => 'Cloud provider token not found.'], 404); } + $this->authorize('view', $token); return response()->json($this->removeSensitiveData($token)); } @@ -242,6 +243,7 @@ public function store(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [CloudProviderToken::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { @@ -386,6 +388,7 @@ public function update(Request $request) if (! $token) { return response()->json(['message' => 'Cloud provider token not found.'], 404); } + $this->authorize('update', $token); $token->update(array_intersect_key($body, array_flip($allowedFields))); @@ -459,6 +462,7 @@ public function destroy(Request $request) if (! $token) { return response()->json(['message' => 'Cloud provider token not found.'], 404); } + $this->authorize('delete', $token); if ($token->hasServers()) { return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400); diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index f6a6b3513..b5cabcde0 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -180,6 +180,7 @@ public function create_github_app(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [GithubApp::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -555,6 +556,7 @@ public function update_github_app(Request $request, $github_app_id) $githubApp = GithubApp::where('id', $github_app_id) ->where('team_id', $teamId) ->firstOrFail(); + $this->authorize('update', $githubApp); // Define allowed fields for update $allowedFields = [ @@ -721,6 +723,7 @@ public function delete_github_app($github_app_id) $githubApp = GithubApp::where('id', $github_app_id) ->where('team_id', $teamId) ->firstOrFail(); + $this->authorize('delete', $githubApp); // Check if the GitHub app is being used by any applications if ($githubApp->applications->isNotEmpty()) { diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 2645c2df1..761e951ea 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -548,6 +548,7 @@ public function createServer(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [Server::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index da553a68c..33b28fc59 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -96,6 +96,7 @@ public function project_by_uuid(Request $request) if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('view', $project); $project->load(['environments']); @@ -232,6 +233,7 @@ public function create_project(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [Project::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { @@ -378,6 +380,7 @@ public function update_project(Request $request) if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('update', $project); $project->update($request->only($allowedFields)); @@ -455,6 +458,7 @@ public function delete_project(Request $request) if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('delete', $project); if (! $project->isEmpty()) { return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); } @@ -630,6 +634,7 @@ public function create_environment(Request $request) if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('update', $project); $existingEnvironment = $project->environments()->where('name', $request->name)->first(); if ($existingEnvironment) { @@ -717,6 +722,7 @@ public function delete_environment(Request $request) if (! $environment) { return response()->json(['message' => 'Environment not found.'], 404); } + $this->authorize('delete', $environment); if (! $environment->isEmpty()) { return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400); diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index e7b36cb9a..4fe738871 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -109,6 +109,7 @@ public function key_by_uuid(Request $request) 'message' => 'Private Key not found.', ], 404); } + $this->authorize('view', $key); return response()->json($this->removeSensitiveData($key)); } @@ -175,6 +176,7 @@ public function create_key(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [PrivateKey::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -330,6 +332,7 @@ public function update_key(Request $request) 'message' => 'Private Key not found.', ], 404); } + $this->authorize('update', $foundKey); $foundKey->update($request->all()); return response()->json(serializeApiResponse([ @@ -406,6 +409,7 @@ public function delete_key(Request $request) if (is_null($key)) { return response()->json(['message' => 'Private Key not found.'], 404); } + $this->authorize('delete', $key); if ($key->isInUse()) { return response()->json([ diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 29c6b854a..67f61f347 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -144,6 +144,7 @@ public function server_by_uuid(Request $request) if (is_null($server)) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('view', $server); if ($with_resources) { $server['resources'] = $server->definedResources()->map(function ($resource) { $payload = [ @@ -464,6 +465,7 @@ public function create_server(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [ModelsServer::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { @@ -664,6 +666,7 @@ public function update_server(Request $request) if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('update', $server); if ($request->proxy_type) { $validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) { return str($proxyType->value)->lower(); @@ -757,6 +760,7 @@ public function delete_server(Request $request) if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('delete', $server); if ($server->definedResources()->count() > 0) { return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400); } @@ -835,6 +839,7 @@ public function validate_server(Request $request) if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('update', $server); ValidateServer::dispatch($server); return response()->json(['message' => 'Validation started.'], 201); diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index fd0282d96..bd0f48809 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -118,6 +118,7 @@ public function team_by_id(Request $request) if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } + $this->authorize('view', $team); $team = $this->removeSensitiveData($team); return response()->json( @@ -176,6 +177,7 @@ public function members_by_id(Request $request) if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } + $this->authorize('view', $team); $members = $team->members; $members->makeHidden([ 'pivot', diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index a46b2f19c..eb5b5f06c 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -65,58 +65,66 @@ public function manualCheckStatus() public function force_deploy_without_cache() { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $this->deploy(force_rebuild: true); + $this->deploy(force_rebuild: true); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function deploy(bool $force_rebuild = false) { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) { - $this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.'); + if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) { + $this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.'); - return; + return; + } + if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.'); + + return; + } + if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image.
More information here: documentation'); + + return; + } + if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); + + return; + } + $this->setDeploymentUuid(); + $result = queue_application_deployment( + application: $this->application, + deployment_uuid: $this->deploymentUuid, + force_rebuild: $force_rebuild, + ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } + if ($result['status'] === 'skipped') { + $this->dispatch('error', 'Deployment skipped', $result['message']); + + return; + } + + return $this->redirectRoute('project.application.deployment.show', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'application_uuid' => $this->parameters['application_uuid'], + 'deployment_uuid' => $this->deploymentUuid, + 'environment_uuid' => $this->parameters['environment_uuid'], + ], navigate: false); + } catch (\Throwable $e) { + return handleError($e, $this); } - if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.'); - - return; - } - if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image.
More information here: documentation'); - - return; - } - if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); - - return; - } - $this->setDeploymentUuid(); - $result = queue_application_deployment( - application: $this->application, - deployment_uuid: $this->deploymentUuid, - force_rebuild: $force_rebuild, - ); - if ($result['status'] === 'queue_full') { - $this->dispatch('error', 'Deployment queue full', $result['message']); - - return; - } - if ($result['status'] === 'skipped') { - $this->dispatch('error', 'Deployment skipped', $result['message']); - - return; - } - - return $this->redirectRoute('project.application.deployment.show', [ - 'project_uuid' => $this->parameters['project_uuid'], - 'application_uuid' => $this->parameters['application_uuid'], - 'deployment_uuid' => $this->deploymentUuid, - 'environment_uuid' => $this->parameters['environment_uuid'], - ], navigate: false); } protected function setDeploymentUuid() @@ -127,45 +135,53 @@ protected function setDeploymentUuid() public function stop() { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $this->dispatch('info', 'Gracefully stopping application.
It could take a while depending on the application.'); - StopApplication::dispatch($this->application, false, $this->docker_cleanup); + $this->dispatch('info', 'Gracefully stopping application.
It could take a while depending on the application.'); + StopApplication::dispatch($this->application, false, $this->docker_cleanup); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function restart() { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); + if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); - return; + return; + } + + $this->setDeploymentUuid(); + $result = queue_application_deployment( + application: $this->application, + deployment_uuid: $this->deploymentUuid, + restart_only: true, + ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } + if ($result['status'] === 'skipped') { + $this->dispatch('success', 'Deployment skipped', $result['message']); + + return; + } + + return $this->redirectRoute('project.application.deployment.show', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'application_uuid' => $this->parameters['application_uuid'], + 'deployment_uuid' => $this->deploymentUuid, + 'environment_uuid' => $this->parameters['environment_uuid'], + ], navigate: false); + } catch (\Throwable $e) { + return handleError($e, $this); } - - $this->setDeploymentUuid(); - $result = queue_application_deployment( - application: $this->application, - deployment_uuid: $this->deploymentUuid, - restart_only: true, - ); - if ($result['status'] === 'queue_full') { - $this->dispatch('error', 'Deployment queue full', $result['message']); - - return; - } - if ($result['status'] === 'skipped') { - $this->dispatch('success', 'Deployment skipped', $result['message']); - - return; - } - - return $this->redirectRoute('project.application.deployment.show', [ - 'project_uuid' => $this->parameters['project_uuid'], - 'application_uuid' => $this->parameters['application_uuid'], - 'deployment_uuid' => $this->deploymentUuid, - 'environment_uuid' => $this->parameters['environment_uuid'], - ], navigate: false); } public function render() diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 41f352c14..50acf76b2 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -215,24 +215,31 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null) public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null) { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true); + $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null) { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $this->add($pull_request_id, $pull_request_html_url); - $this->deploy($pull_request_id, $pull_request_html_url); + $this->add($pull_request_id, $pull_request_html_url); + $this->deploy($pull_request_id, $pull_request_html_url); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false) { - $this->authorize('deploy', $this->application); - try { + $this->authorize('deploy', $this->application); $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found && ! is_null($pull_request_html_url)) { @@ -291,9 +298,8 @@ private function stopContainers(array $containers, $server) public function stop(int $pull_request_id) { - $this->authorize('deploy', $this->application); - try { + $this->authorize('deploy', $this->application); $server = $this->application->destination->server; if ($this->application->destination->server->isSwarm()) { diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index decd59a4c..e4ed2a366 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -14,9 +14,13 @@ class BackupNow extends Component public function backupNow() { - $this->authorize('manageBackups', $this->backup->database); + try { + $this->authorize('manageBackups', $this->backup->database); - DatabaseBackupJob::dispatch($this->backup); - $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); + DatabaseBackupJob::dispatch($this->backup); + $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 8d3d8e294..ef2163f14 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -86,18 +86,26 @@ public function stop() public function restart() { - $this->authorize('manage', $this->database); + try { + $this->authorize('manage', $this->database); - $activity = RestartDatabase::run($this->database); - $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + $activity = RestartDatabase::run($this->database); + $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function start() { - $this->authorize('manage', $this->database); + try { + $this->authorize('manage', $this->database); - $activity = StartDatabase::run($this->database); - $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + $activity = StartDatabase::run($this->database); + $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function render() diff --git a/app/Livewire/Project/Database/ScheduledBackups.php b/app/Livewire/Project/Database/ScheduledBackups.php index 1cf5e53f6..06473c56a 100644 --- a/app/Livewire/Project/Database/ScheduledBackups.php +++ b/app/Livewire/Project/Database/ScheduledBackups.php @@ -56,22 +56,30 @@ public function setSelectedBackup($backupId, $force = false) public function setCustomType() { - $this->authorize('update', $this->database); + try { + $this->authorize('update', $this->database); - $this->database->custom_type = $this->custom_type; - $this->database->save(); - $this->dispatch('success', 'Database type set.'); - $this->refreshScheduledBackups(); + $this->database->custom_type = $this->custom_type; + $this->database->save(); + $this->dispatch('success', 'Database type set.'); + $this->refreshScheduledBackups(); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function delete($scheduled_backup_id): void { - $backup = $this->database->scheduledBackups->find($scheduled_backup_id); - $this->authorize('manageBackups', $this->database); + try { + $this->authorize('manageBackups', $this->database); - $backup->delete(); - $this->dispatch('success', 'Scheduled backup deleted.'); - $this->refreshScheduledBackups(); + $backup = $this->database->scheduledBackups->find($scheduled_backup_id); + $backup->delete(); + $this->dispatch('success', 'Scheduled backup deleted.'); + $this->refreshScheduledBackups(); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function refreshScheduledBackups(?int $id = null): void diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index aa6e95975..28027ce5a 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -30,18 +30,22 @@ public function mount() public function delete() { - $this->validate([ - 'environment_id' => 'required|int', - ]); - $environment = Environment::findOrFail($this->environment_id); - $this->authorize('delete', $environment); + try { + $this->validate([ + 'environment_id' => 'required|int', + ]); + $environment = Environment::findOrFail($this->environment_id); + $this->authorize('delete', $environment); - if ($environment->isEmpty()) { - $environment->delete(); + if ($environment->isEmpty()) { + $environment->delete(); - return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]); + return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]); + } + + return $this->dispatch('error', "Environment {$environment->name} has defined resources, please delete them first."); + } catch (\Throwable $e) { + return handleError($e, $this); } - - return $this->dispatch('error', "Environment {$environment->name} has defined resources, please delete them first."); } } diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index a018046fd..0b99f57a4 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -26,18 +26,22 @@ public function mount() public function delete() { - $this->validate([ - 'project_id' => 'required|int', - ]); - $project = Project::findOrFail($this->project_id); - $this->authorize('delete', $project); + try { + $this->validate([ + 'project_id' => 'required|int', + ]); + $project = Project::findOrFail($this->project_id); + $this->authorize('delete', $project); - if ($project->isEmpty()) { - $project->delete(); + if ($project->isEmpty()) { + $project->delete(); - return redirectRoute($this, 'project.index'); + return redirectRoute($this, 'project.index'); + } + + return $this->dispatch('error', "Project {$project->name} has resources defined, please delete them first."); + } catch (\Throwable $e) { + return handleError($e, $this); } - - return $this->dispatch('error', "Project {$project->name} has resources defined, please delete them first."); } } diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index c8a08d8f9..adc2b151b 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -7,12 +7,15 @@ use App\Actions\Service\StopService; use App\Enums\ProcessStatus; use App\Models\Service; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; use Spatie\Activitylog\Models\Activity; class Heading extends Component { + use AuthorizesRequests; + public Service $service; public array $parameters; @@ -99,13 +102,19 @@ public function checkDeployments() public function start() { - $activity = StartService::run($this->service, pullLatestImages: true); - $this->dispatch('activityMonitor', $activity->id); + try { + $this->authorize('deploy', $this->service); + $activity = StartService::run($this->service, pullLatestImages: true); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function forceDeploy() { try { + $this->authorize('deploy', $this->service); $activities = Activity::where('properties->type_uuid', $this->service->uuid) ->where(function ($q) { $q->where('properties->status', ProcessStatus::IN_PROGRESS->value) @@ -117,42 +126,53 @@ public function forceDeploy() } $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); $this->dispatch('activityMonitor', $activity->id); - } catch (\Exception $e) { - $this->dispatch('error', $e->getMessage()); + } catch (\Throwable $e) { + return handleError($e, $this); } } public function stop() { try { + $this->authorize('stop', $this->service); StopService::dispatch($this->service, false, $this->docker_cleanup); - } catch (\Exception $e) { - $this->dispatch('error', $e->getMessage()); + } catch (\Throwable $e) { + return handleError($e, $this); } } public function restart() { - $this->checkDeployments(); - if ($this->isDeploymentProgress) { - $this->dispatch('error', 'There is a deployment in progress.'); + try { + $this->authorize('deploy', $this->service); + $this->checkDeployments(); + if ($this->isDeploymentProgress) { + $this->dispatch('error', 'There is a deployment in progress.'); - return; + return; + } + $activity = StartService::run($this->service, stopBeforeStart: true); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); } - $activity = StartService::run($this->service, stopBeforeStart: true); - $this->dispatch('activityMonitor', $activity->id); } public function pullAndRestartEvent() { - $this->checkDeployments(); - if ($this->isDeploymentProgress) { - $this->dispatch('error', 'There is a deployment in progress.'); + try { + $this->authorize('deploy', $this->service); + $this->checkDeployments(); + if ($this->isDeploymentProgress) { + $this->dispatch('error', 'There is a deployment in progress.'); - return; + return; + } + $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); } - $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); - $this->dispatch('activityMonitor', $activity->id); } public function render() diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 7ab81b7d1..1eb1dc580 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -7,12 +7,15 @@ use App\Events\ApplicationStatusChanged; use App\Models\Server; use App\Models\StandaloneDocker; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; use Visus\Cuid2\Cuid2; class Destination extends Component { + use AuthorizesRequests; + public $resource; public Collection $networks; @@ -59,6 +62,7 @@ public function loadData() public function stop($serverId) { try { + $this->authorize('deploy', $this->resource); $server = Server::ownedByCurrentTeam()->findOrFail($serverId); StopApplicationOneServer::run($this->resource, $server); $this->refreshServers(); @@ -70,6 +74,7 @@ public function stop($serverId) public function redeploy(int $network_id, int $server_id) { try { + $this->authorize('deploy', $this->resource); if ($this->resource->additional_servers->count() > 0 && str($this->resource->docker_registry_image_name)->isEmpty()) { $this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); @@ -110,15 +115,20 @@ public function redeploy(int $network_id, int $server_id) public function promote(int $network_id, int $server_id) { - $main_destination = $this->resource->destination; - $this->resource->update([ - 'destination_id' => $network_id, - 'destination_type' => StandaloneDocker::class, - ]); - $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); - $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); - $this->refreshServers(); - $this->resource->refresh(); + try { + $this->authorize('update', $this->resource); + $main_destination = $this->resource->destination; + $this->resource->update([ + 'destination_id' => $network_id, + 'destination_type' => StandaloneDocker::class, + ]); + $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); + $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); + $this->refreshServers(); + $this->resource->refresh(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function refreshServers() @@ -130,13 +140,19 @@ public function refreshServers() public function addServer(int $network_id, int $server_id) { - $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]); - $this->dispatch('refresh'); + try { + $this->authorize('update', $this->resource); + $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]); + $this->dispatch('refresh'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function removeServer(int $network_id, int $server_id, $password) { try { + $this->authorize('update', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { return; } diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index df2de5142..0a47034fd 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -70,8 +70,12 @@ class HealthChecks extends Component public function mount() { - $this->authorize('view', $this->resource); - $this->syncData(); + try { + $this->authorize('view', $this->resource); + $this->syncData(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function syncData(bool $toModel = false): void diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index e769e4bcb..25545c4b0 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -47,224 +47,87 @@ public function toggleVolumeCloning(bool $value) public function cloneTo($destination_id) { - $this->authorize('update', $this->resource); + try { + $this->authorize('update', $this->resource); - $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id); - $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id); - if (! $new_destination) { - $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id); - } - if (! $new_destination) { - return $this->addError('destination_id', 'Destination not found.'); - } - $uuid = (string) new Cuid2; - $server = $new_destination->server; - - if ($this->resource->getMorphClass() === \App\Models\Application::class) { - $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); - - $route = route('project.application.configuration', [ - 'project_uuid' => $this->projectUuid, - 'environment_uuid' => $this->environmentUuid, - 'application_uuid' => $new_resource->uuid, - ]).'#resource-operations'; - - return redirect()->to($route); - } elseif ( - $this->resource->getMorphClass() === \App\Models\StandalonePostgresql::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMongodb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMysql::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMariadb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class - ) { + $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id); + $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id); + if (! $new_destination) { + $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id); + } + if (! $new_destination) { + return $this->addError('destination_id', 'Destination not found.'); + } $uuid = (string) new Cuid2; - $new_resource = $this->resource->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => $uuid, - 'name' => $this->resource->name.'-clone-'.$uuid, - 'status' => 'exited', - 'started_at' => null, - 'destination_id' => $new_destination->id, - ]); - $new_resource->save(); + $server = $new_destination->server; - $tags = $this->resource->tags; - foreach ($tags as $tag) { - $new_resource->tags()->attach($tag->id); - } + if ($this->resource->getMorphClass() === \App\Models\Application::class) { + $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); - $new_resource->persistentStorages()->delete(); - $persistentVolumes = $this->resource->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $originalName = $volume->name; - $newName = ''; + $route = route('project.application.configuration', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + 'application_uuid' => $new_resource->uuid, + ]).'#resource-operations'; - if (str_starts_with($originalName, 'postgres-data-')) { - $newName = 'postgres-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'mysql-data-')) { - $newName = 'mysql-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'redis-data-')) { - $newName = 'redis-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'clickhouse-data-')) { - $newName = 'clickhouse-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'mariadb-data-')) { - $newName = 'mariadb-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'mongodb-data-')) { - $newName = 'mongodb-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'keydb-data-')) { - $newName = 'keydb-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'dragonfly-data-')) { - $newName = 'dragonfly-data-'.$new_resource->uuid; - } else { - if (str_starts_with($volume->name, $this->resource->uuid)) { - $newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid); - } else { - $newName = $new_resource->uuid.'-'.$volume->name; - } - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'name' => $newName, - 'resource_id' => $new_resource->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopDatabase::dispatch($this->resource); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $this->resource->destination->server; - $targetServer = $new_resource->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - StartDatabase::dispatch($this->resource); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $this->resource->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $new_resource->id, - ]); - $newStorage->save(); - } - - $scheduledBackups = $this->resource->scheduledBackups()->get(); - foreach ($scheduledBackups as $backup) { + return redirect()->to($route); + } elseif ( + $this->resource->getMorphClass() === \App\Models\StandalonePostgresql::class || + $this->resource->getMorphClass() === \App\Models\StandaloneMongodb::class || + $this->resource->getMorphClass() === \App\Models\StandaloneMysql::class || + $this->resource->getMorphClass() === \App\Models\StandaloneMariadb::class || + $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class || + $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class || + $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || + $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + ) { $uuid = (string) new Cuid2; - $newBackup = $backup->replicate([ + $new_resource = $this->resource->replicate([ 'id', 'created_at', 'updated_at', ])->fill([ 'uuid' => $uuid, - 'database_id' => $new_resource->id, - 'database_type' => $new_resource->getMorphClass(), - 'team_id' => currentTeam()->id, - ]); - $newBackup->save(); - } - - $environmentVaribles = $this->resource->environment_variables()->get(); - foreach ($environmentVaribles as $environmentVarible) { - $payload = [ - 'resourceable_id' => $new_resource->id, - 'resourceable_type' => $new_resource->getMorphClass(), - ]; - $newEnvironmentVariable = $environmentVarible->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill($payload); - $newEnvironmentVariable->save(); - } - - $route = route('project.database.configuration', [ - 'project_uuid' => $this->projectUuid, - 'environment_uuid' => $this->environmentUuid, - 'database_uuid' => $new_resource->uuid, - ]).'#resource-operations'; - - return redirect()->to($route); - } elseif ($this->resource->type() === 'service') { - $uuid = (string) new Cuid2; - $new_resource = $this->resource->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => $uuid, - 'name' => $this->resource->name.'-clone-'.$uuid, - 'destination_id' => $new_destination->id, - 'destination_type' => $new_destination->getMorphClass(), - 'server_id' => $new_destination->server_id, // server_id is probably not needed anymore because of the new polymorphic relationships (here it is needed for clone to a different server to work - but maybe we can drop the column) - ]); - - $new_resource->save(); - - $tags = $this->resource->tags; - foreach ($tags as $tag) { - $new_resource->tags()->attach($tag->id); - } - - $scheduledTasks = $this->resource->scheduled_tasks()->get(); - foreach ($scheduledTasks as $task) { - $newTask = $task->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => (string) new Cuid2, - 'service_id' => $new_resource->id, - 'team_id' => currentTeam()->id, - ]); - $newTask->save(); - } - - $environmentVariables = $this->resource->environment_variables()->get(); - foreach ($environmentVariables as $environmentVariable) { - $newEnvironmentVariable = $environmentVariable->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resourceable_id' => $new_resource->id, - 'resourceable_type' => $new_resource->getMorphClass(), - ]); - $newEnvironmentVariable->save(); - } - - foreach ($new_resource->applications() as $application) { - $application->update([ + 'name' => $this->resource->name.'-clone-'.$uuid, 'status' => 'exited', + 'started_at' => null, + 'destination_id' => $new_destination->id, ]); + $new_resource->save(); - $persistentVolumes = $application->persistentStorages()->get(); + $tags = $this->resource->tags; + foreach ($tags as $tag) { + $new_resource->tags()->attach($tag->id); + } + + $new_resource->persistentStorages()->delete(); + $persistentVolumes = $this->resource->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { + $originalName = $volume->name; $newName = ''; - if (str_starts_with($volume->name, $volume->resource->uuid)) { - $newName = str($volume->name)->replace($volume->resource->uuid, $application->uuid); + + if (str_starts_with($originalName, 'postgres-data-')) { + $newName = 'postgres-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'mysql-data-')) { + $newName = 'mysql-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'redis-data-')) { + $newName = 'redis-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'clickhouse-data-')) { + $newName = 'clickhouse-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'mariadb-data-')) { + $newName = 'mariadb-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'mongodb-data-')) { + $newName = 'mongodb-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'keydb-data-')) { + $newName = 'keydb-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'dragonfly-data-')) { + $newName = 'dragonfly-data-'.$new_resource->uuid; } else { - $newName = $application->uuid.'-'.str($volume->name)->afterLast('-'); + if (str_starts_with($volume->name, $this->resource->uuid)) { + $newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid); + } else { + $newName = $new_resource->uuid.'-'.$volume->name; + } } $newPersistentVolume = $volume->replicate([ @@ -273,79 +136,220 @@ public function cloneTo($destination_id) 'updated_at', ])->fill([ 'name' => $newName, - 'resource_id' => $application->id, + 'resource_id' => $new_resource->id, ]); $newPersistentVolume->save(); if ($this->cloneVolumeData) { try { - StopService::dispatch($application); + StopDatabase::dispatch($this->resource); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; - $sourceServer = $application->service->destination->server; + $sourceServer = $this->resource->destination->server; $targetServer = $new_resource->destination->server; VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - StartService::dispatch($application); + StartDatabase::dispatch($this->resource); } catch (\Exception $e) { \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); } } } - } - foreach ($new_resource->databases() as $database) { - $database->update([ - 'status' => 'exited', - ]); - - $persistentVolumes = $database->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $volume->resource->uuid)) { - $newName = str($volume->name)->replace($volume->resource->uuid, $database->uuid); - } else { - $newName = $database->uuid.'-'.str($volume->name)->afterLast('-'); - } - - $newPersistentVolume = $volume->replicate([ + $fileStorages = $this->resource->fileStorages()->get(); + foreach ($fileStorages as $storage) { + $newStorage = $storage->replicate([ 'id', 'created_at', 'updated_at', ])->fill([ - 'name' => $newName, - 'resource_id' => $database->id, + 'resource_id' => $new_resource->id, ]); - $newPersistentVolume->save(); + $newStorage->save(); + } - if ($this->cloneVolumeData) { - try { - StopService::dispatch($database->service); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $database->service->destination->server; - $targetServer = $new_resource->destination->server; + $scheduledBackups = $this->resource->scheduledBackups()->get(); + foreach ($scheduledBackups as $backup) { + $uuid = (string) new Cuid2; + $newBackup = $backup->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'database_id' => $new_resource->id, + 'database_type' => $new_resource->getMorphClass(), + 'team_id' => currentTeam()->id, + ]); + $newBackup->save(); + } - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + $environmentVaribles = $this->resource->environment_variables()->get(); + foreach ($environmentVaribles as $environmentVarible) { + $payload = [ + 'resourceable_id' => $new_resource->id, + 'resourceable_type' => $new_resource->getMorphClass(), + ]; + $newEnvironmentVariable = $environmentVarible->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill($payload); + $newEnvironmentVariable->save(); + } - StartService::dispatch($database->service); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + $route = route('project.database.configuration', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + 'database_uuid' => $new_resource->uuid, + ]).'#resource-operations'; + + return redirect()->to($route); + } elseif ($this->resource->type() === 'service') { + $uuid = (string) new Cuid2; + $new_resource = $this->resource->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'name' => $this->resource->name.'-clone-'.$uuid, + 'destination_id' => $new_destination->id, + 'destination_type' => $new_destination->getMorphClass(), + 'server_id' => $new_destination->server_id, // server_id is probably not needed anymore because of the new polymorphic relationships (here it is needed for clone to a different server to work - but maybe we can drop the column) + ]); + + $new_resource->save(); + + $tags = $this->resource->tags; + foreach ($tags as $tag) { + $new_resource->tags()->attach($tag->id); + } + + $scheduledTasks = $this->resource->scheduled_tasks()->get(); + foreach ($scheduledTasks as $task) { + $newTask = $task->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => (string) new Cuid2, + 'service_id' => $new_resource->id, + 'team_id' => currentTeam()->id, + ]); + $newTask->save(); + } + + $environmentVariables = $this->resource->environment_variables()->get(); + foreach ($environmentVariables as $environmentVariable) { + $newEnvironmentVariable = $environmentVariable->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resourceable_id' => $new_resource->id, + 'resourceable_type' => $new_resource->getMorphClass(), + ]); + $newEnvironmentVariable->save(); + } + + foreach ($new_resource->applications() as $application) { + $application->update([ + 'status' => 'exited', + ]); + + $persistentVolumes = $application->persistentStorages()->get(); + foreach ($persistentVolumes as $volume) { + $newName = ''; + if (str_starts_with($volume->name, $volume->resource->uuid)) { + $newName = str($volume->name)->replace($volume->resource->uuid, $application->uuid); + } else { + $newName = $application->uuid.'-'.str($volume->name)->afterLast('-'); + } + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'name' => $newName, + 'resource_id' => $application->id, + ]); + $newPersistentVolume->save(); + + if ($this->cloneVolumeData) { + try { + StopService::dispatch($application); + $sourceVolume = $volume->name; + $targetVolume = $newPersistentVolume->name; + $sourceServer = $application->service->destination->server; + $targetServer = $new_resource->destination->server; + + VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + + StartService::dispatch($application); + } catch (\Exception $e) { + \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } } } } + + foreach ($new_resource->databases() as $database) { + $database->update([ + 'status' => 'exited', + ]); + + $persistentVolumes = $database->persistentStorages()->get(); + foreach ($persistentVolumes as $volume) { + $newName = ''; + if (str_starts_with($volume->name, $volume->resource->uuid)) { + $newName = str($volume->name)->replace($volume->resource->uuid, $database->uuid); + } else { + $newName = $database->uuid.'-'.str($volume->name)->afterLast('-'); + } + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'name' => $newName, + 'resource_id' => $database->id, + ]); + $newPersistentVolume->save(); + + if ($this->cloneVolumeData) { + try { + StopService::dispatch($database->service); + $sourceVolume = $volume->name; + $targetVolume = $newPersistentVolume->name; + $sourceServer = $database->service->destination->server; + $targetServer = $new_resource->destination->server; + + VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + + StartService::dispatch($database->service); + } catch (\Exception $e) { + \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } + } + } + } + + $new_resource->parse(); + + $route = route('project.service.configuration', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + 'service_uuid' => $new_resource->uuid, + ]).'#resource-operations'; + + return redirect()->to($route); } - - $new_resource->parse(); - - $route = route('project.service.configuration', [ - 'project_uuid' => $this->projectUuid, - 'environment_uuid' => $this->environmentUuid, - 'service_uuid' => $new_resource->uuid, - ]).'#resource-operations'; - - return redirect()->to($route); + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index a263acedf..d22d5d9fc 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -23,6 +23,8 @@ class ApiTokens extends Component public bool $canUseWritePermissions = false; + public bool $canUseDeployPermissions = false; + public function render() { return view('livewire.security.api-tokens'); @@ -33,6 +35,7 @@ public function mount() $this->isApiEnabled = InstanceSettings::get()->is_api_enabled; $this->canUseRootPermissions = auth()->user()->can('useRootPermissions', PersonalAccessToken::class); $this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class); + $this->canUseDeployPermissions = auth()->user()->can('useDeployPermissions', PersonalAccessToken::class); $this->getTokens(); } @@ -60,6 +63,13 @@ public function updatedPermissions($permissionToUpdate) return; } + if ($permissionToUpdate == 'deploy' && ! $this->canUseDeployPermissions) { + $this->dispatch('error', 'You do not have permission to use deploy permissions.'); + $this->permissions = array_diff($this->permissions, ['deploy']); + + return; + } + if ($permissionToUpdate == 'root') { $this->permissions = ['root']; } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) { @@ -88,6 +98,10 @@ public function addNewToken() throw new \Exception('You do not have permission to create tokens with write permissions.'); } + if (in_array('deploy', $this->permissions) && ! $this->canUseDeployPermissions) { + throw new \Exception('You do not have permission to create tokens with deploy permissions.'); + } + $this->validate([ 'description' => 'required|min:3|max:255', ]); diff --git a/app/Livewire/Security/CloudInitScriptForm.php b/app/Livewire/Security/CloudInitScriptForm.php index 33beff334..5e4ca9853 100644 --- a/app/Livewire/Security/CloudInitScriptForm.php +++ b/app/Livewire/Security/CloudInitScriptForm.php @@ -20,15 +20,19 @@ class CloudInitScriptForm extends Component public function mount(?int $scriptId = null) { - if ($scriptId) { - $this->scriptId = $scriptId; - $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); - $this->authorize('update', $cloudInitScript); + try { + if ($scriptId) { + $this->scriptId = $scriptId; + $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); + $this->authorize('update', $cloudInitScript); - $this->name = $cloudInitScript->name; - $this->script = $cloudInitScript->script; - } else { - $this->authorize('create', CloudInitScript::class); + $this->name = $cloudInitScript->name; + $this->script = $cloudInitScript->script; + } else { + $this->authorize('create', CloudInitScript::class); + } + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 7affb1531..ec4513ff3 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -21,7 +21,11 @@ class CloudProviderTokenForm extends Component public function mount() { - $this->authorize('create', CloudProviderToken::class); + try { + $this->authorize('create', CloudProviderToken::class); + } catch (\Throwable $e) { + return handleError($e, $this); + } } protected function rules(): array diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index cfef30772..b7f389534 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -14,8 +14,12 @@ class CloudProviderTokens extends Component public function mount() { - $this->authorize('viewAny', CloudProviderToken::class); - $this->loadTokens(); + try { + $this->authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function getListeners() diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 1eb66ae3e..0362b65fa 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -21,8 +21,12 @@ public function render() public function cleanupUnusedKeys() { - $this->authorize('create', PrivateKey::class); - PrivateKey::cleanupUnusedKeys(); - $this->dispatch('success', 'Unused keys have been cleaned up.'); + try { + $this->authorize('create', PrivateKey::class); + PrivateKey::cleanupUnusedKeys(); + $this->dispatch('success', 'Unused keys have been cleaned up.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index f1ffa60f2..e8df99d65 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -78,14 +78,18 @@ class ByHetzner extends Component public function mount() { - $this->authorize('viewAny', CloudProviderToken::class); - $this->loadTokens(); - $this->loadSavedCloudInitScripts(); - $this->server_name = generate_random_name(); - $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + try { + $this->authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->loadSavedCloudInitScripts(); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); - if ($this->private_keys->count() > 0) { - $this->private_key_id = $this->private_keys->first()->id; + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 1a14baf89..6c163a112 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -96,11 +96,15 @@ public function getConfigurationFilePathProperty(): string public function changeProxy() { - $this->authorize('update', $this->server); - $this->server->proxy = null; - $this->server->save(); + try { + $this->authorize('update', $this->server); + $this->server->proxy = null; + $this->server->save(); - $this->dispatch('reloadWindow'); + $this->dispatch('reloadWindow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function selectProxy($proxy_type) diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php index c67591cf5..f7db1257c 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php @@ -22,34 +22,38 @@ class DynamicConfigurationNavbar extends Component public function delete(string $fileName) { - $this->authorize('update', $this->server); - $proxy_path = $this->server->proxyPath(); - $proxy_type = $this->server->proxyType(); + try { + $this->authorize('update', $this->server); + $proxy_path = $this->server->proxyPath(); + $proxy_type = $this->server->proxyType(); - // Decode filename: pipes are used to encode dots for Livewire property binding - // (e.g., 'my|service.yaml' -> 'my.service.yaml') - // This must happen BEFORE validation because validateShellSafePath() correctly - // rejects pipe characters as dangerous shell metacharacters - $file = str_replace('|', '.', $fileName); + // Decode filename: pipes are used to encode dots for Livewire property binding + // (e.g., 'my|service.yaml' -> 'my.service.yaml') + // This must happen BEFORE validation because validateShellSafePath() correctly + // rejects pipe characters as dangerous shell metacharacters + $file = str_replace('|', '.', $fileName); - // Validate filename to prevent command injection - validateShellSafePath($file, 'proxy configuration filename'); + // Validate filename to prevent command injection + validateShellSafePath($file, 'proxy configuration filename'); - if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { - $this->dispatch('error', 'Cannot delete Caddyfile.'); + if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { + $this->dispatch('error', 'Cannot delete Caddyfile.'); - return; + return; + } + + $fullPath = "{$proxy_path}/dynamic/{$file}"; + $escapedPath = escapeshellarg($fullPath); + instant_remote_process(["rm -f {$escapedPath}"], $this->server); + if ($proxy_type === 'CADDY') { + $this->server->reloadCaddy(); + } + $this->dispatch('success', 'File deleted.'); + $this->dispatch('loadDynamicConfigurations'); + $this->dispatch('refresh'); + } catch (\Throwable $e) { + return handleError($e, $this); } - - $fullPath = "{$proxy_path}/dynamic/{$file}"; - $escapedPath = escapeshellarg($fullPath); - instant_remote_process(["rm -f {$escapedPath}"], $this->server); - if ($proxy_type === 'CADDY') { - $this->server->reloadCaddy(); - } - $this->dispatch('success', 'File deleted.'); - $this->dispatch('loadDynamicConfigurations'); - $this->dispatch('refresh'); } public function render() diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index a21b0372b..31e57b301 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -29,23 +29,38 @@ public function getListeners() public function startUnmanaged($id) { - $this->server->startUnmanaged($id); - $this->dispatch('success', 'Container started.'); - $this->loadUnmanagedContainers(); + try { + $this->authorize('update', $this->server); + $this->server->startUnmanaged($id); + $this->dispatch('success', 'Container started.'); + $this->loadUnmanagedContainers(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function restartUnmanaged($id) { - $this->server->restartUnmanaged($id); - $this->dispatch('success', 'Container restarted.'); - $this->loadUnmanagedContainers(); + try { + $this->authorize('update', $this->server); + $this->server->restartUnmanaged($id); + $this->dispatch('success', 'Container restarted.'); + $this->loadUnmanagedContainers(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function stopUnmanaged($id) { - $this->server->stopUnmanaged($id); - $this->dispatch('success', 'Container stopped.'); - $this->loadUnmanagedContainers(); + try { + $this->authorize('update', $this->server); + $this->server->stopUnmanaged($id); + $this->dispatch('success', 'Container stopped.'); + $this->loadUnmanagedContainers(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function refreshStatus() diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index b4d151424..087836da3 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -41,7 +41,11 @@ public function mount() { $this->parameters = get_route_parameters(); $this->server = Server::ownedByCurrentTeam()->whereUuid($this->parameters['server_uuid'])->firstOrFail(); - $this->authorize('viewSecurity', $this->server); + try { + $this->authorize('viewSecurity', $this->server); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function checkForUpdatesDispatch() @@ -69,14 +73,14 @@ public function checkForUpdates() public function updateAllPackages() { - $this->authorize('update', $this->server); - if (! $this->packageManager || ! $this->osId) { - $this->dispatch('error', message: 'Run "Check for updates" first.'); - - return; - } - try { + $this->authorize('update', $this->server); + if (! $this->packageManager || ! $this->osId) { + $this->dispatch('error', message: 'Run "Check for updates" first.'); + + return; + } + $activity = UpdatePackage::run( server: $this->server, packageManager: $this->packageManager, @@ -84,8 +88,8 @@ public function updateAllPackages() all: true ); $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); - } catch (\Exception $e) { - $this->dispatch('error', message: $e->getMessage()); + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 83c63a81c..38053386e 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -286,18 +286,22 @@ public function validateServer($install = true) public function checkLocalhostConnection() { - $this->syncData(true); - ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); - if ($uptime) { - $this->dispatch('success', 'Server is reachable.'); - $this->server->settings->is_reachable = $this->isReachable = true; - $this->server->settings->is_usable = $this->isUsable = true; - $this->server->settings->save(); - ServerReachabilityChanged::dispatch($this->server); - } else { - $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.

Check this documentation for further help.

Error: '.$error); + try { + $this->syncData(true); + ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); + if ($uptime) { + $this->dispatch('success', 'Server is reachable.'); + $this->server->settings->is_reachable = $this->isReachable = true; + $this->server->settings->is_usable = $this->isUsable = true; + $this->server->settings->save(); + ServerReachabilityChanged::dispatch($this->server); + } else { + $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.

Check this documentation for further help.

Error: '.$error); - return; + return; + } + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 1a5bd381b..9b0f02573 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -72,31 +72,39 @@ public function startValidatingAfterAsking() public function retry() { - $this->authorize('update', $this->server); - $this->uptime = null; - $this->supported_os_type = null; - $this->prerequisites_installed = null; - $this->docker_installed = null; - $this->docker_compose_installed = null; - $this->docker_version = null; - $this->error = null; - $this->number_of_tries = 0; - $this->init(); + try { + $this->authorize('update', $this->server); + $this->uptime = null; + $this->supported_os_type = null; + $this->prerequisites_installed = null; + $this->docker_installed = null; + $this->docker_compose_installed = null; + $this->docker_version = null; + $this->error = null; + $this->number_of_tries = 0; + $this->init(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function validateConnection() { - $this->authorize('update', $this->server); - ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); - if (! $this->uptime) { - $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error.'
'; - $this->server->update([ - 'validation_logs' => $this->error, - ]); + try { + $this->authorize('update', $this->server); + ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); + if (! $this->uptime) { + $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error.'
'; + $this->server->update([ + 'validation_logs' => $this->error, + ]); - return; + return; + } + $this->dispatch('validateOS'); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->dispatch('validateOS'); } public function validateOS() diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index b205ea1ec..008f4af5a 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -57,9 +57,13 @@ public function mount() public function switch() { - $this->authorize('view', $this->project); - $this->view = $this->view === 'normal' ? 'dev' : 'normal'; - $this->getDevView(); + try { + $this->authorize('view', $this->project); + $this->view = $this->view === 'normal' ? 'dev' : 'normal'; + $this->getDevView(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function getDevView() diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index e420686f0..93e12f376 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -51,9 +51,13 @@ public function mount() public function switch() { - $this->authorize('view', $this->team); - $this->view = $this->view === 'normal' ? 'dev' : 'normal'; - $this->getDevView(); + try { + $this->authorize('view', $this->team); + $this->view = $this->view === 'normal' ? 'dev' : 'normal'; + $this->getDevView(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function getDevView() diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index fdf3d0d28..fd8c12292 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -18,7 +18,11 @@ public function mount() if (! $this->storage) { abort(404); } - $this->authorize('view', $this->storage); + try { + $this->authorize('view', $this->storage); + } catch (\Illuminate\Auth\Access\AuthorizationException) { + return $this->redirectRoute('storage.index', navigate: true); + } } public function render() diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 8a943e6b6..e5ceb2cc9 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -95,23 +95,27 @@ public function submit() public function delete() { - $currentTeam = currentTeam(); - $this->authorize('delete', $currentTeam); - $currentTeam->delete(); + try { + $currentTeam = currentTeam(); + $this->authorize('delete', $currentTeam); + $currentTeam->delete(); - $currentTeam->members->each(function ($user) use ($currentTeam) { - if ($user->id === Auth::id()) { - return; - } - $user->teams()->detach($currentTeam); - $session = DB::table('sessions')->where('user_id', $user->id)->first(); - if ($session) { - DB::table('sessions')->where('id', $session->id)->delete(); - } - }); + $currentTeam->members->each(function ($user) use ($currentTeam) { + if ($user->id === Auth::id()) { + return; + } + $user->teams()->detach($currentTeam); + $session = DB::table('sessions')->where('user_id', $user->id)->first(); + if ($session) { + DB::table('sessions')->where('id', $session->id)->delete(); + } + }); - refreshSession(); + refreshSession(); - return redirect()->route('team.index'); + return redirect()->route('team.index'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Models/Team.php b/app/Models/Team.php index e32526169..92fed1128 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -59,7 +59,7 @@ protected static function booted() $team->webhookNotificationSettings()->create(); }); - static::saving(function ($team) { + static::updating(function ($team) { if (auth()->user()?->isMember()) { throw new \Exception('You are not allowed to update this team.'); } diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php index 761227118..5eb1a05eb 100644 --- a/app/Policies/ApiTokenPolicy.php +++ b/app/Policies/ApiTokenPolicy.php @@ -12,11 +12,6 @@ class ApiTokenPolicy */ public function viewAny(User $user): bool { - // Authorization temporarily disabled - /* - // Users can view their own API tokens - return true; - */ return true; } @@ -25,12 +20,7 @@ public function viewAny(User $user): bool */ public function view(User $user, PersonalAccessToken $token): bool { - // Authorization temporarily disabled - /* - // Users can only view their own tokens return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; - */ - return true; } /** @@ -38,11 +28,6 @@ public function view(User $user, PersonalAccessToken $token): bool */ public function create(User $user): bool { - // Authorization temporarily disabled - /* - // All authenticated users can create their own API tokens - return true; - */ return true; } @@ -51,12 +36,7 @@ public function create(User $user): bool */ public function update(User $user, PersonalAccessToken $token): bool { - // Authorization temporarily disabled - /* - // Users can only update their own tokens return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; - */ - return true; } /** @@ -64,12 +44,7 @@ public function update(User $user, PersonalAccessToken $token): bool */ public function delete(User $user, PersonalAccessToken $token): bool { - // Authorization temporarily disabled - /* - // Users can only delete their own tokens return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; - */ - return true; } /** @@ -77,11 +52,6 @@ public function delete(User $user, PersonalAccessToken $token): bool */ public function manage(User $user): bool { - // Authorization temporarily disabled - /* - // All authenticated users can manage their own API tokens - return true; - */ return true; } @@ -90,7 +60,6 @@ public function manage(User $user): bool */ public function useRootPermissions(User $user): bool { - // Only admins and owners can use root permissions return $user->isAdmin() || $user->isOwner(); } @@ -99,11 +68,14 @@ public function useRootPermissions(User $user): bool */ public function useWritePermissions(User $user): bool { - // Authorization temporarily disabled - /* - // Only admins and owners can use write permissions return $user->isAdmin() || $user->isOwner(); - */ - return true; + } + + /** + * Determine whether the user can use deploy permissions for API tokens. + */ + public function useDeployPermissions(User $user): bool + { + return $user->isAdmin() || $user->isOwner(); } } diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index d64a436ad..7a992f2fd 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -13,10 +13,6 @@ class ApplicationPolicy */ public function viewAny(User $user): bool { - // Authorization temporarily disabled - /* - return true; - */ return true; } @@ -25,11 +21,9 @@ public function viewAny(User $user): bool */ public function view(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return true; - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -37,15 +31,7 @@ public function view(User $user, Application $application): bool */ public function create(User $user): bool { - // Authorization temporarily disabled - /* - if ($user->isAdmin()) { - return true; - } - - return false; - */ - return true; + return $user->isAdmin(); } /** @@ -53,15 +39,17 @@ public function create(User $user): bool */ public function update(User $user, Application $application): Response { - // Authorization temporarily disabled - /* - if ($user->isAdmin()) { + $teamId = $this->getTeamId($application); + + if ($teamId === null) { + return Response::deny('Application team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { return Response::allow(); } - return Response::deny('As a member, you cannot update this application.

You need at least admin or owner permissions.'); - */ - return Response::allow(); + return Response::deny('You need at least admin or owner permissions to update this application.'); } /** @@ -69,15 +57,9 @@ public function update(User $user, Application $application): Response */ public function delete(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - if ($user->isAdmin()) { - return true; - } + $teamId = $this->getTeamId($application); - return false; - */ - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -85,11 +67,7 @@ public function delete(User $user, Application $application): bool */ public function restore(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return true; - */ - return true; + return false; } /** @@ -97,11 +75,7 @@ public function restore(User $user, Application $application): bool */ public function forceDelete(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + return false; } /** @@ -109,11 +83,9 @@ public function forceDelete(User $user, Application $application): bool */ public function deploy(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -121,11 +93,9 @@ public function deploy(User $user, Application $application): bool */ public function manageDeployments(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -133,11 +103,9 @@ public function manageDeployments(User $user, Application $application): bool */ public function manageEnvironment(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -145,10 +113,11 @@ public function manageEnvironment(User $user, Application $application): bool */ public function cleanupDeploymentQueue(User $user): bool { - // Authorization temporarily disabled - /* return $user->isAdmin(); - */ - return true; + } + + private function getTeamId(Application $application): ?int + { + return $application->team()?->id; } } diff --git a/app/Policies/ApplicationPreviewPolicy.php b/app/Policies/ApplicationPreviewPolicy.php index 4d371cc38..f3c13acd9 100644 --- a/app/Policies/ApplicationPreviewPolicy.php +++ b/app/Policies/ApplicationPreviewPolicy.php @@ -21,8 +21,9 @@ public function viewAny(User $user): bool */ public function view(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -30,21 +31,25 @@ public function view(User $user, ApplicationPreview $applicationPreview): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** * Determine whether the user can update the model. */ - public function update(User $user, ApplicationPreview $applicationPreview) + public function update(User $user, ApplicationPreview $applicationPreview): Response { - // if ($user->isAdmin()) { - // return Response::allow(); - // } + $teamId = $this->getTeamId($applicationPreview); - // return Response::deny('As a member, you cannot update this preview.

You need at least admin or owner permissions.'); - return true; + if ($teamId === null) { + return Response::deny('Application preview team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to update this preview.'); } /** @@ -52,8 +57,9 @@ public function update(User $user, ApplicationPreview $applicationPreview) */ public function delete(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -61,8 +67,7 @@ public function delete(User $user, ApplicationPreview $applicationPreview): bool */ public function restore(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + return false; } /** @@ -70,8 +75,7 @@ public function restore(User $user, ApplicationPreview $applicationPreview): boo */ public function forceDelete(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + return false; } /** @@ -79,8 +83,9 @@ public function forceDelete(User $user, ApplicationPreview $applicationPreview): */ public function deploy(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -88,7 +93,13 @@ public function deploy(User $user, ApplicationPreview $applicationPreview): bool */ public function manageDeployments(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId(ApplicationPreview $applicationPreview): ?int + { + return $applicationPreview->application?->team()?->id; } } diff --git a/app/Policies/ApplicationSettingPolicy.php b/app/Policies/ApplicationSettingPolicy.php index 848dc9aee..be2137cb8 100644 --- a/app/Policies/ApplicationSettingPolicy.php +++ b/app/Policies/ApplicationSettingPolicy.php @@ -20,8 +20,9 @@ public function viewAny(User $user): bool */ public function view(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationSetting); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -29,8 +30,7 @@ public function view(User $user, ApplicationSetting $applicationSetting): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -38,8 +38,9 @@ public function create(User $user): bool */ public function update(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationSetting); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -47,8 +48,9 @@ public function update(User $user, ApplicationSetting $applicationSetting): bool */ public function delete(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationSetting); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -56,8 +58,7 @@ public function delete(User $user, ApplicationSetting $applicationSetting): bool */ public function restore(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + return false; } /** @@ -65,7 +66,11 @@ public function restore(User $user, ApplicationSetting $applicationSetting): boo */ public function forceDelete(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + return false; + } + + private function getTeamId(ApplicationSetting $applicationSetting): ?int + { + return $applicationSetting->application?->team()?->id; } } diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php index f8e8af637..6a5348224 100644 --- a/app/Policies/DatabasePolicy.php +++ b/app/Policies/DatabasePolicy.php @@ -20,8 +20,9 @@ public function viewAny(User $user): bool */ public function view(User $user, $database): bool { - // return $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -29,21 +30,25 @@ public function view(User $user, $database): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** * Determine whether the user can update the model. */ - public function update(User $user, $database) + public function update(User $user, $database): Response { - // if ($user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id)) { - // return Response::allow(); - // } + $teamId = $this->getTeamId($database); - // return Response::deny('As a member, you cannot update this database.

You need at least admin or owner permissions.'); - return true; + if ($teamId === null) { + return Response::deny('Database team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to update this database.'); } /** @@ -51,8 +56,9 @@ public function update(User $user, $database) */ public function delete(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -60,8 +66,7 @@ public function delete(User $user, $database): bool */ public function restore(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + return false; } /** @@ -69,8 +74,7 @@ public function restore(User $user, $database): bool */ public function forceDelete(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + return false; } /** @@ -78,8 +82,9 @@ public function forceDelete(User $user, $database): bool */ public function manage(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -87,8 +92,9 @@ public function manage(User $user, $database): bool */ public function manageBackups(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -96,7 +102,17 @@ public function manageBackups(User $user, $database): bool */ public function manageEnvironment(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId($database): ?int + { + if (method_exists($database, 'team')) { + return $database->team()?->id; + } + + return null; } } diff --git a/app/Policies/EnvironmentPolicy.php b/app/Policies/EnvironmentPolicy.php index 7199abb25..e400ec903 100644 --- a/app/Policies/EnvironmentPolicy.php +++ b/app/Policies/EnvironmentPolicy.php @@ -20,8 +20,9 @@ public function viewAny(User $user): bool */ public function view(User $user, Environment $environment): bool { - // return $user->teams->contains('id', $environment->project->team_id); - return true; + $teamId = $this->getTeamId($environment); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -29,8 +30,7 @@ public function view(User $user, Environment $environment): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -38,8 +38,9 @@ public function create(User $user): bool */ public function update(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + $teamId = $this->getTeamId($environment); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -47,8 +48,9 @@ public function update(User $user, Environment $environment): bool */ public function delete(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + $teamId = $this->getTeamId($environment); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -56,8 +58,7 @@ public function delete(User $user, Environment $environment): bool */ public function restore(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + return false; } /** @@ -65,7 +66,11 @@ public function restore(User $user, Environment $environment): bool */ public function forceDelete(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + return false; + } + + private function getTeamId(Environment $environment): ?int + { + return $environment->project?->team_id; } } diff --git a/app/Policies/EnvironmentVariablePolicy.php b/app/Policies/EnvironmentVariablePolicy.php index 21e2ea443..dd0f58918 100644 --- a/app/Policies/EnvironmentVariablePolicy.php +++ b/app/Policies/EnvironmentVariablePolicy.php @@ -20,7 +20,9 @@ public function viewAny(User $user): bool */ public function view(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -28,7 +30,7 @@ public function view(User $user, EnvironmentVariable $environmentVariable): bool */ public function create(User $user): bool { - return true; + return $user->isAdmin(); } /** @@ -36,7 +38,9 @@ public function create(User $user): bool */ public function update(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -44,7 +48,9 @@ public function update(User $user, EnvironmentVariable $environmentVariable): bo */ public function delete(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -52,7 +58,7 @@ public function delete(User $user, EnvironmentVariable $environmentVariable): bo */ public function restore(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + return false; } /** @@ -60,7 +66,7 @@ public function restore(User $user, EnvironmentVariable $environmentVariable): b */ public function forceDelete(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + return false; } /** @@ -68,6 +74,19 @@ public function forceDelete(User $user, EnvironmentVariable $environmentVariable */ public function manageEnvironment(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId(EnvironmentVariable $environmentVariable): ?int + { + $resource = $environmentVariable->resourceable; + + if (! $resource || ! method_exists($resource, 'team')) { + return null; + } + + return $resource->team()?->id; } } diff --git a/app/Policies/GithubAppPolicy.php b/app/Policies/GithubAppPolicy.php index 56bec7032..79dd79838 100644 --- a/app/Policies/GithubAppPolicy.php +++ b/app/Policies/GithubAppPolicy.php @@ -20,8 +20,11 @@ public function viewAny(User $user): bool */ public function view(User $user, GithubApp $githubApp): bool { - // return $user->teams->contains('id', $githubApp->team_id) || $githubApp->is_system_wide; - return true; + if ($githubApp->is_system_wide) { + return true; + } + + return $user->teams->contains('id', $githubApp->team_id); } /** @@ -29,8 +32,7 @@ public function view(User $user, GithubApp $githubApp): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -39,12 +41,10 @@ public function create(User $user): bool public function update(User $user, GithubApp $githubApp): bool { if ($githubApp->is_system_wide) { - // return $user->isAdmin(); - return true; + return $user->canAccessSystemResources(); } - // return $user->isAdmin() && $user->teams->contains('id', $githubApp->team_id); - return true; + return $user->isAdminOfTeam($githubApp->team_id); } /** @@ -53,12 +53,10 @@ public function update(User $user, GithubApp $githubApp): bool public function delete(User $user, GithubApp $githubApp): bool { if ($githubApp->is_system_wide) { - // return $user->isAdmin(); - return true; + return $user->canAccessSystemResources(); } - // return $user->isAdmin() && $user->teams->contains('id', $githubApp->team_id); - return true; + return $user->isAdminOfTeam($githubApp->team_id); } /** diff --git a/app/Policies/NotificationPolicy.php b/app/Policies/NotificationPolicy.php index 4f3be431d..e8764bf13 100644 --- a/app/Policies/NotificationPolicy.php +++ b/app/Policies/NotificationPolicy.php @@ -12,13 +12,11 @@ class NotificationPolicy */ public function view(User $user, Model $notificationSettings): bool { - // Check if the notification settings belong to the user's current team if (! $notificationSettings->team) { return false; } - // return $user->teams()->where('teams.id', $notificationSettings->team->id)->exists(); - return true; + return $user->teams->contains('id', $notificationSettings->team->id); } /** @@ -26,14 +24,13 @@ public function view(User $user, Model $notificationSettings): bool */ public function update(User $user, Model $notificationSettings): bool { - // Check if the notification settings belong to the user's current team if (! $notificationSettings->team) { return false; } - // Only owners and admins can update notification settings - // return $user->isAdmin() || $user->isOwner(); - return true; + $teamId = $notificationSettings->team->id; + + return $user->isAdminOfTeam($teamId); } /** @@ -41,8 +38,7 @@ public function update(User $user, Model $notificationSettings): bool */ public function manage(User $user, Model $notificationSettings): bool { - // return $this->update($user, $notificationSettings); - return true; + return $this->update($user, $notificationSettings); } /** @@ -50,7 +46,6 @@ public function manage(User $user, Model $notificationSettings): bool */ public function sendTest(User $user, Model $notificationSettings): bool { - // return $this->update($user, $notificationSettings); - return true; + return $this->update($user, $notificationSettings); } } diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index e188c293f..9d65b9130 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -20,8 +20,7 @@ public function viewAny(User $user): bool */ public function view(User $user, Project $project): bool { - // return $user->teams->contains('id', $project->team_id); - return true; + return $user->teams->contains('id', $project->team_id); } /** @@ -29,8 +28,7 @@ public function view(User $user, Project $project): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -38,8 +36,7 @@ public function create(User $user): bool */ public function update(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return $user->isAdminOfTeam($project->team_id); } /** @@ -47,8 +44,7 @@ public function update(User $user, Project $project): bool */ public function delete(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return $user->isAdminOfTeam($project->team_id); } /** @@ -56,8 +52,7 @@ public function delete(User $user, Project $project): bool */ public function restore(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return false; } /** @@ -65,7 +60,6 @@ public function restore(User $user, Project $project): bool */ public function forceDelete(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return false; } } diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php index 9ed2b66ab..a7a855402 100644 --- a/app/Policies/ResourceCreatePolicy.php +++ b/app/Policies/ResourceCreatePolicy.php @@ -38,8 +38,7 @@ class ResourceCreatePolicy */ public function createAny(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -51,8 +50,7 @@ public function create(User $user, string $resourceClass): bool return false; } - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php index 6d2396a7d..32436987c 100644 --- a/app/Policies/ServerPolicy.php +++ b/app/Policies/ServerPolicy.php @@ -28,8 +28,7 @@ public function view(User $user, Server $server): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,8 +36,7 @@ public function create(User $user): bool */ public function update(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -46,8 +44,7 @@ public function update(User $user, Server $server): bool */ public function delete(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -71,8 +68,7 @@ public function forceDelete(User $user, Server $server): bool */ public function manageProxy(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -80,8 +76,7 @@ public function manageProxy(User $user, Server $server): bool */ public function manageSentinel(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -89,8 +84,7 @@ public function manageSentinel(User $user, Server $server): bool */ public function manageCaCertificate(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -98,7 +92,6 @@ public function manageCaCertificate(User $user, Server $server): bool */ public function viewSecurity(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } } diff --git a/app/Policies/ServiceApplicationPolicy.php b/app/Policies/ServiceApplicationPolicy.php index af380a90f..c730ab0c6 100644 --- a/app/Policies/ServiceApplicationPolicy.php +++ b/app/Policies/ServiceApplicationPolicy.php @@ -21,8 +21,7 @@ public function view(User $user, ServiceApplication $serviceApplication): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -30,8 +29,7 @@ public function create(User $user): bool */ public function update(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('update', $serviceApplication->service); - return true; + return Gate::allows('update', $serviceApplication->service); } /** @@ -39,8 +37,7 @@ public function update(User $user, ServiceApplication $serviceApplication): bool */ public function delete(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('delete', $serviceApplication->service); - return true; + return Gate::allows('delete', $serviceApplication->service); } /** @@ -48,8 +45,7 @@ public function delete(User $user, ServiceApplication $serviceApplication): bool */ public function restore(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('update', $serviceApplication->service); - return true; + return false; } /** @@ -57,7 +53,6 @@ public function restore(User $user, ServiceApplication $serviceApplication): boo */ public function forceDelete(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('delete', $serviceApplication->service); - return true; + return false; } } diff --git a/app/Policies/ServiceDatabasePolicy.php b/app/Policies/ServiceDatabasePolicy.php index f72f1f327..e5cbe91a0 100644 --- a/app/Policies/ServiceDatabasePolicy.php +++ b/app/Policies/ServiceDatabasePolicy.php @@ -13,7 +13,7 @@ class ServiceDatabasePolicy */ public function view(User $user, ServiceDatabase $serviceDatabase): bool { - return true; + return Gate::allows('view', $serviceDatabase->service); } /** @@ -21,8 +21,7 @@ public function view(User $user, ServiceDatabase $serviceDatabase): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -30,9 +29,7 @@ public function create(User $user): bool */ public function update(User $user, ServiceDatabase $serviceDatabase): bool { - - // return Gate::allows('update', $serviceDatabase->service); - return true; + return Gate::allows('update', $serviceDatabase->service); } /** @@ -40,8 +37,7 @@ public function update(User $user, ServiceDatabase $serviceDatabase): bool */ public function delete(User $user, ServiceDatabase $serviceDatabase): bool { - // return Gate::allows('delete', $serviceDatabase->service); - return true; + return Gate::allows('delete', $serviceDatabase->service); } /** @@ -49,8 +45,7 @@ public function delete(User $user, ServiceDatabase $serviceDatabase): bool */ public function restore(User $user, ServiceDatabase $serviceDatabase): bool { - // return Gate::allows('update', $serviceDatabase->service); - return true; + return false; } /** @@ -58,12 +53,14 @@ public function restore(User $user, ServiceDatabase $serviceDatabase): bool */ public function forceDelete(User $user, ServiceDatabase $serviceDatabase): bool { - // return Gate::allows('delete', $serviceDatabase->service); - return true; + return false; } + /** + * Determine whether the user can manage database backups. + */ public function manageBackups(User $user, ServiceDatabase $serviceDatabase): bool { - return true; + return Gate::allows('update', $serviceDatabase->service); } } diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index 7ab0fe7d0..d48728cdf 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -20,7 +20,9 @@ public function viewAny(User $user): bool */ public function view(User $user, Service $service): bool { - return true; + $teamId = $this->getTeamId($service); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -28,8 +30,7 @@ public function view(User $user, Service $service): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,13 +38,9 @@ public function create(User $user): bool */ public function update(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->isAdmin() && $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -51,12 +48,9 @@ public function update(User $user, Service $service): bool */ public function delete(User $user, Service $service): bool { - // if ($user->isAdmin()) { - // return true; - // } + $teamId = $this->getTeamId($service); - // return false; - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -64,8 +58,7 @@ public function delete(User $user, Service $service): bool */ public function restore(User $user, Service $service): bool { - // return true; - return true; + return false; } /** @@ -73,23 +66,17 @@ public function restore(User $user, Service $service): bool */ public function forceDelete(User $user, Service $service): bool { - // if ($user->isAdmin()) { - // return true; - // } - - // return false; - return true; + return false; } + /** + * Determine whether the user can stop the service. + */ public function stop(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -97,13 +84,9 @@ public function stop(User $user, Service $service): bool */ public function manageEnvironment(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->isAdmin() && $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -111,18 +94,23 @@ public function manageEnvironment(User $user, Service $service): bool */ public function deploy(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } + /** + * Determine whether the user can access the terminal. + */ public function accessTerminal(User $user, Service $service): bool { - // return $user->isAdmin() || $user->teams->contains('id', $service->team()->id); - return true; + $teamId = $this->getTeamId($service); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId(Service $service): ?int + { + return $service->team()?->id; } } diff --git a/app/Policies/SharedEnvironmentVariablePolicy.php b/app/Policies/SharedEnvironmentVariablePolicy.php index b465d8a0c..21b6acb27 100644 --- a/app/Policies/SharedEnvironmentVariablePolicy.php +++ b/app/Policies/SharedEnvironmentVariablePolicy.php @@ -28,8 +28,7 @@ public function view(User $user, SharedEnvironmentVariable $sharedEnvironmentVar */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,8 +36,7 @@ public function create(User $user): bool */ public function update(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); } /** @@ -46,8 +44,7 @@ public function update(User $user, SharedEnvironmentVariable $sharedEnvironmentV */ public function delete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); } /** @@ -55,8 +52,7 @@ public function delete(User $user, SharedEnvironmentVariable $sharedEnvironmentV */ public function restore(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return false; } /** @@ -64,8 +60,7 @@ public function restore(User $user, SharedEnvironmentVariable $sharedEnvironment */ public function forceDelete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return false; } /** @@ -73,7 +68,6 @@ public function forceDelete(User $user, SharedEnvironmentVariable $sharedEnviron */ public function manageEnvironment(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); } } diff --git a/app/Policies/StandaloneDockerPolicy.php b/app/Policies/StandaloneDockerPolicy.php index 3e1f83d12..33eda183a 100644 --- a/app/Policies/StandaloneDockerPolicy.php +++ b/app/Policies/StandaloneDockerPolicy.php @@ -28,8 +28,7 @@ public function view(User $user, StandaloneDocker $standaloneDocker): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,7 +36,7 @@ public function create(User $user): bool */ public function update(User $user, StandaloneDocker $standaloneDocker): bool { - return $user->teams->contains('id', $standaloneDocker->server->team_id); + return $user->isAdminOfTeam($standaloneDocker->server->team_id); } /** @@ -45,7 +44,7 @@ public function update(User $user, StandaloneDocker $standaloneDocker): bool */ public function delete(User $user, StandaloneDocker $standaloneDocker): bool { - return $user->teams->contains('id', $standaloneDocker->server->team_id); + return $user->isAdminOfTeam($standaloneDocker->server->team_id); } /** diff --git a/app/Policies/SwarmDockerPolicy.php b/app/Policies/SwarmDockerPolicy.php index 82a75910b..b19ab4907 100644 --- a/app/Policies/SwarmDockerPolicy.php +++ b/app/Policies/SwarmDockerPolicy.php @@ -28,8 +28,7 @@ public function view(User $user, SwarmDocker $swarmDocker): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,7 +36,7 @@ public function create(User $user): bool */ public function update(User $user, SwarmDocker $swarmDocker): bool { - return $user->teams->contains('id', $swarmDocker->server->team_id); + return $user->isAdminOfTeam($swarmDocker->server->team_id); } /** @@ -45,7 +44,7 @@ public function update(User $user, SwarmDocker $swarmDocker): bool */ public function delete(User $user, SwarmDocker $swarmDocker): bool { - return $user->teams->contains('id', $swarmDocker->server->team_id); + return $user->isAdminOfTeam($swarmDocker->server->team_id); } /** diff --git a/resources/views/components/applications/advanced.blade.php b/resources/views/components/applications/advanced.blade.php index e36583741..5964abb4e 100644 --- a/resources/views/components/applications/advanced.blade.php +++ b/resources/views/components/applications/advanced.blade.php @@ -3,7 +3,7 @@ Advanced @if ($application->status === 'running') -
- - + @if ($isPasswordHiddenForMember) + + + @else + + + @endif
@can('validateConnection', $storage) From 66dc1515d43781347fd79ca7f713eeaded4b0150 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:58:44 +0100 Subject: [PATCH 027/211] fix(security): prevent snapshot replay in API token permission checks Never trust Livewire component properties for authorization decisions, as snapshots can be replayed from another user's session. Re-evaluate all permission checks fresh using auth()->user()->can() against current policies to ensure the authenticated user is being authorized, not a replayed copy. - Replace cached canUse* booleans with fresh policy evaluation - Add comprehensive security tests for token creation permissions - Update API authorization tests to verify middleware blocking behavior --- app/Livewire/Security/ApiTokens.php | 24 +-- .../Authorization/ApiAuthorizationTest.php | 4 +- .../Authorization/ApiTokenPermissionTest.php | 3 + .../Security/ApiTokenCreationSecurityTest.php | 166 ++++++++++++++++++ 4 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 tests/Feature/Security/ApiTokenCreationSecurityTest.php diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index af2ae189a..ffd8f28cf 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -50,31 +50,29 @@ private function getTokens() public function updatedPermissions($permissionToUpdate) { - // Check if user is trying to use restricted permissions - if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) { + // Re-evaluate policies fresh — never trust stored snapshot booleans + if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use root permissions.'); - // Remove root from permissions if it was somehow added $this->permissions = array_diff($this->permissions, ['root']); return; } - if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) { + if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use write permissions.'); - // Remove write permissions if they were somehow added $this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']); return; } - if ($permissionToUpdate == 'deploy' && ! $this->canUseDeployPermissions) { + if ($permissionToUpdate == 'deploy' && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use deploy permissions.'); $this->permissions = array_diff($this->permissions, ['deploy']); return; } - if ($permissionToUpdate == 'read:sensitive' && ! $this->canUseSensitivePermissions) { + if ($permissionToUpdate == 'read:sensitive' && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use read:sensitive permissions.'); $this->permissions = array_diff($this->permissions, ['read:sensitive']); @@ -100,20 +98,22 @@ public function addNewToken() try { $this->authorize('create', PersonalAccessToken::class); - // Validate permissions based on user role - if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) { + // Re-evaluate policies fresh against the current authenticated user. + // Never trust $this->canUse* booleans — they come from the Livewire + // snapshot which can be replayed from another user's session. + if (in_array('root', $this->permissions) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with root permissions.'); } - if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) { + if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with write permissions.'); } - if (in_array('deploy', $this->permissions) && ! $this->canUseDeployPermissions) { + if (in_array('deploy', $this->permissions) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with deploy permissions.'); } - if (in_array('read:sensitive', $this->permissions) && ! $this->canUseSensitivePermissions) { + if (in_array('read:sensitive', $this->permissions) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.'); } diff --git a/tests/Feature/Authorization/ApiAuthorizationTest.php b/tests/Feature/Authorization/ApiAuthorizationTest.php index 59bfd9659..66a6900a3 100644 --- a/tests/Feature/Authorization/ApiAuthorizationTest.php +++ b/tests/Feature/Authorization/ApiAuthorizationTest.php @@ -123,10 +123,10 @@ // --- Member with root token (policy should deny mutations) --- -test('member with root token can view project', function () { +test('member with root token is blocked by middleware', function () { $this->withToken($this->memberRootToken->plainTextToken) ->getJson("/api/v1/projects/{$this->project->uuid}") - ->assertSuccessful(); + ->assertStatus(403); }); test('member with root token cannot delete project', function () { diff --git a/tests/Feature/Authorization/ApiTokenPermissionTest.php b/tests/Feature/Authorization/ApiTokenPermissionTest.php index 44efb7e06..b10afb58e 100644 --- a/tests/Feature/Authorization/ApiTokenPermissionTest.php +++ b/tests/Feature/Authorization/ApiTokenPermissionTest.php @@ -1,5 +1,6 @@ 0], ['is_api_enabled' => true]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); diff --git a/tests/Feature/Security/ApiTokenCreationSecurityTest.php b/tests/Feature/Security/ApiTokenCreationSecurityTest.php new file mode 100644 index 000000000..0e5d363de --- /dev/null +++ b/tests/Feature/Security/ApiTokenCreationSecurityTest.php @@ -0,0 +1,166 @@ + 0], ['is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + + $this->owner = User::factory()->create(); + $this->owner->teams()->attach($this->team, ['role' => 'owner']); + + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); +}); + +describe('Livewire ApiTokens — member cannot create elevated tokens', function () { + test('member cannot create token with root permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-root-token') + ->set('permissions', ['root']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot create token with write permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-write-token') + ->set('permissions', ['write']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot create token with deploy permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-deploy-token') + ->set('permissions', ['deploy']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot create token with read:sensitive permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-sensitive-token') + ->set('permissions', ['read', 'read:sensitive']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot bypass by setting canUseRootPermissions property', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + // Simulate snapshot replay: force the boolean to true + Livewire::test(ApiTokens::class) + ->set('canUseRootPermissions', true) + ->set('description', 'sneaky-root-token') + ->set('permissions', ['root']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member can create token with read permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-read-token') + ->set('permissions', ['read']) + ->call('addNewToken') + ->assertNotDispatched('error'); + + expect($this->member->tokens()->count())->toBe(1); + expect($this->member->tokens()->first()->abilities)->toBe(['read']); + }); + + test('owner can create token with root permissions', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-root-token') + ->set('permissions', ['root']) + ->call('addNewToken') + ->assertNotDispatched('error'); + + expect($this->owner->tokens()->count())->toBe(1); + expect($this->owner->tokens()->first()->abilities)->toBe(['root']); + }); +}); + +describe('ApiAbility middleware — member with elevated token blocked', function () { + test('member root token is blocked on team_id=0 (root team)', function () { + // Create root team with id=0 + $rootTeam = Team::factory()->create(['id' => 0]); + $member = User::factory()->create(); + $rootTeam->members()->attach($member->id, ['role' => 'member']); + + session(['currentTeam' => $rootTeam]); + $token = $member->createToken('root-token', ['root']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertStatus(403); + }); + + test('admin root token passes on team_id=0 (root team)', function () { + $rootTeam = Team::factory()->create(['id' => 0]); + $admin = User::factory()->create(); + $rootTeam->members()->attach($admin->id, ['role' => 'admin']); + + session(['currentTeam' => $rootTeam]); + $token = $admin->createToken('root-token', ['root']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertSuccessful(); + }); + + test('member root token is blocked on non-zero team', function () { + session(['currentTeam' => $this->team]); + $token = $this->member->createToken('root-token', ['root']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertStatus(403); + }); + + test('member read token passes on non-zero team', function () { + session(['currentTeam' => $this->team]); + $token = $this->member->createToken('read-token', ['read']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertSuccessful(); + }); +}); From b2f09f4df068f8b7bab2dda93938ca88501266e2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:11:03 +0100 Subject: [PATCH 028/211] fix(auth): resolve current team from Sanctum token for API requests Add fallback to resolve team from Sanctum access token when session team is unavailable, enabling proper team context for stateless API requests. --- app/Models/User.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Models/User.php b/app/Models/User.php index 4561cddb2..68ecc6b31 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -316,6 +316,11 @@ public function currentTeam(): ?Team { $sessionTeamId = data_get(session('currentTeam'), 'id'); + // Fallback for stateless API requests: resolve team from Sanctum token + if (is_null($sessionTeamId) && $this->currentAccessToken()) { + $sessionTeamId = data_get($this->currentAccessToken(), 'team_id'); + } + if (is_null($sessionTeamId)) { return null; } From b38ce26e34d185c283f40fbe76919f6540f6e15f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:08:29 +0100 Subject: [PATCH 029/211] fix(auth): preserve Sanctum token prefix for lookups Sanctum uses the numeric prefix (e.g. "69|...") in plaintext tokens to index and look up tokens. Stripping this prefix breaks token resolution. --- app/Livewire/Security/ApiTokens.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index ffd8f28cf..97d2bfb44 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -4,7 +4,6 @@ use App\Models\InstanceSettings; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Str; use Laravel\Sanctum\PersonalAccessToken; use Livewire\Component; @@ -122,7 +121,8 @@ public function addNewToken() ]); $token = auth()->user()->createToken($this->description, array_values($this->permissions)); $this->getTokens(); - session()->flash('token', Str::after($token->plainTextToken, '|')); + // Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens. + session()->flash('token', $token->plainTextToken); } catch (\Exception $e) { return handleError($e, $this); } From 63008fceb3d74643e8508e0856394804e06ce961 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 15:15:00 +0100 Subject: [PATCH 030/211] feat(api): add ownedByCurrentTeamAPI scope to Environment model --- app/Models/Environment.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Models/Environment.php b/app/Models/Environment.php index d4e614e6e..ae027a585 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -42,6 +42,11 @@ public static function ownedByCurrentTeam() return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name'); } + public static function ownedByCurrentTeamAPI(int $teamId) + { + return Environment::whereRelation('project.team', 'id', $teamId)->orderBy('name'); + } + public function isEmpty() { return $this->applications()->count() == 0 && From d8178df838f041c8ebe3a920ed6a2d71e1d6f879 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 15:48:00 +0100 Subject: [PATCH 031/211] feat(api): add shared helper for moving resources between environments --- bootstrap/helpers/api.php | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 43c074cd1..b0addaf0e 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -144,6 +144,50 @@ function sharedDataApplications() ]; } +function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): \Illuminate\Http\JsonResponse +{ + + $validator = \Illuminate\Support\Facades\Validator::make($request->all(), [ + 'environment_uuid' => 'required|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $extraFields = array_diff(array_keys($request->all()), ['environment_uuid']); + if (! empty($extraFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(), + ], 422); + } + + $newEnvironment = \App\Models\Environment::ownedByCurrentTeamAPI($teamId) + ->whereUuid($request->environment_uuid) + ->first(); + + if (! $newEnvironment) { + return response()->json(['message' => 'Target environment not found or not owned by your team.'], 404); + } + + if ($resource->environment_id === $newEnvironment->id) { + return response()->json(['message' => "$resourceType is already in this environment."], 400); + } + + $resource->update(['environment_id' => $newEnvironment->id]); + + return response()->json([ + 'message' => "$resourceType moved successfully.", + 'uuid' => $resource->uuid, + 'project_uuid' => $newEnvironment->project->uuid, + 'environment_uuid' => $newEnvironment->uuid, + ]); +} + function validateIncomingRequest(Request $request) { // check if request is json From 6b9a755e32db7e0022983d40d1967ac7f325ad91 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 16:32:00 +0100 Subject: [PATCH 032/211] feat(api): add POST /move endpoints for applications, databases, and services --- .../Api/ApplicationsController.php | 93 +++++++++++++++++++ .../Controllers/Api/DatabasesController.php | 93 +++++++++++++++++++ .../Controllers/Api/ServicesController.php | 93 +++++++++++++++++++ routes/api.php | 3 + 4 files changed, 282 insertions(+) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 4b0cfc6ab..492dd8c93 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3828,6 +3828,99 @@ public function action_restart(Request $request) ); } + #[OA\Post( + summary: 'Move', + description: 'Move application to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.', + path: '/applications/{uuid}/move', + operationId: 'move-application-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the application to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Application moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Application moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + return moveResourceToEnvironment($request, $application, 'Application', $teamId); + } + private function validateDataApplications(Request $request, Server $server) { $teamId = getTeamIdFromToken(); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index f7a62cf90..c37edf6cd 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2503,6 +2503,99 @@ public function list_backup_executions(Request $request) ]); } + #[OA\Post( + summary: 'Move', + description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.', + path: '/databases/{uuid}/move', + operationId: 'move-database-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the database to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Database moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Database moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + return moveResourceToEnvironment($request, $database, 'Database', $teamId); + } + #[OA\Get( summary: 'Start', description: 'Start database. `Post` request is also accepted.', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 32097443e..a4a74463a 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1591,6 +1591,99 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable deleted.']); } + #[OA\Post( + summary: 'Move', + description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.', + path: '/services/{uuid}/move', + operationId: 'move-service-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the service to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Service moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Service moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('update', $service); + + return moveResourceToEnvironment($request, $service, 'Service', $teamId); + } + #[OA\Get( summary: 'Start', description: 'Start service. `Post` request is also accepted.', diff --git a/routes/api.php b/routes/api.php index 8b28177f3..a3dfc02d5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -121,6 +121,7 @@ Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/move', [ApplicationsController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); @@ -152,6 +153,7 @@ Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); + Route::post('/databases/{uuid}/move', [DatabasesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); @@ -169,6 +171,7 @@ Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::post('/services/{uuid}/move', [ServicesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); From 94700347f80f5280a1e6f52ebb58543ba9ec1473 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 17:05:00 +0100 Subject: [PATCH 033/211] test: add move resource API tests --- tests/Feature/MoveResourceApiTest.php | 238 ++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 tests/Feature/MoveResourceApiTest.php diff --git a/tests/Feature/MoveResourceApiTest.php b/tests/Feature/MoveResourceApiTest.php new file mode 100644 index 000000000..80d50122b --- /dev/null +++ b/tests/Feature/MoveResourceApiTest.php @@ -0,0 +1,238 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->targetProject = Project::factory()->create(['team_id' => $this->team->id]); + $this->targetEnvironment = Environment::factory()->create(['project_id' => $this->targetProject->id]); +}); + +describe('POST /api/v1/applications/{uuid}/move', function () { + test('moves application to another environment', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Application moved successfully.']); + $response->assertJsonStructure(['message', 'uuid', 'project_uuid', 'environment_uuid']); + + $application->refresh(); + expect($application->environment_id)->toBe($this->targetEnvironment->id); + }); + + test('returns 404 when application not found', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/applications/non-existent-uuid/move', [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(404); + }); + + test('returns 422 when environment_uuid is missing', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", []); + + $response->assertStatus(422); + }); + + test('returns 422 when extra fields are provided', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + 'environment_uuid' => $this->targetEnvironment->uuid, + 'bogus_field' => 'value', + ]); + + $response->assertStatus(422); + }); + + test('returns 404 when target environment belongs to another team', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + 'environment_uuid' => $otherEnvironment->uuid, + ]); + + $response->assertStatus(404); + }); + + test('returns 400 when application is already in the target environment', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + 'environment_uuid' => $this->environment->uuid, + ]); + + $response->assertStatus(400); + }); + + test('preserves resource-level environment variables after move', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + \App\Models\EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(200); + + $application->refresh(); + $envVar = $application->environment_variables->where('key', 'TEST_VAR')->first(); + expect($envVar)->not->toBeNull(); + expect($envVar->value)->toBe('test-value'); + }); +}); + +describe('POST /api/v1/databases/{uuid}/move', function () { + test('moves database to another environment', function () { + $database = StandalonePostgresql::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/move", [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Database moved successfully.']); + + $database->refresh(); + expect($database->environment_id)->toBe($this->targetEnvironment->id); + }); + + test('returns 404 when database not found', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/databases/non-existent-uuid/move', [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/services/{uuid}/move', function () { + test('moves service to another environment', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/services/{$service->uuid}/move", [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Service moved successfully.']); + + $service->refresh(); + expect($service->environment_id)->toBe($this->targetEnvironment->id); + }); + + test('returns 404 when service not found', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/services/non-existent-uuid/move', [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(404); + }); +}); From 2f8df2f9bd86f5d0337e28a93a412fe40f2016cb Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 17:25:00 +0100 Subject: [PATCH 034/211] fix(test): align test setup with project conventions --- tests/Feature/MoveResourceApiTest.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/Feature/MoveResourceApiTest.php b/tests/Feature/MoveResourceApiTest.php index 80d50122b..faf4f699b 100644 --- a/tests/Feature/MoveResourceApiTest.php +++ b/tests/Feature/MoveResourceApiTest.php @@ -2,6 +2,7 @@ use App\Models\Application; use App\Models\Environment; +use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; use App\Models\Service; @@ -14,6 +15,8 @@ uses(RefreshDatabase::class); beforeEach(function () { + InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -24,12 +27,12 @@ $this->bearerToken = $this->token->plainTextToken; $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); - $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->environment = $this->project->environments()->first(); $this->targetProject = Project::factory()->create(['team_id' => $this->team->id]); - $this->targetEnvironment = Environment::factory()->create(['project_id' => $this->targetProject->id]); + $this->targetEnvironment = $this->targetProject->environments()->first(); }); describe('POST /api/v1/applications/{uuid}/move', function () { @@ -170,7 +173,11 @@ describe('POST /api/v1/databases/{uuid}/move', function () { test('moves database to another environment', function () { - $database = StandalonePostgresql::factory()->create([ + $database = StandalonePostgresql::create([ + 'name' => 'test-pg', + 'postgres_user' => 'postgres', + 'postgres_password' => 'secret', + 'postgres_db' => 'testdb', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), From 8ad65a0ef8607f7db7e1b36ec5f55a86e2a430ca Mon Sep 17 00:00:00 2001 From: Bakr Date: Sun, 29 Mar 2026 06:03:47 +0300 Subject: [PATCH 035/211] eat(api): add service-applications API to manage service applications --- .../Service/DeployServiceApplication.php | 58 ++ .../Service/RestartServiceApplication.php | 24 + .../Service/StopServiceApplication.php | 24 + .../UpdateServiceApplicationFromApi.php | 107 +++ .../Api/ServiceApplicationsController.php | 754 ++++++++++++++++++ app/Policies/ServiceApplicationPolicy.php | 11 +- app/Support/ServiceComposeUrl.php | 55 ++ routes/api.php | 9 + tests/Feature/ServiceApplicationsApiTest.php | 258 ++++++ 9 files changed, 1298 insertions(+), 2 deletions(-) create mode 100644 app/Actions/Service/DeployServiceApplication.php create mode 100644 app/Actions/Service/RestartServiceApplication.php create mode 100644 app/Actions/Service/StopServiceApplication.php create mode 100644 app/Actions/Service/UpdateServiceApplicationFromApi.php create mode 100644 app/Http/Controllers/Api/ServiceApplicationsController.php create mode 100644 app/Support/ServiceComposeUrl.php create mode 100644 tests/Feature/ServiceApplicationsApiTest.php diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php new file mode 100644 index 000000000..f79437a26 --- /dev/null +++ b/app/Actions/Service/DeployServiceApplication.php @@ -0,0 +1,58 @@ +service; + $composeServiceName = $serviceApplication->name; + + $service->parse(); + $service->saveComposeConfigs(); + $service->isConfigurationChanged(save: true); + + $workdir = $service->workdir(); + $commands = collect([ + "echo 'Saved configuration files to {$workdir}.'", + "touch {$workdir}/.env", + ]); + + if ($pullLatestImages) { + $commands->push('echo Pulling image for service.'); + $commands->push("docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} pull {$composeServiceName}"); + } + + if ($service->networks()->count() > 0) { + $commands->push('echo Creating Docker network.'); + $commands->push("docker network inspect {$service->uuid} >/dev/null 2>&1 || docker network create --attachable {$service->uuid}"); + } + + $upCommand = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --no-deps"; + if ($forceRebuild) { + $upCommand .= ' --build'; + } + $upCommand .= " {$composeServiceName}"; + $commands->push('echo Starting service container.'); + $commands->push($upCommand); + + $commands->push("docker network connect {$service->uuid} coolify-proxy >/dev/null 2>&1 || true"); + + if (data_get($service, 'connect_to_docker_network')) { + $compose = data_get($service, 'docker_compose', []); + $network = $service->destination->network; + $commands->push("docker network connect --alias {$composeServiceName}-{$service->uuid} {$network} {$composeServiceName}-{$service->uuid} >/dev/null 2>&1 || true"); + } + + return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); + } +} diff --git a/app/Actions/Service/RestartServiceApplication.php b/app/Actions/Service/RestartServiceApplication.php new file mode 100644 index 000000000..f5a7883e5 --- /dev/null +++ b/app/Actions/Service/RestartServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = $serviceApplication->name.'-'.$service->uuid; + + instant_remote_process([ + "docker restart {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php new file mode 100644 index 000000000..d34f1f3c8 --- /dev/null +++ b/app/Actions/Service/StopServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = $serviceApplication->name.'-'.$service->uuid; + + instant_remote_process([ + "docker stop {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php new file mode 100644 index 000000000..8a2ce6ecd --- /dev/null +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -0,0 +1,107 @@ +boolean('force_domain_override'); + + if ($request->has('url')) { + $urlRaw = $request->input('url'); + if ($urlRaw !== null && ! is_string($urlRaw)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['url' => 'The url must be a string.'], + ], 422); + } + + $parsed = ServiceComposeUrl::validateUrlString( + is_string($urlRaw) ? $urlRaw : null, + $forceDomainOverride + ); + + if (count($parsed['errors']) > 0) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $parsed['errors'], + ], 422); + } + + if ($parsed['normalized'] !== null) { + $containerUrls = str($parsed['normalized']) + ->explode(',') + ->map(fn ($url) => str(trim((string) $url))->lower()); + + $result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $serviceApplication->uuid); + if (isset($result['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$result['error']], + ], 422); + } + + if ($result['hasConflicts'] && ! $forceDomainOverride) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + } + + $serviceApplication->fqdn = $parsed['normalized']; + } + + if ($request->has('human_name')) { + $serviceApplication->human_name = $request->input('human_name'); + } + + if ($request->has('description')) { + $serviceApplication->description = $request->input('description'); + } + + if ($request->has('image')) { + $serviceApplication->image = $request->input('image'); + } + + if ($request->has('exclude_from_status')) { + $serviceApplication->exclude_from_status = $request->boolean('exclude_from_status'); + } + + if ($request->has('is_gzip_enabled')) { + $serviceApplication->is_gzip_enabled = $request->boolean('is_gzip_enabled'); + } + + if ($request->has('is_stripprefix_enabled')) { + $serviceApplication->is_stripprefix_enabled = $request->boolean('is_stripprefix_enabled'); + } + + if ($request->has('is_log_drain_enabled')) { + $enabled = $request->boolean('is_log_drain_enabled'); + $server = $serviceApplication->service->destination->server; + if ($enabled && ! $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); + } + $serviceApplication->is_log_drain_enabled = $enabled; + } + + $serviceApplication->save(); + $serviceApplication->refresh(); + + updateCompose($serviceApplication); + + return null; + } +} diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php new file mode 100644 index 000000000..da3a8b337 --- /dev/null +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -0,0 +1,754 @@ +makeHidden([ + 'id', + 'resourceable', + 'resourceable_id', + 'resourceable_type', + ]); + + return serializeApiResponse($serviceApplication); + } + + private function resolveService(Request $request, int $teamId): ?Service + { + $uuid = $request->route('uuid'); + if (! $uuid) { + return null; + } + + return Service::whereRelation('environment.project.team', 'id', $teamId) + ->whereUuid($uuid) + ->first(); + } + + private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication + { + $appUuid = $request->route('app_uuid'); + if (! $appUuid) { + return null; + } + + return $service->applications() + ->where('uuid', $appUuid) + ->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 applications', + description: 'List compose service applications (containers) for a single service.', + path: '/services/{uuid}/applications', + operationId: 'list-service-applications-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + 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 applications for this service.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + 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); + + $items = $service->applications() + ->get() + ->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa)); + + return response()->json($items); + } + + #[OA\Get( + summary: 'Get service application', + description: 'Get a single compose service application by service UUID and application UUID.', + path: '/services/{uuid}/applications/{app_uuid}', + operationId: 'get-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Service application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(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); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('view', $serviceApplication); + + return response()->json($this->removeSensitiveData($serviceApplication)); + } + + #[OA\Patch( + summary: 'Update service application', + description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).', + path: '/services/{uuid}/applications/{app_uuid}', + operationId: 'patch-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'force_domain_override', + in: 'query', + description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + requestBody: new OA\RequestBody( + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'url' => new OA\Property( + property: 'url', + type: 'string', + nullable: true, + description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.' + ), + 'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true), + 'description' => new OA\Property(property: 'description', type: 'string', nullable: true), + 'image' => new OA\Property(property: 'image', type: 'string', nullable: true), + 'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true), + 'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true), + 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), + 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), + ] + ) + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Updated service application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(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: 'Domain conflicts (unless force_domain_override).', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('update', $serviceApplication); + + $allowedFields = [ + 'url', + 'human_name', + 'description', + 'image', + 'exclude_from_status', + 'is_log_drain_enabled', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + ]; + + $validationRules = [ + 'url' => 'nullable|string', + 'human_name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'image' => 'nullable|string', + 'exclude_from_status' => 'sometimes|boolean', + 'is_log_drain_enabled' => 'sometimes|boolean', + 'is_gzip_enabled' => 'sometimes|boolean', + 'is_stripprefix_enabled' => 'sometimes|boolean', + ]; + + $validator = Validator::make($request->all(), $validationRules); + + $extraFields = array_diff(array_keys($request->all()), $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); + } + + $response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId); + if ($response instanceof JsonResponse) { + return $response; + } + + $serviceApplication->refresh(); + + return response()->json($this->removeSensitiveData($serviceApplication)); + } + + #[OA\Get( + summary: 'Get service application logs', + description: 'Get Docker logs for a single compose service container.', + path: '/services/{uuid}/applications/{app_uuid}/logs', + operationId: 'get-service-application-logs-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema(type: 'integer', format: 'int32', default: 100) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Logs.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => 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(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('view', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + $containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid; + + $status = getContainerStatus($server, $containerName); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Service application container is not running.', + ], 400); + } + + $lines = (int) ($request->query('lines', 100) ?: 100); + $logs = getContainerLogs($server, $containerName, $lines); + + return response()->json([ + 'logs' => $logs, + ]); + } + + #[OA\Get( + 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: 'start-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'force', + in: 'query', + description: 'When true, passes --build to docker compose up.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + new OA\Parameter( + name: 'latest', + in: 'query', + description: 'When true, pulls the image for this compose service before up.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Deploy request queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + 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(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + $pullLatest = $request->boolean('latest', false); + $forceRebuild = $request->boolean('force', false); + + DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild); + + return response()->json([ + 'message' => 'Service application deploy request queued.', + ], 200); + } + + #[OA\Get( + summary: 'Restart service application container', + description: 'Restarts a single compose service container (docker restart).', + path: '/services/{uuid}/applications/{app_uuid}/restart', + operationId: 'restart-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Restart queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + 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(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + RestartServiceApplication::dispatch($serviceApplication); + + return response()->json([ + 'message' => 'Service application restart request queued.', + ], 200); + } + + #[OA\Get( + summary: 'Stop service application container', + description: 'Stops a single compose service container (docker stop).', + path: '/services/{uuid}/applications/{app_uuid}/stop', + operationId: 'stop-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Stop queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + 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(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + StopServiceApplication::dispatch($serviceApplication); + + return response()->json([ + 'message' => 'Service application stop request queued.', + ], 200); + } +} diff --git a/app/Policies/ServiceApplicationPolicy.php b/app/Policies/ServiceApplicationPolicy.php index af380a90f..619b885ff 100644 --- a/app/Policies/ServiceApplicationPolicy.php +++ b/app/Policies/ServiceApplicationPolicy.php @@ -30,8 +30,15 @@ public function create(User $user): bool */ public function update(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('update', $serviceApplication->service); - return true; + return Gate::allows('update', $serviceApplication->service); + } + + /** + * Determine whether the user can deploy or run lifecycle actions on the parent service stack. + */ + public function deploy(User $user, ServiceApplication $serviceApplication): bool + { + return Gate::allows('deploy', $serviceApplication->service); } /** diff --git a/app/Support/ServiceComposeUrl.php b/app/Support/ServiceComposeUrl.php new file mode 100644 index 000000000..cdeb75e58 --- /dev/null +++ b/app/Support/ServiceComposeUrl.php @@ -0,0 +1,55 @@ +, normalized: ?string} + */ + public static function validateUrlString(?string $urlValue, bool $forceDomainOverride = false): array + { + $errors = []; + + if ($urlValue === null || $urlValue === '') { + return ['errors' => [], 'normalized' => null]; + } + + $urls = str($urlValue) + ->replaceStart(',', '') + ->replaceEnd(',', '') + ->trim() + ->explode(',') + ->map(fn ($url) => trim((string) $url)) + ->filter(); + + foreach ($urls as $url) { + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = "Invalid URL: {$url}"; + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'], true)) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; + } + } + + $duplicates = $urls->duplicates()->unique()->values(); + if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { + $errors[] = 'The current request contains duplicate URLs: '.implode(', ', $duplicates->toArray()).'. Use force_domain_override=true to proceed.'; + } + + if (count($errors) > 0) { + return ['errors' => $errors, 'normalized' => null]; + } + + $normalized = $urls + ->map(fn ($u) => str($u)->lower()->value()) + ->unique() + ->filter(fn ($u) => filled($u)) + ->implode(','); + + return ['errors' => [], 'normalized' => $normalized !== '' ? $normalized : null]; + } +} diff --git a/routes/api.php b/routes/api.php index 0d3edcced..57ec3934a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,6 +12,7 @@ use App\Http\Controllers\Api\ScheduledTasksController; use App\Http\Controllers\Api\SecurityController; use App\Http\Controllers\Api\ServersController; +use App\Http\Controllers\Api\ServiceApplicationsController; use App\Http\Controllers\Api\ServicesController; use App\Http\Controllers\Api\TeamController; use App\Http\Middleware\ApiAllowed; @@ -193,6 +194,14 @@ Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); + Route::get('/services/{uuid}/applications', [ServiceApplicationsController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'update'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/logs', [ServiceApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/start', [ServiceApplicationsController::class, 'action_start'])->middleware(['api.ability:deploy']); + 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('/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/ServiceApplicationsApiTest.php b/tests/Feature/ServiceApplicationsApiTest.php new file mode 100644 index 000000000..d4f12350e --- /dev/null +++ b/tests/Feature/ServiceApplicationsApiTest.php @@ -0,0 +1,258 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $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 createServiceWithApplicationForApiTest(object $ctx): object +{ + $service = Service::factory()->create([ + 'environment_id' => $ctx->environment->id, + 'server_id' => $ctx->server->id, + 'destination_id' => $ctx->destination->id, + 'destination_type' => $ctx->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + + $sa = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'web', + 'service_id' => $service->id, + 'image' => 'nginx:alpine', + ]); + + return (object) ['service' => $service, 'serviceApplication' => $sa]; +} + +function createServiceWithoutApplicationsForApiTest(object $ctx): Service +{ + return Service::factory()->create([ + 'environment_id' => $ctx->environment->id, + 'server_id' => $ctx->server->id, + 'destination_id' => $ctx->destination->id, + 'destination_type' => $ctx->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); +} + +describe('GET /api/v1/services/{uuid}/applications', function () { + test('returns empty array when service has no applications', function () { + $service = createServiceWithoutApplicationsForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$service->uuid}/applications"); + + $response->assertStatus(200); + $response->assertJson([]); + }); + + test('lists service applications for the service', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications"); + + $response->assertStatus(200); + $response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid]); + }); + + test('returns 404 when service does not exist', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000001/applications'); + + $response->assertStatus(404); + $response->assertJsonFragment(['message' => 'Service not found.']); + }); +}); + +describe('GET /api/v1/services/{uuid}/applications/{app_uuid}', function () { + test('returns 404 for unknown service', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000002/applications/non-existent-uuid-12345'); + + $response->assertStatus(404); + $response->assertJsonFragment(['message' => 'Service not found.']); + }); + + test('returns 404 when application uuid is not under service', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/00000000-0000-0000-0000-000000000003"); + + $response->assertStatus(404); + $response->assertJsonFragment(['message' => 'Service application not found.']); + }); + + test('returns service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}"); + + $response->assertStatus(200); + $response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid, 'name' => 'web']); + }); +}); + +describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () { + test('returns 400 without valid token', function () { + $response = $this->patchJson('/api/v1/services/some-uuid/applications/some-app', [ + 'human_name' => 'x', + ], ['Accept' => 'application/json', 'Content-Type' => 'application/json']); + + $response->assertStatus(400); + }); + + test('updates human_name', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'human_name' => 'Web UI', + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['human_name' => 'Web UI']); + $ctx->serviceApplication->refresh(); + expect($ctx->serviceApplication->human_name)->toBe('Web UI'); + }); + + test('returns 422 for invalid url scheme', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'url' => 'ftp://example.com', + ]); + + $response->assertStatus(422); + }); + + test('returns 422 when enabling log drain but server has no log drain', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'is_log_drain_enabled' => true, + ]); + + $response->assertStatus(422); + expect((string) $response->json('errors.is_log_drain_enabled.0'))->toContain('Log drain'); + }); +}); + +describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/restart', function () { + test('returns 400 without valid token', function () { + $response = $this->postJson('/api/v1/services/some-uuid/applications/some-app/restart'); + + $response->assertStatus(400); + }); + + test('queues restart for a service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/restart"); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Service application restart request queued.']); + RestartServiceApplication::assertPushed(); + }); +}); + +describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/start', function () { + test('queues deploy for a service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start?latest=1&force=1"); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Service application deploy request queued.']); + DeployServiceApplication::assertPushed(); + }); +}); + +describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/stop', function () { + test('queues stop for a service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/stop"); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Service application stop request queued.']); + StopServiceApplication::assertPushed(); + }); +}); + +describe('GET /api/v1/services/{uuid}/applications/{app_uuid}/logs', function () { + test('returns 400 when server is not functional', function () { + $ctx = createServiceWithApplicationForApiTest($this); + $this->server->settings->update([ + 'is_reachable' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/logs"); + + $response->assertStatus(400); + $response->assertJsonFragment(['message' => 'Server is not functional.']); + }); +}); From 23d5b854e980cb20d3d38f8aa20e5ea110f316fa Mon Sep 17 00:00:00 2001 From: Michael Jathe Date: Sun, 29 Mar 2026 16:02:05 +0200 Subject: [PATCH 036/211] feat(api): add tag management endpoints for applications, databases, and services Add CRUD tag endpoints (GET/POST/DELETE) as sub-resources for applications, databases, and services. Add team-level GET /tags endpoint. Extend all resource creation endpoints to accept an optional tags array. Uses a shared HandlesTagsApi trait to avoid duplication across controllers. Tags are race-safe via syncWithoutDetaching(), garbage-collected when orphaned, and sanitized (strip_tags + lowercase). --- .../Api/ApplicationsController.php | 186 +++++++- .../Api/Concerns/HandlesTagsApi.php | 142 ++++++ .../Controllers/Api/DatabasesController.php | 206 ++++++++- .../Controllers/Api/ServicesController.php | 171 +++++++- app/Http/Controllers/Api/TagsController.php | 61 +++ app/Models/Tag.php | 11 + bootstrap/helpers/api.php | 1 + routes/api.php | 15 + tests/Feature/TagApiTest.php | 410 ++++++++++++++++++ 9 files changed, 1191 insertions(+), 12 deletions(-) create mode 100644 app/Http/Controllers/Api/Concerns/HandlesTagsApi.php create mode 100644 app/Http/Controllers/Api/TagsController.php create mode 100644 tests/Feature/TagApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ad1f50ea2..d6e0de340 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -33,6 +33,18 @@ class ApplicationsController extends Controller { + use Concerns\HandlesTagsApi; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Application not found.'; + } + private function removeSensitiveData($application) { $application->makeHidden([ @@ -230,6 +242,7 @@ public function applications(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], '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.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -395,6 +408,7 @@ public function create_public_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], '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.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -560,6 +574,7 @@ public function create_private_gh_app_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], '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.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -697,6 +712,7 @@ public function create_private_deploy_key_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], '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.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -831,6 +847,7 @@ public function create_dockerfile_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], '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.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -1006,7 +1023,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', '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']; + $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', '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']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1020,6 +1037,8 @@ private function create_application(Request $request, $type) 'http_basic_auth_username' => 'string|nullable', 'http_basic_auth_password' => 'string|nullable', 'autogenerate_domain' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -1277,6 +1296,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($request->has('tags')) { + $this->attachTagsToResource($application, $request->tags, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1503,6 +1525,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($request->has('tags')) { + $this->attachTagsToResource($application, $request->tags, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1699,6 +1724,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($request->has('tags')) { + $this->attachTagsToResource($application, $request->tags, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1810,6 +1838,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($request->has('tags')) { + $this->attachTagsToResource($application, $request->tags, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1920,6 +1951,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($request->has('tags')) { + $this->attachTagsToResource($application, $request->tags, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1943,7 +1977,7 @@ private function create_application(Request $request, $type) 'domains' => data_get($application, 'fqdn'), ]))->setStatusCode(201); } elseif ($type === 'dockercompose') { - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -2017,6 +2051,10 @@ private function create_application(Request $request, $type) // Apply service-specific application prerequisites applyServiceApplicationPrerequisites($service); + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -4454,4 +4492,148 @@ public function delete_storage(Request $request): JsonResponse return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for an application by UUID.', + path: '/applications/{uuid}/tags', + operationId: 'list-tags-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to an application by UUID.', + path: '/applications/{uuid}/tags', + operationId: 'create-tag-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from an application by UUID.', + path: '/applications/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php new file mode 100644 index 000000000..b6d6d259a --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php @@ -0,0 +1,142 @@ +findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('view', $resource); + + return response()->json($resource->tags->map(TagsController::serializeTag(...))); + } + + public function createTag(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $resource = $this->findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('update', $resource); + + if ($request->has('tag_name') && $request->has('tag_names')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']], + ], 422); + } + + $validator = Validator::make($request->all(), [ + 'tag_name' => 'required_without:tag_names|string|min:2', + 'tag_names' => 'required_without:tag_name|array|min:1', + 'tag_names.*' => 'string|min:2', + ]); + + $extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $tagNames = $request->has('tag_names') ? $request->tag_names : [$request->tag_name]; + + $this->attachTagsToResource($resource, $tagNames, $teamId); + + return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201); + } + + public function deleteTag(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $resource = $this->findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('update', $resource); + + $tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first(); + if (! $tag) { + return response()->json(['message' => 'Tag not found.'], 404); + } + + $resource->tags()->detach($tag->id); + + if (DB::table('taggables')->where('tag_id', $tag->id)->count() === 0) { + $tag->delete(); + } + + return response()->json(['message' => 'Tag removed.']); + } + + protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void + { + foreach ($tagNames as $tagName) { + $tagName = strtolower(strip_tags($tagName)); + if (strlen($tagName) < 2) { + continue; + } + + $tag = Tag::where('team_id', $teamId)->where('name', $tagName)->first(); + if (! $tag) { + $tag = Tag::create([ + 'name' => $tagName, + 'team_id' => $teamId, + ]); + } + + $resource->tags()->syncWithoutDetaching([$tag->id]); + } + } +} diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 33d875758..c96bffa9b 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -27,6 +27,18 @@ class DatabasesController extends Controller { + use Concerns\HandlesTagsApi; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return queryDatabaseByUuidWithinTeam($uuid, $teamId); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Database not found.'; + } + private function removeSensitiveData($database) { $database->makeHidden([ @@ -1079,6 +1091,7 @@ public function update_backup(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1147,6 +1160,7 @@ public function create_database_postgresql(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1214,6 +1228,7 @@ public function create_database_clickhouse(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1282,6 +1297,7 @@ public function create_database_dragonfly(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1350,6 +1366,7 @@ public function create_database_redis(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1421,6 +1438,7 @@ public function create_database_keydb(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1492,6 +1510,7 @@ public function create_database_mariadb(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1560,6 +1579,7 @@ public function create_database_mysql(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1689,6 +1709,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', 'instant_deploy' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]); if ($validator->failed()) { return response()->json([ @@ -1707,7 +1729,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } } if ($type === NewDatabaseTypes::POSTGRESQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'postgres_user' => 'string', 'postgres_password' => 'string', @@ -1752,6 +1774,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('postgres_conf', $postgresConf); } $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1766,7 +1791,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MARIADB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'tags']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => 'string', 'clickhouse_admin_password' => 'string', @@ -1807,6 +1832,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('mariadb_conf', $mariadbConf); } $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1822,7 +1850,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MYSQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'mysql_root_password' => 'string', 'mysql_password' => 'string', @@ -1866,6 +1894,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('mysql_conf', $mysqlConf); } $database = create_standalone_mysql($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1881,7 +1912,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::REDIS) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'redis_password' => 'string', 'redis_conf' => 'string', @@ -1922,6 +1953,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('redis_conf', $redisConf); } $database = create_standalone_redis($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1937,7 +1971,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::DRAGONFLY) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password', 'tags']; $validator = customApiValidator($request->all(), [ 'dragonfly_password' => 'string', ]); @@ -1959,6 +1993,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) removeUnnecessaryFieldsFromRequest($request); $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1967,7 +2004,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'uuid' => $database->uuid, ]))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::KEYDB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'keydb_password' => 'string', 'keydb_conf' => 'string', @@ -2008,6 +2045,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('keydb_conf', $keydbConf); } $database = create_standalone_keydb($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2023,7 +2063,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password', 'tags']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => 'string', 'clickhouse_admin_password' => 'string', @@ -2044,6 +2084,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) } removeUnnecessaryFieldsFromRequest($request); $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2059,7 +2102,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MONGODB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'tags']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', 'mongo_initdb_root_username' => 'string', @@ -2102,6 +2145,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('mongo_conf', $mongoConf); } $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -3856,4 +3902,148 @@ public function delete_storage(Request $request): JsonResponse return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for a database by UUID.', + path: '/databases/{uuid}/tags', + operationId: 'list-tags-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to a database by UUID.', + path: '/databases/{uuid}/tags', + operationId: 'create-tag-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from a database by UUID.', + path: '/databases/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index fbf4b9e56..7b60a6d0c 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -22,6 +22,18 @@ class ServicesController extends Controller { + use Concerns\HandlesTagsApi; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first(); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Service not found.'; + } + private function removeSensitiveData($service) { $service->makeHidden([ @@ -227,6 +239,7 @@ public function services(Request $request) ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'], ], ), ), @@ -293,7 +306,7 @@ public function services(Request $request) )] public function create_service(Request $request) { - $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -323,6 +336,8 @@ public function create_service(Request $request) 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -482,6 +497,10 @@ public function create_service(Request $request) } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -494,7 +513,7 @@ public function create_service(Request $request) return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); } elseif (filled($request->docker_compose_raw)) { - $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; $validationRules = [ 'project_uuid' => 'string|required', @@ -646,6 +665,10 @@ public function create_service(Request $request) } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -2458,4 +2481,148 @@ public function delete_storage(Request $request): JsonResponse return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for a service by UUID.', + path: '/services/{uuid}/tags', + operationId: 'list-tags-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to a service by UUID.', + path: '/services/{uuid}/tags', + operationId: 'create-tag-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from a service by UUID.', + path: '/services/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/TagsController.php b/app/Http/Controllers/Api/TagsController.php new file mode 100644 index 000000000..173a8ab7b --- /dev/null +++ b/app/Http/Controllers/Api/TagsController.php @@ -0,0 +1,61 @@ + $tag->uuid, + 'name' => $tag->name, + 'created_at' => $tag->created_at, + 'updated_at' => $tag->updated_at, + ]; + } + + #[OA\Get( + summary: 'List', + description: 'List all tags for the current team.', + path: '/tags', + operationId: 'list-tags', + security: [ + ['bearerAuth' => []], + ], + tags: ['Tags'], + responses: [ + new OA\Response( + response: 200, + description: 'All tags for the current team.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + ] + )] + public function tags(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $tags = Tag::where('team_id', $teamId)->orderBy('name')->get(); + + return response()->json($tags->map(self::serializeTag(...))); + } +} diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 3594d1072..221ef15bb 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -3,7 +3,18 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Tag model', + type: 'object', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'created_at', type: 'string'), + new OA\Property(property: 'updated_at', type: 'string'), + ] +)] class Tag extends BaseModel { use HasSafeStringAttribute; diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 3241276e1..e3a611ceb 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -194,4 +194,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('autogenerate_domain'); $request->offsetUnset('is_container_label_escape_enabled'); $request->offsetUnset('docker_compose_raw'); + $request->offsetUnset('tags'); } diff --git a/routes/api.php b/routes/api.php index 0d3edcced..716bcf286 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Api\SecurityController; use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServicesController; +use App\Http\Controllers\Api\TagsController; use App\Http\Controllers\Api\TeamController; use App\Http\Middleware\ApiAllowed; use App\Jobs\PushServerUpdateJob; @@ -98,6 +99,8 @@ Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); + Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); + Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']); Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']); @@ -125,6 +128,10 @@ Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::get('/applications/{uuid}/tags', [ApplicationsController::class, 'tags'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); @@ -167,6 +174,10 @@ Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/tags', [DatabasesController::class, 'tags'])->middleware(['api.ability:read']); + Route::post('/databases/{uuid}/tags', [DatabasesController::class, 'create_tag'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); @@ -189,6 +200,10 @@ Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/tags', [ServicesController::class, 'tags'])->middleware(['api.ability:read']); + Route::post('/services/{uuid}/tags', [ServicesController::class, 'create_tag'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); diff --git a/tests/Feature/TagApiTest.php b/tests/Feature/TagApiTest.php new file mode 100644 index 000000000..dd62c1062 --- /dev/null +++ b/tests/Feature/TagApiTest.php @@ -0,0 +1,410 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +function tagApiAuthHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('GET /api/v1/tags', function () { + test('returns all tags for current team', function () { + Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + Tag::create(['name' => 'staging', 'team_id' => $this->team->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson('/api/v1/tags'); + + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonFragment(['name' => 'production']); + $response->assertJsonFragment(['name' => 'staging']); + }); + + test('returns empty array when no tags exist', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson('/api/v1/tags'); + + $response->assertStatus(200); + $response->assertJsonCount(0); + }); + + test('does not return tags from other teams', function () { + $otherTeam = Team::factory()->create(); + Tag::create(['name' => 'other-team-tag', 'team_id' => $otherTeam->id]); + Tag::create(['name' => 'my-tag', 'team_id' => $this->team->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson('/api/v1/tags'); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'my-tag']); + $response->assertJsonMissing(['name' => 'other-team-tag']); + }); +}); + +describe('GET /api/v1/applications/{uuid}/tags', function () { + test('returns tags for an application', function () { + $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + $this->application->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$this->application->uuid}/tags"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'production']); + }); + + test('returns 404 for non-existent application', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson('/api/v1/applications/non-existent-uuid/tags'); + + $response->assertStatus(404); + }); + + test('returns empty array when application has no tags', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$this->application->uuid}/tags"); + + $response->assertStatus(200); + $response->assertJsonCount(0); + }); +}); + +describe('POST /api/v1/applications/{uuid}/tags', function () { + test('adds a single tag via tag_name', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'production', + ]); + + $response->assertStatus(201); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'production']); + + expect($this->application->tags()->count())->toBe(1); + }); + + test('adds multiple tags via tag_names array', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_names' => ['production', 'frontend'], + ]); + + $response->assertStatus(201); + $response->assertJsonCount(2); + + expect($this->application->tags()->count())->toBe(2); + }); + + test('reuses existing team tag instead of creating duplicate', function () { + Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'production', + ]); + + $response->assertStatus(201); + expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1); + }); + + test('rejects tag_name shorter than 2 characters', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'x', + ]); + + $response->assertStatus(422); + }); + + test('rejects both tag_name and tag_names provided simultaneously', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'production', + 'tag_names' => ['staging'], + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]); + }); + + test('skips duplicate tag already on resource', function () { + $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + $this->application->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'production', + ]); + + $response->assertStatus(201); + expect($this->application->tags()->count())->toBe(1); + }); + + test('returns 404 for non-existent application', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/non-existent-uuid/tags', [ + 'tag_name' => 'production', + ]); + + $response->assertStatus(404); + }); +}); + +describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () { + test('removes tag from application', function () { + $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + $this->application->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}"); + + $response->assertStatus(200); + expect($this->application->tags()->count())->toBe(0); + }); + + test('garbage-collects orphaned tag', function () { + $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + $this->application->tags()->attach($tag->id); + + $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}"); + + expect(Tag::find($tag->id))->toBeNull(); + }); + + test('keeps tag if still used by other resources', function () { + $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + $this->application->tags()->attach($tag->id); + + $otherApp = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $otherApp->tags()->attach($tag->id); + + $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}"); + + expect(Tag::find($tag->id))->not->toBeNull(); + }); + + test('returns 404 for non-existent tag', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid"); + + $response->assertStatus(404); + }); +}); + +describe('GET /api/v1/databases/{uuid}/tags', function () { + test('returns tags for a database', function () { + $database = StandalonePostgresql::create([ + 'name' => 'test-pg', + 'postgres_password' => 'testpassword', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]); + $database->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/databases/{$database->uuid}/tags"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'database-tag']); + }); +}); + +describe('POST /api/v1/databases/{uuid}/tags', function () { + test('adds tag to database', function () { + $database = StandalonePostgresql::create([ + 'name' => 'test-pg', + 'postgres_password' => 'testpassword', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/databases/{$database->uuid}/tags", [ + 'tag_name' => 'database-tag', + ]); + + $response->assertStatus(201); + expect($database->tags()->count())->toBe(1); + }); +}); + +describe('DELETE /api/v1/databases/{uuid}/tags/{tag_uuid}', function () { + test('removes tag from database', function () { + $database = StandalonePostgresql::create([ + 'name' => 'test-pg', + 'postgres_password' => 'testpassword', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]); + $database->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}"); + + $response->assertStatus(200); + expect($database->tags()->count())->toBe(0); + }); +}); + +describe('GET /api/v1/services/{uuid}/tags', function () { + test('returns tags for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]); + $service->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/services/{$service->uuid}/tags"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'service-tag']); + }); +}); + +describe('POST /api/v1/services/{uuid}/tags', function () { + test('adds tag to service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/services/{$service->uuid}/tags", [ + 'tag_name' => 'service-tag', + ]); + + $response->assertStatus(201); + expect($service->tags()->count())->toBe(1); + }); +}); + +describe('DELETE /api/v1/services/{uuid}/tags/{tag_uuid}', function () { + test('removes tag from service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]); + $service->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}"); + + $response->assertStatus(200); + expect($service->tags()->count())->toBe(0); + }); +}); + +describe('Tag name sanitization', function () { + test('strips HTML tags from tag names', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'production', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'alert("xss")production']); + $response->assertJsonMissing(['name' => 'production', ]); - $response->assertStatus(201); + $response->assertCreated(); $response->assertJsonFragment(['name' => 'alert("xss")production']); $response->assertJsonMissing(['name' => ' + @endscript diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index d03d10dfb..f8f231e4c 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -212,6 +212,11 @@ class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warn Validating... @endif + @php + $hasLinkableCloudProviders = (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty()) + || (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty()) + || (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()); + @endphp @if ($server->id === 0) Save + @if ($hasLinkableCloudProviders) +
+ + Link Cloud Provider + + + + +
+
+
+ @if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty()) + + + + +
+

+ Link this server to a Hetzner Cloud instance to enable power controls and status monitoring. +

+
+ + + @foreach ($availableHetznerTokens as $token) + + @endforeach + +
+
+
+
+ +
+ + Search + Searching... + +
+
+
+ OR +
+
+ + Search by IP + Searching... + +
+ @if ($hetznerSearchError) +
+

{{ $hetznerSearchError }}

+
+ @endif + @if ($hetznerNoMatchFound) +
+

+ @if ($manualHetznerServerId) + No Hetzner server found with ID: {{ $manualHetznerServerId }} + @else + No Hetzner server found matching IP: {{ $server->ip }} + @endif +

+

+ Try a different token, enter the Server ID manually, or verify the details are correct. +

+
+ @endif + @if ($matchedHetznerServer) +
+

Match Found!

+
+
Name: {{ $matchedHetznerServer['name'] }}
+
ID: {{ $matchedHetznerServer['id'] }}
+
Status: {{ ucfirst($matchedHetznerServer['status']) }}
+
Type: {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}
+
+ + Link This Server + +
+ @endif +
+
+ @endif + @if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()) + + + + +
+

+ Link this server to a DigitalOcean droplet to enable power controls and status monitoring. +

+
+ + + @foreach ($availableDigitalOceanTokens as $token) + + @endforeach + +
+
+
+
+ +
+ + Search + Searching... + +
+
+
+ OR +
+
+ + Search by IP + Searching... + +
+ @if ($digitalOceanSearchError) +
+

{{ $digitalOceanSearchError }}

+
+ @endif + @if ($digitalOceanNoMatchFound) +
+

+ @if ($manualDigitalOceanDropletId) + No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }} + @else + No DigitalOcean droplet found matching IP: {{ $server->ip }} + @endif +

+

+ Try a different token, enter the Droplet ID manually, or verify the details are correct. +

+
+ @endif + @if ($matchedDigitalOceanDroplet) +
+

Match Found!

+
+
Name: {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}
+
ID: {{ $matchedDigitalOceanDroplet['id'] }}
+
Status: {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}
+
Size: {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}
+
+ + Link This Server + +
+ @endif +
+
+ @endif + @if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty()) + + + + +
+

+ Link this server to a Vultr instance to enable power controls and status monitoring. +

+
+ + + @foreach ($availableVultrTokens as $token) + + @endforeach + +
+
+
+
+ +
+ + Search + Searching... + +
+
+
+ OR +
+
+ + Search by IP + Searching... + +
+ @if ($vultrSearchError) +
+

{{ $vultrSearchError }}

+
+ @endif + @if ($vultrNoMatchFound) +
+

+ @if ($manualVultrInstanceId) + No Vultr instance found with ID: {{ $manualVultrInstanceId }} + @else + No Vultr instance found matching IP: {{ $server->ip }} + @endif +

+

+ Try a different token, enter the Instance ID manually, or verify the details are correct. +

+
+ @endif + @if ($matchedVultrInstance) +
+

Match Found!

+
+
Name: {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}
+
ID: {{ $matchedVultrInstance['id'] }}
+
Status: {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}
+
Plan: {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}
+
+ + Link This Server + +
+ @endif +
+
+ @endif +
+
+
+
+ @endif @if ($server->isFunctional()) Validate & configure @@ -482,228 +777,6 @@ class="dark:hover:fill-white fill-black dark:fill-warning"> @endif @endif - @if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty()) -
-

Link to Hetzner Cloud

-

- Link this server to a Hetzner Cloud instance to enable power controls and status monitoring. -

- -
-
- - - @foreach ($availableHetznerTokens as $token) - - @endforeach - -
-
- -
- - Search by ID - Searching... - -
OR
- - Search by IP - Searching... - -
- - @if ($hetznerSearchError) -
-

{{ $hetznerSearchError }}

-
- @endif - - @if ($hetznerNoMatchFound) -
-

- @if ($manualHetznerServerId) - No Hetzner server found with ID: {{ $manualHetznerServerId }} - @else - No Hetzner server found matching IP: {{ $server->ip }} - @endif -

-

- Try a different token, enter the Server ID manually, or verify the details are correct. -

-
- @endif - - @if ($matchedHetznerServer) -
-

Match Found!

-
-
Name: {{ $matchedHetznerServer['name'] }}
-
ID: {{ $matchedHetznerServer['id'] }}
-
Status: {{ ucfirst($matchedHetznerServer['status']) }}
-
Type: {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}
-
- - Link This Server - -
- @endif -
- @endif - @if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty()) -
-

Link to Vultr

-

- Link this server to a Vultr instance to enable power controls and status monitoring. -

- -
-
- - - @foreach ($availableVultrTokens as $token) - - @endforeach - -
-
- -
- - Search by ID - Searching... - -
OR
- - Search by IP - Searching... - -
- - @if ($vultrSearchError) -
-

{{ $vultrSearchError }}

-
- @endif - - @if ($vultrNoMatchFound) -
-

- @if ($manualVultrInstanceId) - No Vultr instance found with ID: {{ $manualVultrInstanceId }} - @else - No Vultr instance found matching IP: {{ $server->ip }} - @endif -

-

- Try a different token, enter the Instance ID manually, or verify the details are correct. -

-
- @endif - - @if ($matchedVultrInstance) -
-

Match Found!

-
-
Name: {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}
-
ID: {{ $matchedVultrInstance['id'] }}
-
Status: {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}
-
Plan: {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}
-
- - Link This Server - -
- @endif -
- @endif - @if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()) -
-

Link to DigitalOcean

-

- Link this server to a DigitalOcean droplet to enable power controls and status monitoring. -

- -
-
- - - @foreach ($availableDigitalOceanTokens as $token) - - @endforeach - -
-
- -
- - Search by ID - Searching... - -
OR
- - Search by IP - Searching... - -
- - @if ($digitalOceanSearchError) -
-

{{ $digitalOceanSearchError }}

-
- @endif - - @if ($digitalOceanNoMatchFound) -
-

- @if ($manualDigitalOceanDropletId) - No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }} - @else - No DigitalOcean droplet found matching IP: {{ $server->ip }} - @endif -

-
- @endif - - @if ($matchedDigitalOceanDroplet) -
-

Match Found!

-
-
Name: {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}
-
ID: {{ $matchedDigitalOceanDroplet['id'] }}
-
Status: {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}
-
Size: {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}
-
- - Link This Server - -
- @endif -
- @endif diff --git a/tests/Feature/CloudProviderLinkDropdownTest.php b/tests/Feature/CloudProviderLinkDropdownTest.php new file mode 100644 index 000000000..b05f08173 --- /dev/null +++ b/tests/Feature/CloudProviderLinkDropdownTest.php @@ -0,0 +1,192 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 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->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'ip' => '1.2.3.4', + ]); +}); + +it('shows link cloud provider dropdown with available unlinked providers', function () { + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'test-hetzner-token', + 'name' => 'Test Hetzner Token', + ]); + + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + 'token' => 'test-vultr-token', + 'name' => 'Test Vultr Token', + ]); + + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'digitalocean', + 'token' => 'test-digitalocean-token', + 'name' => 'Test DigitalOcean Token', + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Link Cloud Provider') + ->assertSee('Hetzner') + ->assertSee('DigitalOcean') + ->assertSee('Vultr') + ->assertSee('Hetzner Token') + ->assertSee('DigitalOcean Token') + ->assertSee('Vultr Token') + ->assertSee('Server ID') + ->assertSee('Droplet ID') + ->assertSee('Instance ID') + ->assertSee('Search by IP') + ->assertSee('Search'); +}); + +it('hides link cloud provider dropdown when no providers can be linked', function () { + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertDontSee('Link Cloud Provider'); +}); + +it('does not list providers already linked to the server', function () { + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'test-hetzner-token', + 'name' => 'Test Hetzner Token', + ]); + + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + 'token' => 'test-vultr-token', + 'name' => 'Test Vultr Token', + ]); + + $this->server->update(['hetzner_server_id' => 123]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Link Cloud Provider') + ->assertSee('Vultr Token') + ->assertDontSee('Hetzner Token'); +}); + +it('shows Hetzner search by IP errors in the modal', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'invalid-hetzner-token', + 'name' => 'Invalid Hetzner Token', + ]); + + Http::fake([ + 'https://api.hetzner.cloud/v1/servers*' => Http::response([ + 'error' => ['message' => 'invalid token'], + ], 401), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedHetznerTokenId', $token->id) + ->call('searchHetznerServer') + ->assertSet('hetznerSearchError', fn (string $error) => str_contains($error, 'Failed to search Hetzner servers:') && str_contains($error, 'invalid token')) + ->assertSee('Failed to search Hetzner servers:') + ->assertSee('invalid token'); +}); + +it('shows Hetzner search by ID errors in the modal', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'invalid-hetzner-token', + 'name' => 'Invalid Hetzner Token', + ]); + + Http::fake([ + 'https://api.hetzner.cloud/v1/servers/12345678' => Http::response([ + 'error' => ['message' => 'invalid token'], + ], 401), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedHetznerTokenId', $token->id) + ->set('manualHetznerServerId', '12345678') + ->call('searchHetznerServerById') + ->assertSet('hetznerSearchError', fn (string $error) => str_contains($error, 'Failed to fetch Hetzner server:') && str_contains($error, 'invalid token')) + ->assertSee('Failed to fetch Hetzner server:') + ->assertSee('invalid token'); +}); + +it('shows DigitalOcean search errors in the modal', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'digitalocean', + 'token' => 'invalid-digitalocean-token', + 'name' => 'Invalid DigitalOcean Token', + ]); + + Http::fake([ + 'https://api.digitalocean.com/v2/droplets*' => Http::response([ + 'message' => 'invalid token', + ], 401), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedDigitalOceanTokenId', $token->id) + ->call('searchDigitalOceanDroplet') + ->assertSet('digitalOceanSearchError', fn (string $error) => str_contains($error, 'Failed to search DigitalOcean droplets:') && str_contains($error, 'invalid token')) + ->assertSee('Failed to search DigitalOcean droplets:') + ->assertSee('invalid token'); +}); + +it('shows Vultr search errors in the modal', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + 'token' => 'invalid-vultr-token', + 'name' => 'Invalid Vultr Token', + ]); + + Http::fake([ + 'https://api.vultr.com/v2/instances*' => Http::response([ + 'error' => 'invalid token', + ], 401), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedVultrTokenId', $token->id) + ->call('searchVultrInstance') + ->assertSet('vultrSearchError', fn (string $error) => str_contains($error, 'Failed to search Vultr instances:') && str_contains($error, 'invalid token')) + ->assertSee('Failed to search Vultr instances:') + ->assertSee('invalid token'); +}); diff --git a/tests/Feature/Security/PrivateKeyDropdownTest.php b/tests/Feature/Security/PrivateKeyDropdownTest.php index 6152e0c2b..5dfeb31ab 100644 --- a/tests/Feature/Security/PrivateKeyDropdownTest.php +++ b/tests/Feature/Security/PrivateKeyDropdownTest.php @@ -2,8 +2,10 @@ use App\Livewire\Security\PrivateKey\Create; use App\Livewire\Security\PrivateKey\Index; +use App\Livewire\Security\PrivateKey\Show; 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,3 +97,38 @@ ->and($view)->toContain('') ->and($badgePosition)->toBeLessThan($saveButtonPosition); }); + +test('used private key details disable delete with an explanation', function () { + $privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + + $this->get(route('security.private-key.show', [ + 'private_key_uuid' => $privateKey->uuid, + ])) + ->assertSuccessful() + ->assertSee('This private key is currently used by a server, application, or Git app and cannot be deleted.', false) + ->assertSee('disabled', false); +}); + +test('used private key delete action keeps the key and shows an error', function () { + $privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + + Livewire::test(Show::class, ['private_key_uuid' => $privateKey->uuid]) + ->call('delete') + ->assertDispatched('error'); + + expect($privateKey->fresh())->not->toBeNull(); +}); diff --git a/tests/Feature/ServerPrivateKeyDropdownTest.php b/tests/Feature/ServerPrivateKeyDropdownTest.php new file mode 100644 index 000000000..a0ebcd044 --- /dev/null +++ b/tests/Feature/ServerPrivateKeyDropdownTest.php @@ -0,0 +1,100 @@ +whereKey(0)->exists()) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + } + + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + $this->actingAs($this->user); + + Config::set('cache.default', 'array'); + Storage::fake('ssh-keys'); + + $this->currentPrivateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->currentPrivateKey->id, + ]); +}); + +test('server private key page shows highlighted add dropdown actions', function () { + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('+ Add') + ->assertSee('Generate ED25519') + ->assertSee('Generate RSA') + ->assertSee('Add manually') + ->assertSee('Check connection'); +}); + +test('generating a server private key stores it and refreshes the current view', function () { + $component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('generatePrivateKey', 'ed25519') + ->assertNoRedirect() + ->assertDispatched('success'); + + $privateKey = PrivateKey::query() + ->where('id', '!=', $this->currentPrivateKey->id) + ->firstOrFail(); + + expect($privateKey->team_id)->toBe($this->team->id) + ->and($privateKey->public_key)->toStartWith('ssh-ed25519'); + + $component + ->assertDispatched('copyPublicKeyToClipboard', publicKey: $privateKey->public_key) + ->assertSee($privateKey->name); +}); + +test('server private key page copies generated public keys and shows a copied hint', function () { + $view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php')); + + expect($view)->toContain('copyPublicKeyToClipboard') + ->and($view)->toContain('navigator.clipboard.writeText') + ->and($view)->toContain('Public key copied to clipboard.'); +}); + +test('server private key cards include a copy public key button', function () { + $keyData = PrivateKey::generateNewKeyPair('rsa'); + + PrivateKey::createAndStore([ + 'team_id' => $this->team->id, + 'name' => 'Alternative SSH Key', + 'description' => 'Created by test', + 'private_key' => $keyData['private_key'], + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Copy public key') + ->assertSee('Alternative SSH Key'); + + $view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php')); + + expect($view)->toContain('Copy public key') + ->and($view)->toContain('$private_key->public_key') + ->and($view)->toContain('Public key copied to clipboard.'); +}); From 3f960d94c350220c7928c7572772f421b757b0d0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:41:37 +0200 Subject: [PATCH 188/211] fix(github): skip opened PR previews with skip ci --- app/Jobs/ProcessGithubPullRequestWebhook.php | 2 +- .../ProcessGithubPullRequestWebhookTest.php | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 666888a57..5186a58bb 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -101,7 +101,7 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp $repo = $repository_parts[1] ?? ''; $headCommitMessage = null; - if ($this->action === 'synchronize') { + if ($this->action === 'opened' || $this->action === 'synchronize' || $this->action === 'reopened') { $headCommitMessage = getGithubCommitMessage($githubApp, $owner, $repo, $this->commitSha); } diff --git a/tests/Feature/ProcessGithubPullRequestWebhookTest.php b/tests/Feature/ProcessGithubPullRequestWebhookTest.php index 557e42ce8..bd217396c 100644 --- a/tests/Feature/ProcessGithubPullRequestWebhookTest.php +++ b/tests/Feature/ProcessGithubPullRequestWebhookTest.php @@ -108,3 +108,50 @@ protected function dispatchPullRequestClosedUpdate(Application $application, App Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/example/repo/commits/after-sha'); }); + +it('skips a GitHub pull request preview when the opened or reopened head commit message contains skip ci', function (string $action) { + Queue::fake(); + + $this->application->settings->update([ + 'is_preview_deployments_enabled' => true, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Public GitHub', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => true, + 'team_id' => $this->team->id, + ]); + + Http::fake([ + 'https://api.github.com/repos/example/repo/commits/head-sha' => Http::response([ + 'commit' => [ + 'message' => 'docs: fix typo [skip ci]', + ], + ]), + 'https://api.github.com/repos/example/repo/pulls/42/files' => Http::response([]), + ]); + + $job = new ProcessGithubPullRequestWebhook( + applicationId: $this->application->id, + githubAppId: $githubApp->id, + action: $action, + pullRequestId: 42, + pullRequestHtmlUrl: 'https://github.com/example/repo/pull/42', + pullRequestTitle: 'Add feature', + beforeSha: null, + afterSha: null, + commitSha: 'head-sha', + authorAssociation: 'OWNER', + fullName: 'example/repo', + ); + + $job->handle(); + + expect(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 42)->exists())->toBeFalse() + ->and(ApplicationDeploymentQueue::where('application_id', $this->application->id)->where('pull_request_id', 42)->exists())->toBeFalse(); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/example/repo/commits/head-sha'); + Http::assertNotSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/example/repo/pulls/42/files'); +})->with(['opened', 'reopened']); From f24439f9c8410e00c6373b54a03850f84442e373 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:44:40 +0200 Subject: [PATCH 189/211] test(github): cover PR previews without skip ci --- .../ProcessGithubPullRequestWebhookTest.php | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/Feature/ProcessGithubPullRequestWebhookTest.php b/tests/Feature/ProcessGithubPullRequestWebhookTest.php index bd217396c..667a7643f 100644 --- a/tests/Feature/ProcessGithubPullRequestWebhookTest.php +++ b/tests/Feature/ProcessGithubPullRequestWebhookTest.php @@ -109,6 +109,57 @@ protected function dispatchPullRequestClosedUpdate(Application $application, App Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/example/repo/commits/after-sha'); }); +it('deploys a synchronized GitHub pull request preview when the head commit message does not contain skip ci', function () { + Queue::fake(); + + $this->application->settings->update([ + 'is_preview_deployments_enabled' => true, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Public GitHub', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => true, + 'team_id' => $this->team->id, + ]); + + Http::fake([ + 'https://api.github.com/repos/example/repo/commits/after-sha' => Http::response([ + 'commit' => [ + 'message' => 'docs: fix typo', + ], + ]), + 'https://api.github.com/repos/example/repo/compare/before-sha...after-sha' => Http::response([ + 'files' => [ + ['filename' => 'README.md'], + ], + ]), + ]); + + $job = new ProcessGithubPullRequestWebhook( + applicationId: $this->application->id, + githubAppId: $githubApp->id, + action: 'synchronize', + pullRequestId: 42, + pullRequestHtmlUrl: 'https://github.com/example/repo/pull/42', + pullRequestTitle: 'Add feature', + beforeSha: 'before-sha', + afterSha: 'after-sha', + commitSha: 'after-sha', + authorAssociation: 'OWNER', + fullName: 'example/repo', + ); + + $job->handle(); + + expect(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 42)->exists())->toBeTrue() + ->and(ApplicationDeploymentQueue::where('application_id', $this->application->id)->where('pull_request_id', 42)->where('commit', 'after-sha')->exists())->toBeTrue(); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/example/repo/commits/after-sha'); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/example/repo/compare/before-sha...after-sha'); +}); + it('skips a GitHub pull request preview when the opened or reopened head commit message contains skip ci', function (string $action) { Queue::fake(); From 8c1405e1689c7f26e27f062bb35025f76df02b05 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:23:14 +0200 Subject: [PATCH 190/211] feat(notifications): deduplicate repeated email alerts Add notification-level deduplication keys and TTLs for deployment, backup, server, container, scheduled task, SSL, token, and transactional emails. Apply deduplication in email channels before sending rendered messages. --- .../ApiTokenExpiringNotification.php | 10 ++ .../Application/DeploymentFailed.php | 10 ++ .../Application/DeploymentSuccess.php | 10 ++ .../Application/RestartLimitReached.php | 10 ++ .../Application/StatusChanged.php | 11 +++ app/Notifications/Channels/EmailChannel.php | 38 +++++--- .../Channels/TransactionalEmailChannel.php | 11 ++- .../Container/ContainerRestarted.php | 10 ++ .../Container/ContainerStopped.php | 10 ++ app/Notifications/CustomEmailNotification.php | 15 +++ app/Notifications/Database/BackupFailed.php | 13 +++ app/Notifications/Database/BackupSuccess.php | 13 +++ .../Database/BackupSuccessWithS3Warning.php | 13 +++ .../ScheduledTask/TaskFailed.php | 10 ++ .../ScheduledTask/TaskSuccess.php | 10 ++ .../Server/DockerCleanupFailed.php | 10 ++ .../Server/DockerCleanupSuccess.php | 10 ++ app/Notifications/Server/ForceDisabled.php | 10 ++ app/Notifications/Server/ForceEnabled.php | 10 ++ .../Server/HetznerDeletionFailed.php | 10 ++ app/Notifications/Server/HighDiskUsage.php | 10 ++ app/Notifications/Server/Reachable.php | 10 ++ app/Notifications/Server/ServerPatchCheck.php | 10 ++ .../Server/TraefikVersionOutdated.php | 12 +++ app/Notifications/Server/Unreachable.php | 10 ++ .../SslExpirationNotification.php | 16 ++++ app/Notifications/Test.php | 5 + .../EmailChangeVerification.php | 10 ++ .../TransactionalEmails/InvitationLink.php | 10 ++ .../TransactionalEmails/Test.php | 5 + app/Services/NotificationDeduplicator.php | 95 +++++++++++++++++++ ...pplicationStoppedAfterRestartLimitTest.php | 18 ++++ .../Feature/NotificationDeduplicationTest.php | 75 +++++++++++++++ 33 files changed, 516 insertions(+), 14 deletions(-) create mode 100644 app/Services/NotificationDeduplicator.php create mode 100644 tests/Feature/NotificationDeduplicationTest.php diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index 451dd312a..c00ac2d12 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -29,6 +29,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('api_token_expiring'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "api-token-expiring:{$this->token->id}"; + } + + public function deduplicateFor(): int + { + return 172800; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/DeploymentFailed.php b/app/Notifications/Application/DeploymentFailed.php index 8fff7f03b..0ed705edd 100644 --- a/app/Notifications/Application/DeploymentFailed.php +++ b/app/Notifications/Application/DeploymentFailed.php @@ -52,6 +52,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('deployment_failure'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "deployment-failed:{$this->deployment_uuid}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/DeploymentSuccess.php b/app/Notifications/Application/DeploymentSuccess.php index 415df5831..56b692cda 100644 --- a/app/Notifications/Application/DeploymentSuccess.php +++ b/app/Notifications/Application/DeploymentSuccess.php @@ -52,6 +52,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('deployment_success'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "deployment-success:{$this->deployment_uuid}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php index 635dfdbdc..507bba28d 100644 --- a/app/Notifications/Application/RestartLimitReached.php +++ b/app/Notifications/Application/RestartLimitReached.php @@ -49,6 +49,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "restart-limit-reached:application:{$this->resource->uuid}:count:{$this->restart_count}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/StatusChanged.php b/app/Notifications/Application/StatusChanged.php index ef61b7e6a..87986435d 100644 --- a/app/Notifications/Application/StatusChanged.php +++ b/app/Notifications/Application/StatusChanged.php @@ -42,6 +42,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "application-status-changed:application:{$this->resource->uuid}:stopped"; + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $mail = new MailMessage; @@ -50,6 +60,7 @@ public function toMail(): MailMessage $mail->view('emails.application-status-changes', [ 'name' => $this->resource_name, 'fqdn' => $fqdn, + 'application_url' => $this->resource_url, 'resource_url' => $this->resource_url, ]); diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index abd115550..45c6cb2d6 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -4,13 +4,20 @@ use App\Exceptions\NonReportableException; use App\Models\Team; +use App\Services\NotificationDeduplicator; use Exception; use Illuminate\Notifications\Notification; use Resend; +use Resend\Exceptions\ErrorException; +use Resend\Exceptions\TransporterException; +use Symfony\Component\Mailer\Mailer; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; class EmailChannel { - public function __construct() {} + public function __construct(private NotificationDeduplicator $deduplicator) {} public function send(SendsEmail $notifiable, Notification $notification): void { @@ -67,6 +74,11 @@ public function send(SendsEmail $notifiable, Notification $notification): void } $mailMessage = $notification->toMail($notifiable); + $renderedMail = (string) $mailMessage->render(); + + if (! $this->deduplicator->shouldSend($notifiable, $notification, self::class, $recipients, $mailMessage->subject, $renderedMail)) { + return; + } if ($isResendEnabled) { $resend = Resend::client($settings->resend_api_key); @@ -75,17 +87,17 @@ public function send(SendsEmail $notifiable, Notification $notification): void 'from' => $from, 'to' => $recipients, 'subject' => $mailMessage->subject, - 'html' => (string) $mailMessage->render(), + 'html' => $renderedMail, ]); } elseif ($isSmtpEnabled) { - $encryption = match (strtolower($settings->smtp_encryption)) { + $encryption = match (strtolower($settings->smtp_encryption ?? '')) { 'starttls' => null, 'tls' => 'tls', 'none' => null, default => null, }; - $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( + $transport = new EsmtpTransport( $settings->smtp_host, $settings->smtp_port, $encryption @@ -93,20 +105,20 @@ public function send(SendsEmail $notifiable, Notification $notification): void $transport->setUsername($settings->smtp_username ?? ''); $transport->setPassword($settings->smtp_password ?? ''); - $mailer = new \Symfony\Component\Mailer\Mailer($transport); + $mailer = new Mailer($transport); $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost'; $fromName = $settings->smtp_from_name ?? 'System'; - $from = new \Symfony\Component\Mime\Address($fromEmail, $fromName); - $email = (new \Symfony\Component\Mime\Email) + $from = new Address($fromEmail, $fromName); + $email = (new Email) ->from($from) ->to(...$recipients) ->subject($mailMessage->subject) - ->html((string) $mailMessage->render()); + ->html($renderedMail); $mailer->send($email); } - } catch (\Resend\Exceptions\ErrorException $e) { + } catch (ErrorException $e) { // Map HTTP status codes to user-friendly messages $userMessage = match ($e->getErrorCode()) { 403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.', @@ -131,13 +143,13 @@ public function send(SendsEmail $notifiable, Notification $notification): void // Don't report expected errors (invalid keys, validation) to Sentry if (in_array($e->getErrorCode(), [403, 401, 400])) { - throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e)); + throw NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e)); } - throw new \Exception($userMessage, $e->getCode(), $e); - } catch (\Resend\Exceptions\TransporterException $e) { + throw new Exception($userMessage, $e->getCode(), $e); + } catch (TransporterException $e) { send_internal_notification("Resend Transport Error: {$e->getMessage()}"); - throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); + throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); } catch (\Throwable $e) { // Check if this is a Resend domain verification error on cloud instances if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 8ab74a60b..803db57f3 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -3,6 +3,7 @@ namespace App\Notifications\Channels; use App\Models\User; +use App\Services\NotificationDeduplicator; use Exception; use Illuminate\Mail\Message; use Illuminate\Notifications\Notification; @@ -10,6 +11,8 @@ class TransactionalEmailChannel { + public function __construct(private NotificationDeduplicator $deduplicator) {} + public function send(User $notifiable, Notification $notification): void { $settings = instanceSettings(); @@ -27,13 +30,19 @@ public function send(User $notifiable, Notification $notification): void } $this->bootConfigs(); $mailMessage = $notification->toMail($notifiable); + $renderedMail = (string) $mailMessage->render(); + + if (! $this->deduplicator->shouldSend($notifiable, $notification, self::class, [$email], $mailMessage->subject, $renderedMail)) { + return; + } + Mail::send( [], [], fn (Message $message) => $message ->to($email) ->subject($mailMessage->subject) - ->html((string) $mailMessage->render()) + ->html($renderedMail) ); } diff --git a/app/Notifications/Container/ContainerRestarted.php b/app/Notifications/Container/ContainerRestarted.php index 2d7eb58b5..d51c77cb3 100644 --- a/app/Notifications/Container/ContainerRestarted.php +++ b/app/Notifications/Container/ContainerRestarted.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "container-restarted:server:{$this->server->uuid}:container:{$this->name}"; + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Container/ContainerStopped.php b/app/Notifications/Container/ContainerStopped.php index f518cd2fd..7daba04ca 100644 --- a/app/Notifications/Container/ContainerStopped.php +++ b/app/Notifications/Container/ContainerStopped.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "container-stopped:server:{$this->server->uuid}:container:{$this->name}"; + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/CustomEmailNotification.php b/app/Notifications/CustomEmailNotification.php index c3c89b30f..e3f62e22a 100644 --- a/app/Notifications/CustomEmailNotification.php +++ b/app/Notifications/CustomEmailNotification.php @@ -15,4 +15,19 @@ class CustomEmailNotification extends Notification implements ShouldQueue public $tries = 5; public $maxExceptions = 5; + + public function shouldDeduplicate(): bool + { + return true; + } + + public function deduplicateFor(): int + { + return 900; + } + + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return null; + } } diff --git a/app/Notifications/Database/BackupFailed.php b/app/Notifications/Database/BackupFailed.php index c2b21b1d5..8d9c99603 100644 --- a/app/Notifications/Database/BackupFailed.php +++ b/app/Notifications/Database/BackupFailed.php @@ -11,6 +11,8 @@ class BackupFailed extends CustomEmailNotification { + public int|string|null $backupId = null; + public string $name; public string $frequency; @@ -18,6 +20,7 @@ class BackupFailed extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $output, public $database_name) { $this->onQueue('high'); + $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; } @@ -27,6 +30,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_failure'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "backup-failed:backup:{$this->backupId}:database:{$this->database->uuid}:output:".hash('sha256', (string) $this->output); + } + + public function deduplicateFor(): int + { + return 21600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Database/BackupSuccess.php b/app/Notifications/Database/BackupSuccess.php index 3d2d8ece3..166a48496 100644 --- a/app/Notifications/Database/BackupSuccess.php +++ b/app/Notifications/Database/BackupSuccess.php @@ -11,6 +11,8 @@ class BackupSuccess extends CustomEmailNotification { + public int|string|null $backupId = null; + public string $name; public string $frequency; @@ -18,6 +20,7 @@ class BackupSuccess extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name) { $this->onQueue('high'); + $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; @@ -28,6 +31,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_success'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "backup-success:backup:{$this->backupId}:database:{$this->database->uuid}:name:{$this->database_name}:frequency:{$this->frequency}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php index ee24ef17d..0da619448 100644 --- a/app/Notifications/Database/BackupSuccessWithS3Warning.php +++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php @@ -11,6 +11,8 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification { + public int|string|null $backupId = null; + public string $name; public string $frequency; @@ -20,6 +22,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name, public $s3_error) { $this->onQueue('high'); + $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; @@ -34,6 +37,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_failure'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "backup-s3-warning:backup:{$this->backupId}:database:{$this->database->uuid}:error:".hash('sha256', (string) $this->s3_error); + } + + public function deduplicateFor(): int + { + return 21600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/ScheduledTask/TaskFailed.php b/app/Notifications/ScheduledTask/TaskFailed.php index bd060112a..5078ca8e9 100644 --- a/app/Notifications/ScheduledTask/TaskFailed.php +++ b/app/Notifications/ScheduledTask/TaskFailed.php @@ -28,6 +28,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('scheduled_task_failure'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "scheduled-task-failed:task:{$this->task->uuid}:output:".hash('sha256', $this->output); + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/ScheduledTask/TaskSuccess.php b/app/Notifications/ScheduledTask/TaskSuccess.php index 58c959bd8..0231ecf3d 100644 --- a/app/Notifications/ScheduledTask/TaskSuccess.php +++ b/app/Notifications/ScheduledTask/TaskSuccess.php @@ -28,6 +28,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('scheduled_task_success'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "scheduled-task-success:task:{$this->task->uuid}:output:".hash('sha256', $this->output); + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/DockerCleanupFailed.php b/app/Notifications/Server/DockerCleanupFailed.php index 9cbdeb488..ac0eea17d 100644 --- a/app/Notifications/Server/DockerCleanupFailed.php +++ b/app/Notifications/Server/DockerCleanupFailed.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('docker_cleanup_failure'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "docker-cleanup-failed:server:{$this->server->uuid}:message:".hash('sha256', $this->message); + } + + public function deduplicateFor(): int + { + return 21600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/DockerCleanupSuccess.php b/app/Notifications/Server/DockerCleanupSuccess.php index d28f25c6c..7e5ec0bcf 100644 --- a/app/Notifications/Server/DockerCleanupSuccess.php +++ b/app/Notifications/Server/DockerCleanupSuccess.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('docker_cleanup_success'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "docker-cleanup-success:server:{$this->server->uuid}:message:".hash('sha256', $this->message); + } + + public function deduplicateFor(): int + { + return 21600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 4b56f5860..8d1817026 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_force_disabled'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "server-force-disabled:{$this->server->uuid}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ForceEnabled.php b/app/Notifications/Server/ForceEnabled.php index 36dad3c60..3db96f995 100644 --- a/app/Notifications/Server/ForceEnabled.php +++ b/app/Notifications/Server/ForceEnabled.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_force_enabled'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "server-force-enabled:{$this->server->uuid}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index bb452b054..866d2eb07 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('hetzner_deletion_failed'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "hetzner-deletion-failed:{$this->hetznerServerId}:error:".hash('sha256', $this->errorMessage); + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/HighDiskUsage.php b/app/Notifications/Server/HighDiskUsage.php index 149d1bbc8..4007ca805 100644 --- a/app/Notifications/Server/HighDiskUsage.php +++ b/app/Notifications/Server/HighDiskUsage.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_disk_usage'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "high-disk-usage:server:{$this->server->uuid}:threshold:{$this->server_disk_usage_notification_threshold}"; + } + + public function deduplicateFor(): int + { + return 21600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/Reachable.php b/app/Notifications/Server/Reachable.php index e64b0af2a..b297b7d3d 100644 --- a/app/Notifications/Server/Reachable.php +++ b/app/Notifications/Server/Reachable.php @@ -30,6 +30,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_reachable'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "server-reachable:{$this->server->uuid}"; + } + + public function deduplicateFor(): int + { + return 1800; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ServerPatchCheck.php b/app/Notifications/Server/ServerPatchCheck.php index ba6cd4982..d0d5f4875 100644 --- a/app/Notifications/Server/ServerPatchCheck.php +++ b/app/Notifications/Server/ServerPatchCheck.php @@ -24,6 +24,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_patch'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "server-patch-check:server:{$this->server->uuid}:state:".hash('sha256', json_encode($this->patchData)); + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail($notifiable = null): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/TraefikVersionOutdated.php b/app/Notifications/Server/TraefikVersionOutdated.php index c94cc1732..d6e5ae8aa 100644 --- a/app/Notifications/Server/TraefikVersionOutdated.php +++ b/app/Notifications/Server/TraefikVersionOutdated.php @@ -38,6 +38,18 @@ private function getUpgradeTarget(array $info): string return $this->formatVersion($info['latest'] ?? 'unknown'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + $serverUuids = $this->servers->pluck('uuid')->sort()->values()->join('|'); + + return 'traefik-version-outdated:servers:'.hash('sha256', $serverUuids); + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail($notifiable = null): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/Unreachable.php b/app/Notifications/Server/Unreachable.php index 99742f3b7..cd6fd63b6 100644 --- a/app/Notifications/Server/Unreachable.php +++ b/app/Notifications/Server/Unreachable.php @@ -30,6 +30,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_unreachable'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "server-unreachable:{$this->server->uuid}"; + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): ?MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 78e1e8be9..72ce136bd 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -59,6 +59,22 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('ssl_certificate_renewal'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + $resourceKeys = $this->resources + ->map(fn ($resource) => data_get($resource, 'uuid') ?? data_get($resource, 'name')) + ->sort() + ->values() + ->join('|'); + + return 'ssl-certificate-renewed:resources:'.hash('sha256', $resourceKeys); + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Test.php b/app/Notifications/Test.php index bbed22777..ea3dfe9c1 100644 --- a/app/Notifications/Test.php +++ b/app/Notifications/Test.php @@ -30,6 +30,11 @@ public function __construct(public ?string $emails = null, public ?string $chann $this->onQueue('high'); } + public function shouldDeduplicate(): bool + { + return false; + } + public function via(object $notifiable): array { if ($this->channel) { diff --git a/app/Notifications/TransactionalEmails/EmailChangeVerification.php b/app/Notifications/TransactionalEmails/EmailChangeVerification.php index ea8462366..bb5e7f870 100644 --- a/app/Notifications/TransactionalEmails/EmailChangeVerification.php +++ b/app/Notifications/TransactionalEmails/EmailChangeVerification.php @@ -25,6 +25,16 @@ public function __construct( $this->onQueue('high'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "email-change-verification:user:{$this->user->id}:email:{$this->newEmail}:code:{$this->verificationCode}"; + } + + public function deduplicateFor(): int + { + return (int) max(1, now()->diffInSeconds($this->expiresAt, false)); + } + public function toMail(): MailMessage { // Use the configured expiry minutes value diff --git a/app/Notifications/TransactionalEmails/InvitationLink.php b/app/Notifications/TransactionalEmails/InvitationLink.php index 9bfb54798..f3b1e6d67 100644 --- a/app/Notifications/TransactionalEmails/InvitationLink.php +++ b/app/Notifications/TransactionalEmails/InvitationLink.php @@ -21,6 +21,16 @@ public function __construct(public User $user, public bool $isTransactionalEmail $this->onQueue('high'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "invitation-link:user:{$this->user->id}:email:{$this->user->email}"; + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $invitation = TeamInvitation::whereEmail($this->user->email)->first(); diff --git a/app/Notifications/TransactionalEmails/Test.php b/app/Notifications/TransactionalEmails/Test.php index 2f7d70bbf..dc8c0dac7 100644 --- a/app/Notifications/TransactionalEmails/Test.php +++ b/app/Notifications/TransactionalEmails/Test.php @@ -15,6 +15,11 @@ public function __construct(public string $emails, public bool $isTransactionalE $this->onQueue('high'); } + public function shouldDeduplicate(): bool + { + return false; + } + public function via(): array { return [EmailChannel::class]; diff --git a/app/Services/NotificationDeduplicator.php b/app/Services/NotificationDeduplicator.php new file mode 100644 index 000000000..d018dd0a5 --- /dev/null +++ b/app/Services/NotificationDeduplicator.php @@ -0,0 +1,95 @@ + $recipients + */ + public function shouldSend(object $notifiable, Notification $notification, string $channel, array $recipients, ?string $subject = null, ?string $body = null): bool + { + if (method_exists($notification, 'shouldDeduplicate') && ! $notification->shouldDeduplicate()) { + return true; + } + + $ttl = method_exists($notification, 'deduplicateFor') + ? $notification->deduplicateFor() + : self::DEFAULT_TTL; + + if ($ttl <= 0) { + return true; + } + + return Cache::add( + $this->cacheKey($notifiable, $notification, $channel, $recipients, $subject, $body), + true, + $ttl, + ); + } + + /** + * @param array $recipients + */ + private function cacheKey(object $notifiable, Notification $notification, string $channel, array $recipients, ?string $subject, ?string $body): string + { + $semanticKey = method_exists($notification, 'deduplicationKey') + ? $notification->deduplicationKey($notifiable, $channel) + : null; + + $payload = $semanticKey + ? $this->semanticFingerprint($notifiable, $notification, $channel, $recipients, $semanticKey) + : $this->defaultFingerprint($notifiable, $notification, $channel, $recipients, $subject, $body); + + return 'notification-dedupe:'.hash('sha256', $payload); + } + + /** + * @param array $recipients + */ + private function semanticFingerprint(object $notifiable, Notification $notification, string $channel, array $recipients, string $semanticKey): string + { + return json_encode([ + 'notification' => $notification::class, + 'notifiable' => $notifiable::class, + 'notifiable_id' => data_get($notifiable, 'id'), + 'channel' => $channel, + 'recipients' => $this->normalizeRecipients($recipients), + 'semantic_key' => $semanticKey, + ], JSON_THROW_ON_ERROR); + } + + /** + * @param array $recipients + */ + private function defaultFingerprint(object $notifiable, Notification $notification, string $channel, array $recipients, ?string $subject, ?string $body): string + { + return json_encode([ + 'notification' => $notification::class, + 'notifiable' => $notifiable::class, + 'notifiable_id' => data_get($notifiable, 'id'), + 'channel' => $channel, + 'recipients' => $this->normalizeRecipients($recipients), + 'subject' => $subject, + 'body_hash' => hash('sha256', (string) $body), + ], JSON_THROW_ON_ERROR); + } + + /** + * @param array $recipients + * @return array + */ + private function normalizeRecipients(array $recipients): array + { + return collect($recipients) + ->map(fn (string $recipient) => mb_strtolower(trim($recipient))) + ->sort() + ->values() + ->all(); + } +} diff --git a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php index 6b82d5568..482a3e16e 100644 --- a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php +++ b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php @@ -48,6 +48,24 @@ function applicationWithRestartState(array $attributes = []): Application expect($html)->not->toContain('Stopped after reaching restart limit'); }); +it('uses a semantic dedupe key for restart limit notifications', function () { + $application = applicationWithRestartState(); + $application->forceFill([ + 'name' => 'crashy-app', + 'uuid' => 'application-uuid', + ]); + $application->setRelation('environment', (object) [ + 'uuid' => 'environment-uuid', + 'name' => 'production', + 'project' => (object) ['uuid' => 'project-uuid'], + ]); + + $notification = new RestartLimitReached($application); + + expect($notification->deduplicationKey((object) ['id' => 1], 'mail'))->toBe('restart-limit-reached:application:application-uuid:count:2') + ->and($notification->deduplicateFor())->toBe(86400); +}); + it('keeps restart tracking configurable when stopping an application', function () { $method = new ReflectionMethod(StopApplication::class, 'handle'); $resetRestartCount = collect($method->getParameters())->firstWhere('name', 'resetRestartCount'); diff --git a/tests/Feature/NotificationDeduplicationTest.php b/tests/Feature/NotificationDeduplicationTest.php new file mode 100644 index 000000000..39fc70433 --- /dev/null +++ b/tests/Feature/NotificationDeduplicationTest.php @@ -0,0 +1,75 @@ +subject('Test'); + } + + public function shouldDeduplicate(): bool + { + return $this->deduplicate; + } + + public function deduplicateFor(): int + { + return $this->ttl; + } + + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return $this->semanticKey; + } +} + +beforeEach(function () { + Cache::flush(); + $this->deduplicator = app(NotificationDeduplicator::class); + $this->notifiable = new class + { + public int $id = 123; + }; +}); + +it('allows only the first identical notification fingerprint during the ttl', function () { + $notification = new DedupeTestNotification; + + expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeFalse(); +}); + +it('allows different recipients and content through the default fingerprint', function () { + $notification = new DedupeTestNotification; + + expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Subject', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Other body

'))->toBeTrue(); +}); + +it('uses semantic keys instead of rendered content when provided', function () { + $notification = new DedupeTestNotification(semanticKey: 'event:123'); + + expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', '

Other body

'))->toBeFalse() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Other subject', '

Other body

'))->toBeTrue(); +}); + +it('allows notifications to opt out of deduplication', function () { + $notification = new DedupeTestNotification(deduplicate: false); + + expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeTrue(); +}); From 2a0183bfad2ab7252144e5d73eb8cf7b598a0625 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:19:48 +0200 Subject: [PATCH 191/211] Revert "feat(notifications): deduplicate repeated email alerts" This reverts commit 8c1405e1689c7f26e27f062bb35025f76df02b05. --- .../ApiTokenExpiringNotification.php | 10 -- .../Application/DeploymentFailed.php | 10 -- .../Application/DeploymentSuccess.php | 10 -- .../Application/RestartLimitReached.php | 10 -- .../Application/StatusChanged.php | 11 --- app/Notifications/Channels/EmailChannel.php | 38 +++----- .../Channels/TransactionalEmailChannel.php | 11 +-- .../Container/ContainerRestarted.php | 10 -- .../Container/ContainerStopped.php | 10 -- app/Notifications/CustomEmailNotification.php | 15 --- app/Notifications/Database/BackupFailed.php | 13 --- app/Notifications/Database/BackupSuccess.php | 13 --- .../Database/BackupSuccessWithS3Warning.php | 13 --- .../ScheduledTask/TaskFailed.php | 10 -- .../ScheduledTask/TaskSuccess.php | 10 -- .../Server/DockerCleanupFailed.php | 10 -- .../Server/DockerCleanupSuccess.php | 10 -- app/Notifications/Server/ForceDisabled.php | 10 -- app/Notifications/Server/ForceEnabled.php | 10 -- .../Server/HetznerDeletionFailed.php | 10 -- app/Notifications/Server/HighDiskUsage.php | 10 -- app/Notifications/Server/Reachable.php | 10 -- app/Notifications/Server/ServerPatchCheck.php | 10 -- .../Server/TraefikVersionOutdated.php | 12 --- app/Notifications/Server/Unreachable.php | 10 -- .../SslExpirationNotification.php | 16 ---- app/Notifications/Test.php | 5 - .../EmailChangeVerification.php | 10 -- .../TransactionalEmails/InvitationLink.php | 10 -- .../TransactionalEmails/Test.php | 5 - app/Services/NotificationDeduplicator.php | 95 ------------------- ...pplicationStoppedAfterRestartLimitTest.php | 18 ---- .../Feature/NotificationDeduplicationTest.php | 75 --------------- 33 files changed, 14 insertions(+), 516 deletions(-) delete mode 100644 app/Services/NotificationDeduplicator.php delete mode 100644 tests/Feature/NotificationDeduplicationTest.php diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index c00ac2d12..451dd312a 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -29,16 +29,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('api_token_expiring'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "api-token-expiring:{$this->token->id}"; - } - - public function deduplicateFor(): int - { - return 172800; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/DeploymentFailed.php b/app/Notifications/Application/DeploymentFailed.php index 0ed705edd..8fff7f03b 100644 --- a/app/Notifications/Application/DeploymentFailed.php +++ b/app/Notifications/Application/DeploymentFailed.php @@ -52,16 +52,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('deployment_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "deployment-failed:{$this->deployment_uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/DeploymentSuccess.php b/app/Notifications/Application/DeploymentSuccess.php index 56b692cda..415df5831 100644 --- a/app/Notifications/Application/DeploymentSuccess.php +++ b/app/Notifications/Application/DeploymentSuccess.php @@ -52,16 +52,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('deployment_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "deployment-success:{$this->deployment_uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php index 507bba28d..635dfdbdc 100644 --- a/app/Notifications/Application/RestartLimitReached.php +++ b/app/Notifications/Application/RestartLimitReached.php @@ -49,16 +49,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "restart-limit-reached:application:{$this->resource->uuid}:count:{$this->restart_count}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/StatusChanged.php b/app/Notifications/Application/StatusChanged.php index 87986435d..ef61b7e6a 100644 --- a/app/Notifications/Application/StatusChanged.php +++ b/app/Notifications/Application/StatusChanged.php @@ -42,16 +42,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "application-status-changed:application:{$this->resource->uuid}:stopped"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; @@ -60,7 +50,6 @@ public function toMail(): MailMessage $mail->view('emails.application-status-changes', [ 'name' => $this->resource_name, 'fqdn' => $fqdn, - 'application_url' => $this->resource_url, 'resource_url' => $this->resource_url, ]); diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 45c6cb2d6..abd115550 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -4,20 +4,13 @@ use App\Exceptions\NonReportableException; use App\Models\Team; -use App\Services\NotificationDeduplicator; use Exception; use Illuminate\Notifications\Notification; use Resend; -use Resend\Exceptions\ErrorException; -use Resend\Exceptions\TransporterException; -use Symfony\Component\Mailer\Mailer; -use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; -use Symfony\Component\Mime\Address; -use Symfony\Component\Mime\Email; class EmailChannel { - public function __construct(private NotificationDeduplicator $deduplicator) {} + public function __construct() {} public function send(SendsEmail $notifiable, Notification $notification): void { @@ -74,11 +67,6 @@ public function send(SendsEmail $notifiable, Notification $notification): void } $mailMessage = $notification->toMail($notifiable); - $renderedMail = (string) $mailMessage->render(); - - if (! $this->deduplicator->shouldSend($notifiable, $notification, self::class, $recipients, $mailMessage->subject, $renderedMail)) { - return; - } if ($isResendEnabled) { $resend = Resend::client($settings->resend_api_key); @@ -87,17 +75,17 @@ public function send(SendsEmail $notifiable, Notification $notification): void 'from' => $from, 'to' => $recipients, 'subject' => $mailMessage->subject, - 'html' => $renderedMail, + 'html' => (string) $mailMessage->render(), ]); } elseif ($isSmtpEnabled) { - $encryption = match (strtolower($settings->smtp_encryption ?? '')) { + $encryption = match (strtolower($settings->smtp_encryption)) { 'starttls' => null, 'tls' => 'tls', 'none' => null, default => null, }; - $transport = new EsmtpTransport( + $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( $settings->smtp_host, $settings->smtp_port, $encryption @@ -105,20 +93,20 @@ public function send(SendsEmail $notifiable, Notification $notification): void $transport->setUsername($settings->smtp_username ?? ''); $transport->setPassword($settings->smtp_password ?? ''); - $mailer = new Mailer($transport); + $mailer = new \Symfony\Component\Mailer\Mailer($transport); $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost'; $fromName = $settings->smtp_from_name ?? 'System'; - $from = new Address($fromEmail, $fromName); - $email = (new Email) + $from = new \Symfony\Component\Mime\Address($fromEmail, $fromName); + $email = (new \Symfony\Component\Mime\Email) ->from($from) ->to(...$recipients) ->subject($mailMessage->subject) - ->html($renderedMail); + ->html((string) $mailMessage->render()); $mailer->send($email); } - } catch (ErrorException $e) { + } catch (\Resend\Exceptions\ErrorException $e) { // Map HTTP status codes to user-friendly messages $userMessage = match ($e->getErrorCode()) { 403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.', @@ -143,13 +131,13 @@ public function send(SendsEmail $notifiable, Notification $notification): void // Don't report expected errors (invalid keys, validation) to Sentry if (in_array($e->getErrorCode(), [403, 401, 400])) { - throw NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e)); + throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e)); } - throw new Exception($userMessage, $e->getCode(), $e); - } catch (TransporterException $e) { + throw new \Exception($userMessage, $e->getCode(), $e); + } catch (\Resend\Exceptions\TransporterException $e) { send_internal_notification("Resend Transport Error: {$e->getMessage()}"); - throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); + throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); } catch (\Throwable $e) { // Check if this is a Resend domain verification error on cloud instances if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 803db57f3..8ab74a60b 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -3,7 +3,6 @@ namespace App\Notifications\Channels; use App\Models\User; -use App\Services\NotificationDeduplicator; use Exception; use Illuminate\Mail\Message; use Illuminate\Notifications\Notification; @@ -11,8 +10,6 @@ class TransactionalEmailChannel { - public function __construct(private NotificationDeduplicator $deduplicator) {} - public function send(User $notifiable, Notification $notification): void { $settings = instanceSettings(); @@ -30,19 +27,13 @@ public function send(User $notifiable, Notification $notification): void } $this->bootConfigs(); $mailMessage = $notification->toMail($notifiable); - $renderedMail = (string) $mailMessage->render(); - - if (! $this->deduplicator->shouldSend($notifiable, $notification, self::class, [$email], $mailMessage->subject, $renderedMail)) { - return; - } - Mail::send( [], [], fn (Message $message) => $message ->to($email) ->subject($mailMessage->subject) - ->html($renderedMail) + ->html((string) $mailMessage->render()) ); } diff --git a/app/Notifications/Container/ContainerRestarted.php b/app/Notifications/Container/ContainerRestarted.php index d51c77cb3..2d7eb58b5 100644 --- a/app/Notifications/Container/ContainerRestarted.php +++ b/app/Notifications/Container/ContainerRestarted.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "container-restarted:server:{$this->server->uuid}:container:{$this->name}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Container/ContainerStopped.php b/app/Notifications/Container/ContainerStopped.php index 7daba04ca..f518cd2fd 100644 --- a/app/Notifications/Container/ContainerStopped.php +++ b/app/Notifications/Container/ContainerStopped.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "container-stopped:server:{$this->server->uuid}:container:{$this->name}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/CustomEmailNotification.php b/app/Notifications/CustomEmailNotification.php index e3f62e22a..c3c89b30f 100644 --- a/app/Notifications/CustomEmailNotification.php +++ b/app/Notifications/CustomEmailNotification.php @@ -15,19 +15,4 @@ class CustomEmailNotification extends Notification implements ShouldQueue public $tries = 5; public $maxExceptions = 5; - - public function shouldDeduplicate(): bool - { - return true; - } - - public function deduplicateFor(): int - { - return 900; - } - - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return null; - } } diff --git a/app/Notifications/Database/BackupFailed.php b/app/Notifications/Database/BackupFailed.php index 8d9c99603..c2b21b1d5 100644 --- a/app/Notifications/Database/BackupFailed.php +++ b/app/Notifications/Database/BackupFailed.php @@ -11,8 +11,6 @@ class BackupFailed extends CustomEmailNotification { - public int|string|null $backupId = null; - public string $name; public string $frequency; @@ -20,7 +18,6 @@ class BackupFailed extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $output, public $database_name) { $this->onQueue('high'); - $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; } @@ -30,16 +27,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "backup-failed:backup:{$this->backupId}:database:{$this->database->uuid}:output:".hash('sha256', (string) $this->output); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Database/BackupSuccess.php b/app/Notifications/Database/BackupSuccess.php index 166a48496..3d2d8ece3 100644 --- a/app/Notifications/Database/BackupSuccess.php +++ b/app/Notifications/Database/BackupSuccess.php @@ -11,8 +11,6 @@ class BackupSuccess extends CustomEmailNotification { - public int|string|null $backupId = null; - public string $name; public string $frequency; @@ -20,7 +18,6 @@ class BackupSuccess extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name) { $this->onQueue('high'); - $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; @@ -31,16 +28,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "backup-success:backup:{$this->backupId}:database:{$this->database->uuid}:name:{$this->database_name}:frequency:{$this->frequency}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php index 0da619448..ee24ef17d 100644 --- a/app/Notifications/Database/BackupSuccessWithS3Warning.php +++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php @@ -11,8 +11,6 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification { - public int|string|null $backupId = null; - public string $name; public string $frequency; @@ -22,7 +20,6 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name, public $s3_error) { $this->onQueue('high'); - $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; @@ -37,16 +34,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "backup-s3-warning:backup:{$this->backupId}:database:{$this->database->uuid}:error:".hash('sha256', (string) $this->s3_error); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/ScheduledTask/TaskFailed.php b/app/Notifications/ScheduledTask/TaskFailed.php index 5078ca8e9..bd060112a 100644 --- a/app/Notifications/ScheduledTask/TaskFailed.php +++ b/app/Notifications/ScheduledTask/TaskFailed.php @@ -28,16 +28,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('scheduled_task_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "scheduled-task-failed:task:{$this->task->uuid}:output:".hash('sha256', $this->output); - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/ScheduledTask/TaskSuccess.php b/app/Notifications/ScheduledTask/TaskSuccess.php index 0231ecf3d..58c959bd8 100644 --- a/app/Notifications/ScheduledTask/TaskSuccess.php +++ b/app/Notifications/ScheduledTask/TaskSuccess.php @@ -28,16 +28,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('scheduled_task_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "scheduled-task-success:task:{$this->task->uuid}:output:".hash('sha256', $this->output); - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/DockerCleanupFailed.php b/app/Notifications/Server/DockerCleanupFailed.php index ac0eea17d..9cbdeb488 100644 --- a/app/Notifications/Server/DockerCleanupFailed.php +++ b/app/Notifications/Server/DockerCleanupFailed.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('docker_cleanup_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "docker-cleanup-failed:server:{$this->server->uuid}:message:".hash('sha256', $this->message); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/DockerCleanupSuccess.php b/app/Notifications/Server/DockerCleanupSuccess.php index 7e5ec0bcf..d28f25c6c 100644 --- a/app/Notifications/Server/DockerCleanupSuccess.php +++ b/app/Notifications/Server/DockerCleanupSuccess.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('docker_cleanup_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "docker-cleanup-success:server:{$this->server->uuid}:message:".hash('sha256', $this->message); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 8d1817026..4b56f5860 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_force_disabled'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-force-disabled:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ForceEnabled.php b/app/Notifications/Server/ForceEnabled.php index 3db96f995..36dad3c60 100644 --- a/app/Notifications/Server/ForceEnabled.php +++ b/app/Notifications/Server/ForceEnabled.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_force_enabled'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-force-enabled:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index 866d2eb07..bb452b054 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('hetzner_deletion_failed'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "hetzner-deletion-failed:{$this->hetznerServerId}:error:".hash('sha256', $this->errorMessage); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/HighDiskUsage.php b/app/Notifications/Server/HighDiskUsage.php index 4007ca805..149d1bbc8 100644 --- a/app/Notifications/Server/HighDiskUsage.php +++ b/app/Notifications/Server/HighDiskUsage.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_disk_usage'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "high-disk-usage:server:{$this->server->uuid}:threshold:{$this->server_disk_usage_notification_threshold}"; - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/Reachable.php b/app/Notifications/Server/Reachable.php index b297b7d3d..e64b0af2a 100644 --- a/app/Notifications/Server/Reachable.php +++ b/app/Notifications/Server/Reachable.php @@ -30,16 +30,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_reachable'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-reachable:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 1800; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ServerPatchCheck.php b/app/Notifications/Server/ServerPatchCheck.php index d0d5f4875..ba6cd4982 100644 --- a/app/Notifications/Server/ServerPatchCheck.php +++ b/app/Notifications/Server/ServerPatchCheck.php @@ -24,16 +24,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_patch'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-patch-check:server:{$this->server->uuid}:state:".hash('sha256', json_encode($this->patchData)); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail($notifiable = null): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/TraefikVersionOutdated.php b/app/Notifications/Server/TraefikVersionOutdated.php index d6e5ae8aa..c94cc1732 100644 --- a/app/Notifications/Server/TraefikVersionOutdated.php +++ b/app/Notifications/Server/TraefikVersionOutdated.php @@ -38,18 +38,6 @@ private function getUpgradeTarget(array $info): string return $this->formatVersion($info['latest'] ?? 'unknown'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - $serverUuids = $this->servers->pluck('uuid')->sort()->values()->join('|'); - - return 'traefik-version-outdated:servers:'.hash('sha256', $serverUuids); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail($notifiable = null): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/Unreachable.php b/app/Notifications/Server/Unreachable.php index cd6fd63b6..99742f3b7 100644 --- a/app/Notifications/Server/Unreachable.php +++ b/app/Notifications/Server/Unreachable.php @@ -30,16 +30,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_unreachable'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-unreachable:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): ?MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 72ce136bd..78e1e8be9 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -59,22 +59,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('ssl_certificate_renewal'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - $resourceKeys = $this->resources - ->map(fn ($resource) => data_get($resource, 'uuid') ?? data_get($resource, 'name')) - ->sort() - ->values() - ->join('|'); - - return 'ssl-certificate-renewed:resources:'.hash('sha256', $resourceKeys); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Test.php b/app/Notifications/Test.php index ea3dfe9c1..bbed22777 100644 --- a/app/Notifications/Test.php +++ b/app/Notifications/Test.php @@ -30,11 +30,6 @@ public function __construct(public ?string $emails = null, public ?string $chann $this->onQueue('high'); } - public function shouldDeduplicate(): bool - { - return false; - } - public function via(object $notifiable): array { if ($this->channel) { diff --git a/app/Notifications/TransactionalEmails/EmailChangeVerification.php b/app/Notifications/TransactionalEmails/EmailChangeVerification.php index bb5e7f870..ea8462366 100644 --- a/app/Notifications/TransactionalEmails/EmailChangeVerification.php +++ b/app/Notifications/TransactionalEmails/EmailChangeVerification.php @@ -25,16 +25,6 @@ public function __construct( $this->onQueue('high'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "email-change-verification:user:{$this->user->id}:email:{$this->newEmail}:code:{$this->verificationCode}"; - } - - public function deduplicateFor(): int - { - return (int) max(1, now()->diffInSeconds($this->expiresAt, false)); - } - public function toMail(): MailMessage { // Use the configured expiry minutes value diff --git a/app/Notifications/TransactionalEmails/InvitationLink.php b/app/Notifications/TransactionalEmails/InvitationLink.php index f3b1e6d67..9bfb54798 100644 --- a/app/Notifications/TransactionalEmails/InvitationLink.php +++ b/app/Notifications/TransactionalEmails/InvitationLink.php @@ -21,16 +21,6 @@ public function __construct(public User $user, public bool $isTransactionalEmail $this->onQueue('high'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "invitation-link:user:{$this->user->id}:email:{$this->user->email}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $invitation = TeamInvitation::whereEmail($this->user->email)->first(); diff --git a/app/Notifications/TransactionalEmails/Test.php b/app/Notifications/TransactionalEmails/Test.php index dc8c0dac7..2f7d70bbf 100644 --- a/app/Notifications/TransactionalEmails/Test.php +++ b/app/Notifications/TransactionalEmails/Test.php @@ -15,11 +15,6 @@ public function __construct(public string $emails, public bool $isTransactionalE $this->onQueue('high'); } - public function shouldDeduplicate(): bool - { - return false; - } - public function via(): array { return [EmailChannel::class]; diff --git a/app/Services/NotificationDeduplicator.php b/app/Services/NotificationDeduplicator.php deleted file mode 100644 index d018dd0a5..000000000 --- a/app/Services/NotificationDeduplicator.php +++ /dev/null @@ -1,95 +0,0 @@ - $recipients - */ - public function shouldSend(object $notifiable, Notification $notification, string $channel, array $recipients, ?string $subject = null, ?string $body = null): bool - { - if (method_exists($notification, 'shouldDeduplicate') && ! $notification->shouldDeduplicate()) { - return true; - } - - $ttl = method_exists($notification, 'deduplicateFor') - ? $notification->deduplicateFor() - : self::DEFAULT_TTL; - - if ($ttl <= 0) { - return true; - } - - return Cache::add( - $this->cacheKey($notifiable, $notification, $channel, $recipients, $subject, $body), - true, - $ttl, - ); - } - - /** - * @param array $recipients - */ - private function cacheKey(object $notifiable, Notification $notification, string $channel, array $recipients, ?string $subject, ?string $body): string - { - $semanticKey = method_exists($notification, 'deduplicationKey') - ? $notification->deduplicationKey($notifiable, $channel) - : null; - - $payload = $semanticKey - ? $this->semanticFingerprint($notifiable, $notification, $channel, $recipients, $semanticKey) - : $this->defaultFingerprint($notifiable, $notification, $channel, $recipients, $subject, $body); - - return 'notification-dedupe:'.hash('sha256', $payload); - } - - /** - * @param array $recipients - */ - private function semanticFingerprint(object $notifiable, Notification $notification, string $channel, array $recipients, string $semanticKey): string - { - return json_encode([ - 'notification' => $notification::class, - 'notifiable' => $notifiable::class, - 'notifiable_id' => data_get($notifiable, 'id'), - 'channel' => $channel, - 'recipients' => $this->normalizeRecipients($recipients), - 'semantic_key' => $semanticKey, - ], JSON_THROW_ON_ERROR); - } - - /** - * @param array $recipients - */ - private function defaultFingerprint(object $notifiable, Notification $notification, string $channel, array $recipients, ?string $subject, ?string $body): string - { - return json_encode([ - 'notification' => $notification::class, - 'notifiable' => $notifiable::class, - 'notifiable_id' => data_get($notifiable, 'id'), - 'channel' => $channel, - 'recipients' => $this->normalizeRecipients($recipients), - 'subject' => $subject, - 'body_hash' => hash('sha256', (string) $body), - ], JSON_THROW_ON_ERROR); - } - - /** - * @param array $recipients - * @return array - */ - private function normalizeRecipients(array $recipients): array - { - return collect($recipients) - ->map(fn (string $recipient) => mb_strtolower(trim($recipient))) - ->sort() - ->values() - ->all(); - } -} diff --git a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php index 482a3e16e..6b82d5568 100644 --- a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php +++ b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php @@ -48,24 +48,6 @@ function applicationWithRestartState(array $attributes = []): Application expect($html)->not->toContain('Stopped after reaching restart limit'); }); -it('uses a semantic dedupe key for restart limit notifications', function () { - $application = applicationWithRestartState(); - $application->forceFill([ - 'name' => 'crashy-app', - 'uuid' => 'application-uuid', - ]); - $application->setRelation('environment', (object) [ - 'uuid' => 'environment-uuid', - 'name' => 'production', - 'project' => (object) ['uuid' => 'project-uuid'], - ]); - - $notification = new RestartLimitReached($application); - - expect($notification->deduplicationKey((object) ['id' => 1], 'mail'))->toBe('restart-limit-reached:application:application-uuid:count:2') - ->and($notification->deduplicateFor())->toBe(86400); -}); - it('keeps restart tracking configurable when stopping an application', function () { $method = new ReflectionMethod(StopApplication::class, 'handle'); $resetRestartCount = collect($method->getParameters())->firstWhere('name', 'resetRestartCount'); diff --git a/tests/Feature/NotificationDeduplicationTest.php b/tests/Feature/NotificationDeduplicationTest.php deleted file mode 100644 index 39fc70433..000000000 --- a/tests/Feature/NotificationDeduplicationTest.php +++ /dev/null @@ -1,75 +0,0 @@ -subject('Test'); - } - - public function shouldDeduplicate(): bool - { - return $this->deduplicate; - } - - public function deduplicateFor(): int - { - return $this->ttl; - } - - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return $this->semanticKey; - } -} - -beforeEach(function () { - Cache::flush(); - $this->deduplicator = app(NotificationDeduplicator::class); - $this->notifiable = new class - { - public int $id = 123; - }; -}); - -it('allows only the first identical notification fingerprint during the ttl', function () { - $notification = new DedupeTestNotification; - - expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeFalse(); -}); - -it('allows different recipients and content through the default fingerprint', function () { - $notification = new DedupeTestNotification; - - expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Subject', '

Body

'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', '

Body

'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Other body

'))->toBeTrue(); -}); - -it('uses semantic keys instead of rendered content when provided', function () { - $notification = new DedupeTestNotification(semanticKey: 'event:123'); - - expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', '

Other body

'))->toBeFalse() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Other subject', '

Other body

'))->toBeTrue(); -}); - -it('allows notifications to opt out of deduplication', function () { - $notification = new DedupeTestNotification(deduplicate: false); - - expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeTrue(); -}); From bcadcc920083e5383869ce9bc3ca78ec927e3dcd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:47:38 +0200 Subject: [PATCH 192/211] docs(readme): serve sponsor images from Coollabs CDN --- README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 91458b703..ee4028d6a 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ ### Big Sponsors ### Small Sponsors -Movavi +Movavi ABXY LaunchFast Boilerplates Vanaways @@ -113,39 +113,39 @@ ### Small Sponsors MindEd Tech YouStable Transcript LOL -Autom -HuntAPI +Autom +HuntAPI ULTRASERVERS VibeTone -Piloterr +Piloterr Alexey Panteleev SummYT - YouTube Summarizer OpenElements Xaman Monadical Magic as a Service -FiveManage +FiveManage Crypto Jobs List SerpAPI typebot 360Creators Cap-go -Cirun +Cirun Puls Digital Group Jonathan Pereira -Internet Garden +Internet Garden Evercam -Web3 Jobs -LinkDr -Arvensis Systems -Reshot -RunPod +Web3 Jobs +LinkDr +Arvensis Systems +Reshot +RunPod Gravity Wiz UXWizz -Codext -InterviewPal +Codext +InterviewPal Decidable -Host Havoc +Host Havoc ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) From dd10a90d8c82d2eee20a772bea28df7d60310ed4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:48:06 +0200 Subject: [PATCH 193/211] fix(meta): update social preview image URL --- resources/views/layouts/base.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index be7b928ab..553248b60 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -22,13 +22,13 @@ - + - + @use('App\Models\InstanceSettings') @php From e4f925ebbfc52d75d9921d22781054576c7783ea Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:49:59 +0200 Subject: [PATCH 194/211] fix(meta): serve releases metadata from Coollabs CDN --- config/constants.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/constants.php b/config/constants.php index b9e3d600f..bf053fde3 100644 --- a/config/constants.php +++ b/config/constants.php @@ -16,7 +16,7 @@ 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), - 'releases_url' => env('RELEASES_URL', 'https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'), + 'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'), ], 'urls' => [ From d3fbb32c527bf880244330c3ce655ad8a672e935 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:29:11 +0200 Subject: [PATCH 195/211] feat(cdn): sync release metadata through BunnyCDN Replace the legacy sync:bunny flags with an interactive CDN sync flow for service templates and release metadata. Serve official service templates from the Coollabs CDN, update version metadata, and remove obsolete helper scripts. --- app/Console/Commands/SyncBunny.php | 321 +++++++++++++++--- .../Concerns/SummarizesDiffText.php | 2 +- config/constants.php | 4 +- other/nightly/versions.json | 4 +- scripts/conductor-setup.sh | 97 ------ scripts/sync_volume.sh | 57 ---- tests/Feature/PullChangelogTest.php | 2 +- tests/Feature/SyncBunnyTest.php | 232 ++++++++++--- .../ApplicationConfigurationSnapshotTest.php | 8 +- versions.json | 4 +- 10 files changed, 475 insertions(+), 256 deletions(-) delete mode 100755 scripts/conductor-setup.sh delete mode 100644 scripts/sync_volume.sh diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 3f3e213fd..55acf3828 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -5,9 +5,12 @@ use Illuminate\Console\Command; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Pool; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use function Laravel\Prompts\confirm; +use function Laravel\Prompts\multiselect; +use function Laravel\Prompts\select; class SyncBunny extends Command { @@ -16,7 +19,7 @@ class SyncBunny extends Command * * @var string */ - protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; + protected $signature = 'sync:bunny {--bunny}'; /** * The console command description. @@ -25,15 +28,234 @@ class SyncBunny extends Command */ protected $description = 'Sync files to BunnyCDN'; + protected function removeTemporaryDirectory(string $tmpDir): void + { + $temporaryRoot = realpath(sys_get_temp_dir()); + $temporaryDirectory = realpath($tmpDir); + + if ($temporaryRoot === false || $temporaryDirectory === false) { + return; + } + + $expectedPrefix = rtrim($temporaryRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'coollabs-cdn-'; + if (! str_starts_with($temporaryDirectory, $expectedPrefix)) { + return; + } + + File::deleteDirectory($temporaryDirectory); + } + + /** + * Fetch GitHub releases and sync to GitHub repository + */ + private function syncReleasesToGitHubRepo(array $files, bool $nightly = false): bool + { + $this->info('Fetching releases from GitHub...'); + try { + $response = Http::timeout(30) + ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ + 'per_page' => 30, // Fetch more releases for better changelog + ]); + + if (! $response->successful()) { + $this->error('Failed to fetch releases from GitHub: '.$response->status()); + + return false; + } + + $releasesFile = tempnam(sys_get_temp_dir(), 'coolify-releases-'); + if ($releasesFile === false || file_put_contents($releasesFile, json_encode($response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) { + $this->error('Failed to create temporary releases.json.'); + + return false; + } + + $files[$releasesFile] = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json'; + + try { + return $this->syncFilesToGitHubRepo($files, $nightly); + } finally { + @unlink($releasesFile); + } + } catch (\Throwable $e) { + $this->error('Error syncing releases: '.$e->getMessage()); + + return false; + } + } + + /** + * Sync install.sh, docker-compose, and env files to GitHub repository via PR + */ + private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool + { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("Syncing $envLabel files to GitHub repository..."); + try { + $timestamp = time(); + $tmpDir = sys_get_temp_dir().'/coollabs-cdn-files-'.$timestamp; + $branchName = 'update-files-'.$timestamp; + + // Clone the repository + $this->info('Cloning coollabs-cdn repository...'); + $output = []; + exec('gh repo clone coollabsio/coollabs-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to clone repository: '.implode("\n", $output)); + + return false; + } + + // Create feature branch + $this->info('Creating feature branch...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to create branch: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Copy each file to its target path in the CDN repo + $copiedFiles = []; + foreach ($files as $sourceFile => $targetPath) { + if (! file_exists($sourceFile)) { + $this->warn("Source file not found, skipping: $sourceFile"); + + continue; + } + + $destPath = "$tmpDir/$targetPath"; + $destDir = dirname($destPath); + + if (! is_dir($destDir)) { + if (! mkdir($destDir, 0755, true)) { + $this->error("Failed to create directory: $destDir"); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + } + + if (copy($sourceFile, $destPath) === false) { + $this->error("Failed to copy $sourceFile to $destPath"); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + $copiedFiles[] = $targetPath; + $this->info("Copied: $targetPath"); + } + + if (empty($copiedFiles)) { + $this->warn('No files were copied. Nothing to commit.'); + $this->removeTemporaryDirectory($tmpDir); + + return true; + } + + // Stage all copied files + $this->info('Staging changes...'); + $output = []; + $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1'; + exec($stageCmd, $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to stage changes: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Check for changes + $this->info('Checking for changes...'); + $changedFiles = []; + exec('cd '.escapeshellarg($tmpDir).' && git diff --cached --name-only 2>&1', $changedFiles, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to check changed files: '.implode("\n", $changedFiles)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + $changedFiles = array_values(array_filter($changedFiles)); + if (empty($changedFiles)) { + $this->info('All files are already up to date. No changes to commit.'); + $this->removeTemporaryDirectory($tmpDir); + + return true; + } + + // Commit changes + $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to commit changes: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Push to remote + $this->info('Pushing branch to remote...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to push branch: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Create pull request + $this->info('Creating pull request...'); + $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s'); + $fileList = implode("\n- ", $changedFiles); + $prBody = "Automated update of $envLabel files:\n- $fileList"; + $prCommand = 'gh pr create --repo coollabsio/coollabs-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; + $output = []; + exec($prCommand, $output, $returnCode); + + // Clean up + $this->removeTemporaryDirectory($tmpDir); + + if ($returnCode !== 0) { + $this->error('Failed to create PR: '.implode("\n", $output)); + + return false; + } + + $this->info('Pull request created successfully!'); + if (! empty($output)) { + $this->info('PR URL: '.implode("\n", $output)); + } + $this->info('Files synced: '.count($changedFiles)); + + return true; + } catch (\Throwable $e) { + $this->error('Error syncing files to GitHub: '.$e->getMessage()); + + return false; + } + } + /** * Execute the console command. */ public function handle() { $that = $this; - $only_template = $this->option('templates'); - $only_version = $this->option('release'); - $nightly = $this->option('nightly'); + $only_bunny = $this->option('bunny'); + $nightly = select( + label: 'Which environment would you like to sync?', + options: [ + 'production' => 'Production', + 'nightly' => 'Nightly', + ], + default: 'production', + ) === 'nightly'; $bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn_path = 'coolify'; $bunny_cdn_storage_name = 'coolcdn'; @@ -55,6 +277,7 @@ public function handle() $upgrade_script_location = "$parent_dir/scripts/upgrade.sh"; $upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh"; $production_env_location = "$parent_dir/.env.production"; + $service_template_location = "$parent_dir/templates/$service_template"; $versions_location = "$parent_dir/$versions"; PendingRequest::macro('storage', function ($fileName) use ($that) { @@ -93,7 +316,7 @@ public function handle() $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - if (! $only_template && ! $only_version) { + if ($only_bunny) { $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; $this->info("About to sync $envLabel files to BunnyCDN."); $this->newLine(); @@ -108,7 +331,7 @@ public function handle() $install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script", ]; - $diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time(); + $diffTmpDir = sys_get_temp_dir().'/coollabs-cdn-diff-'.time(); @mkdir($diffTmpDir, 0755, true); $hasChanges = false; @@ -151,7 +374,7 @@ public function handle() } } - exec('rm -rf '.escapeshellarg($diffTmpDir)); + $this->removeTemporaryDirectory($diffTmpDir); if (! $hasChanges) { $this->newLine(); @@ -167,49 +390,55 @@ public function handle() return; } } - if ($only_template) { - $this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.'); - $confirmed = confirm('Are you sure you want to sync?'); - if (! $confirmed) { - return; - } - Http::pool(fn (Pool $pool) => [ - $pool->storage(fileName: "$parent_dir/templates/$service_template")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$service_template"), - $pool->purge("$bunny_cdn/$bunny_cdn_path/$service_template"), - ]); - $this->info('Service template uploaded & purged...'); + if (! $only_bunny) { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("About to sync $envLabel releases, versions, compose, and environment files to GitHub repository."); - return; - } elseif ($only_version) { if ($nightly) { - $this->info('About to sync NIGHTLY versions.json to BunnyCDN.'); + $files = [ + $versions_location => 'json/coolify/nightly/versions.json', + $compose_file_location => 'json/coolify/nightly/docker-compose.yml', + $compose_file_prod_location => 'json/coolify/nightly/docker-compose.prod.yml', + $production_env_location => 'json/coolify/nightly/.env.production', + $install_script_location => 'json/coolify/nightly/install.sh', + $upgrade_script_location => 'json/coolify/nightly/upgrade.sh', + $upgrade_postgres_script_location => 'json/coolify/nightly/upgrade-postgres.sh', + $service_template_location => 'json/coolify/nightly/service-templates-latest.json', + ]; } else { - $this->info('About to sync PRODUCTION versions.json to BunnyCDN.'); - } - $file = file_get_contents($versions_location); - $json = json_decode($file, true); - $actual_version = data_get($json, 'coolify.v4.version'); - - $this->info("Version: {$actual_version}"); - $this->info('This will:'); - $this->info(' 1. Sync versions.json to BunnyCDN'); - $this->newLine(); - - $confirmed = confirm('Are you sure you want to proceed?'); - if (! $confirmed) { - return; + $files = [ + $versions_location => 'json/coolify/versions.json', + $compose_file_location => 'json/coolify/docker-compose.yml', + $compose_file_prod_location => 'json/coolify/docker-compose.prod.yml', + $production_env_location => 'json/coolify/.env.production', + $install_script_location => 'json/coolify/install.sh', + $upgrade_script_location => 'json/coolify/upgrade.sh', + $upgrade_postgres_script_location => 'json/coolify/upgrade-postgres.sh', + $service_template_location => 'json/coolify/service-templates-latest.json', + ]; } - $this->info('Syncing versions.json to BunnyCDN...'); - Http::pool(fn (Pool $pool) => [ - $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), - $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), - ]); - $this->info('✓ versions.json uploaded & purged to BunnyCDN'); - $this->newLine(); + $releasesTarget = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json'; + $options = [$releasesTarget, ...array_values($files)]; + $selectedFiles = multiselect( + label: 'Which files would you like to sync?', + options: $options, + default: $options, + required: true, + scroll: count($options), + ); - $this->info('=== Summary ==='); - $this->info('BunnyCDN sync: ✓ Complete'); + $includeReleases = in_array($releasesTarget, $selectedFiles, true); + $files = array_filter( + $files, + fn (string $targetPath) => in_array($targetPath, $selectedFiles, true), + ); + + if ($includeReleases) { + $this->syncReleasesToGitHubRepo($files, $nightly); + } else { + $this->syncFilesToGitHubRepo($files, $nightly); + } return; } @@ -231,10 +460,6 @@ public function handle() $pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"), ]); $this->info('All files uploaded & purged to BunnyCDN.'); - $this->newLine(); - - $this->info('=== Summary ==='); - $this->info('BunnyCDN sync: Complete'); } catch (\Throwable $e) { $this->error('Error: '.$e->getMessage()); } diff --git a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php index 6960a8f1b..8eedf0920 100644 --- a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php +++ b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php @@ -9,7 +9,7 @@ trait SummarizesDiffText * worth expanding. Kept as one constant so the snapshot summary and the * differ's expand decision never drift apart. */ - private const SINGLE_LINE_LIMIT = 120; + private const SINGLE_LINE_LIMIT = 40; /** * Returns the value only when it is worth expanding (multi-line or longer diff --git a/config/constants.php b/config/constants.php index bf053fde3..290ce3f95 100644 --- a/config/constants.php +++ b/config/constants.php @@ -25,9 +25,7 @@ ], 'services' => [ - // Temporary disabled until cache is implemented - // 'official' => 'https://cdn.coollabs.io/coolify/service-templates.json', - 'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/v4.x/templates/service-templates-latest.json', + 'official' => 'https://cdn.coollabs.io/coolify/service-templates-latest.json', 'file_name' => 'service-templates-latest.json', ], diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 751db0754..9c9a405aa 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.2.0" + "version": "4.1.2" }, "nightly": { - "version": "4.2.1" + "version": "4.2.0" }, "helper": { "version": "1.0.14" diff --git a/scripts/conductor-setup.sh b/scripts/conductor-setup.sh deleted file mode 100755 index a88b457fb..000000000 --- a/scripts/conductor-setup.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/bash -set -e - -# Validate CONDUCTOR_ROOT_PATH is set and valid before any operations -if [ -z "$CONDUCTOR_ROOT_PATH" ]; then - echo "ERROR: CONDUCTOR_ROOT_PATH environment variable is not set" - echo "This script must be run by Conductor with CONDUCTOR_ROOT_PATH set to the main repository path" - exit 1 -fi - -if [ ! -d "$CONDUCTOR_ROOT_PATH" ]; then - echo "ERROR: CONDUCTOR_ROOT_PATH ($CONDUCTOR_ROOT_PATH) is not a valid directory" - exit 1 -fi - -# Copy .env file -cp "$CONDUCTOR_ROOT_PATH/.env" .env - -# Setup shared dependencies via symlinks to main repo -echo "Setting up shared node_modules and vendor directories..." - -# Ensure main repo has the directories -mkdir -p "$CONDUCTOR_ROOT_PATH/node_modules" -mkdir -p "$CONDUCTOR_ROOT_PATH/vendor" - -# Get current worktree path -WORKTREE_PATH=$(pwd) - -# Safety check 1: ensure WORKTREE_PATH is valid -if [ -z "$WORKTREE_PATH" ]; then - echo "ERROR: WORKTREE_PATH is empty" - exit 1 -fi - -# Safety check 2: CRITICAL FIRST - blacklist system directories -# This check runs BEFORE the positive check to prevent dangerous operations -# even if someone misconfigures CONDUCTOR_ROOT_PATH -case "$WORKTREE_PATH" in - /|/bin|/sbin|/usr|/usr/*|/etc|/etc/*|/var|/var/*|/System|/System/*|/Library|/Library/*|/Applications|/Applications/*|"$HOME") - echo "ERROR: WORKTREE_PATH ($WORKTREE_PATH) is in a dangerous system location" - exit 1 - ;; -esac - -# Safety check 3: positive check - verify we're under CONDUCTOR_ROOT_PATH -case "$WORKTREE_PATH" in - "$CONDUCTOR_ROOT_PATH"|"$CONDUCTOR_ROOT_PATH"/.conductor/*) - # Valid: either main repo or under .conductor/ - ;; - *) - echo "ERROR: WORKTREE_PATH ($WORKTREE_PATH) is not under CONDUCTOR_ROOT_PATH ($CONDUCTOR_ROOT_PATH)" - exit 1 - ;; -esac - -# Safety check 4: verify we're in a git repository -if [ ! -f ".git" ] && [ ! -d ".git" ]; then - echo "ERROR: Not in a git repository" - exit 1 -fi - -# Remove existing directories/symlinks if they exist -# For symlinks: use 'rm' without -r to remove the symlink itself (not following it) -# For directories: use 'rm -rf' to remove the directory and contents -if [ -L "node_modules" ]; then - # It's a symlink - remove it without following (no -r flag) - rm "$WORKTREE_PATH/node_modules" -elif [ -e "node_modules" ]; then - # It's a regular directory or file - safe to use -rf - rm -rf "$WORKTREE_PATH/node_modules" -fi - -if [ -L "vendor" ]; then - # It's a symlink - remove it without following (no -r flag) - rm "$WORKTREE_PATH/vendor" -elif [ -e "vendor" ]; then - # It's a regular directory or file - safe to use -rf - rm -rf "$WORKTREE_PATH/vendor" -fi - -# Calculate relative path from worktree to main repo -# Use bash-native approach: try realpath first (GNU coreutils), fallback to perl -if command -v realpath &> /dev/null && realpath --relative-to / / &> /dev/null 2>&1; then - # GNU coreutils realpath with --relative-to support - RELATIVE_PATH=$(realpath --relative-to="$WORKTREE_PATH" "$CONDUCTOR_ROOT_PATH") -else - # Fallback: use perl which is standard on macOS and most Unix systems - RELATIVE_PATH=$(perl -e 'use File::Spec; print File::Spec->abs2rel($ARGV[0], $ARGV[1])' "$CONDUCTOR_ROOT_PATH" "$WORKTREE_PATH") -fi - -# Create symlinks to main repo's node_modules and vendor -ln -sf "$RELATIVE_PATH/node_modules" node_modules -ln -sf "$RELATIVE_PATH/vendor" vendor - -echo "✓ Shared dependencies linked successfully" -echo " node_modules -> $RELATIVE_PATH/node_modules" -echo " vendor -> $RELATIVE_PATH/vendor" \ No newline at end of file diff --git a/scripts/sync_volume.sh b/scripts/sync_volume.sh deleted file mode 100644 index 43631fdf7..000000000 --- a/scripts/sync_volume.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash -# Sync docker volumes between two servers - -VERSION="1.0.0" -SOURCE=$1 -DESTINATION=$2 -set -e -if [ -z "$SOURCE" ]; then - echo "Source server is not specified." - exit 1 -fi -if [ -z "$DESTINATION" ]; then - echo "Destination server is not specified." - exit 1 -fi - -SOURCE_USER=$(echo $SOURCE | cut -d@ -f1) -SOURCE_SERVER=$(echo $SOURCE | cut -d: -f1 | cut -d@ -f2) -SOURCE_PORT=$(echo $SOURCE | cut -d: -f2 | cut -d/ -f1) -SOURCE_VOLUME_NAME=$(echo $SOURCE | cut -d/ -f2) - -if ! [[ "$SOURCE_PORT" =~ ^[0-9]+$ ]]; then - echo "Invalid source port: $SOURCE_PORT" - exit 1 -fi - -DESTINATION_USER=$(echo $DESTINATION | cut -d@ -f1) -DESTINATION_SERVER=$(echo $DESTINATION | cut -d: -f1 | cut -d@ -f2) -DESTINATION_PORT=$(echo $DESTINATION | cut -d: -f2 | cut -d/ -f1) -DESTINATION_VOLUME_NAME=$(echo $DESTINATION | cut -d/ -f2) - -if ! [[ "$DESTINATION_PORT" =~ ^[0-9]+$ ]]; then - echo "Invalid destination port: $DESTINATION_PORT" - exit 1 -fi - -echo "Generating backup file to ./$SOURCE_VOLUME_NAME.tgz" -ssh -p $SOURCE_PORT $SOURCE_USER@$SOURCE_SERVER "docker run -v $SOURCE_VOLUME_NAME:/volume --rm --log-driver none loomchild/volume-backup backup -c pigz -v" >./$SOURCE_VOLUME_NAME.tgz -echo "" -if [ -f "./$SOURCE_VOLUME_NAME.tgz" ]; then - echo "Uploading backup file to $DESTINATION_SERVER:~/$DESTINATION_VOLUME_NAME.tgz" - scp -P $DESTINATION_PORT ./$SOURCE_VOLUME_NAME.tgz $DESTINATION_USER@$DESTINATION_SERVER:~/$DESTINATION_VOLUME_NAME.tgz - echo "" - echo "Restoring backup file on remote ($DESTINATION_SERVER:/~/$DESTINATION_VOLUME_NAME.tgz)" - ssh -p $DESTINATION_PORT $DESTINATION_USER@$DESTINATION_SERVER "docker run -i -v $DESTINATION_VOLUME_NAME:/volume --log-driver none --rm loomchild/volume-backup restore -c pigz -vf < ~/$DESTINATION_VOLUME_NAME.tgz" - echo "" - echo "Deleting backup file on remote ($DESTINATION_SERVER:/~/$DESTINATION_VOLUME_NAME.tgz)" - ssh -p $DESTINATION_PORT $DESTINATION_USER@$DESTINATION_SERVER "rm ~/$DESTINATION_VOLUME_NAME.tgz" - - echo "" - echo "Local file ./$SOURCE_VOLUME_NAME.tgz is not deleted." - - echo "" - echo "WARNING: If you are copying a database volume, you need to set the right users/passwords on the destination service's environment variables." - echo "Why? Because we are copying the volume as-is, so the database credentials will bethe same as on the source volume." -fi - diff --git a/tests/Feature/PullChangelogTest.php b/tests/Feature/PullChangelogTest.php index 145638812..7793b0b77 100644 --- a/tests/Feature/PullChangelogTest.php +++ b/tests/Feature/PullChangelogTest.php @@ -34,7 +34,7 @@ function fakeReleasesPayload(): array test('releases_url config defaults to the GitHub raw source', function () { expect(config('constants.coolify.releases_url')) - ->toBe('https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'); + ->toBe('https://cdn.coollabs.io/coolify/service-templates-latest.json'); }); test('PullChangelog fetches from the configured releases_url and writes the changelog', function () { diff --git a/tests/Feature/SyncBunnyTest.php b/tests/Feature/SyncBunnyTest.php index ca3091841..9f4badad3 100644 --- a/tests/Feature/SyncBunnyTest.php +++ b/tests/Feature/SyncBunnyTest.php @@ -1,63 +1,107 @@ > "$SYNC_BUNNY_TEST_LOG" -exit 1 -SH); + file_put_contents("{$binDir}/{$name}", $contents); chmod("{$binDir}/{$name}", 0755); } -it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function () { - Http::fake([ - 'storage.bunnycdn.com/*' => Http::response([], 201), - 'api.bunny.net/purge*' => Http::response([], 200), - ]); +it('only exposes the BunnyCDN legacy sync option', function () { + $definition = Artisan::all()['sync:bunny']->getDefinition(); - $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid(); - $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log'; - - mkdir($binDir, 0755, true); - createSyncBunnyFailingBinary($binDir, 'gh'); - createSyncBunnyFailingBinary($binDir, 'git'); - - $originalPath = getenv('PATH') ?: ''; - putenv("PATH={$binDir}:{$originalPath}"); - putenv("SYNC_BUNNY_TEST_LOG={$logFile}"); - - try { - $this->artisan('sync:bunny --release --nightly') - ->expectsConfirmation('Are you sure you want to proceed?', 'yes') - ->expectsOutputToContain('BunnyCDN sync: ✓ Complete') - ->doesntExpectOutputToContain('GitHub PR') - ->assertExitCode(0); - } finally { - putenv("PATH={$originalPath}"); - putenv('SYNC_BUNNY_TEST_LOG'); - } - - expect(file_exists($logFile))->toBeFalse(); - - Http::assertSent(fn ($request) => $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify-nightly/versions.json'); - Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge') - && $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json'); + expect($definition->hasOption('bunny'))->toBeTrue() + ->and($definition->hasOption('github-releases'))->toBeFalse() + ->and($definition->hasOption('release'))->toBeFalse() + ->and($definition->hasOption('nightly'))->toBeFalse() + ->and($definition->hasOption('templates'))->toBeFalse(); }); -it('syncs postgres upgrade script to BunnyCDN during full sync', function () { +it('loads service templates from the Coollabs CDN', function () { + expect(config('constants.services.official')) + ->toBe('https://cdn.coollabs.io/coolify/service-templates-latest.json'); +}); + +it('only removes validated Coolify CDN temporary directories', function () { + $command = new class extends SyncBunny + { + public function removeDirectory(string $path): void + { + $this->removeTemporaryDirectory($path); + } + }; + + $invalidDirectory = sys_get_temp_dir().'/unrelated-directory-'.uniqid(); + $validDirectory = sys_get_temp_dir().'/coollabs-cdn-files-'.uniqid(); + mkdir($invalidDirectory); + mkdir($validDirectory); + + $command->removeDirectory(''); + $command->removeDirectory($invalidDirectory); + $command->removeDirectory($validDirectory); + + expect($invalidDirectory)->toBeDirectory() + ->and($validDirectory)->not->toBeDirectory(); + + rmdir($invalidDirectory); +}); + +it('syncs full files to BunnyCDN only when explicitly requested', function () { Http::fake([ 'https://cdn.coollabs.io/coolify/*' => Http::response('', 404), 'https://storage.bunnycdn.com/*' => Http::response([], 201), 'https://api.bunny.net/purge*' => Http::response([], 200), ]); - $this->artisan('sync:bunny') - ->expectsConfirmation('Are you sure you want to sync?', 'yes') - ->expectsOutputToContain('BunnyCDN sync: Complete') - ->assertExitCode(0); + $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid(); + $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log'; + + mkdir($binDir, 0755, true); + + createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH' +#!/bin/sh +printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" +if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then + mkdir -p "$4/scripts" +fi +exit 0 +SH); + + createFakeSyncBunnyBinary($binDir, 'git', <<<'SH' +#!/bin/sh +printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" +if [ "$1" = "status" ]; then + printf 'M scripts/upgrade-postgres.sh\n' +fi +exit 0 +SH); + + $originalPath = getenv('PATH') ?: ''; + putenv("PATH={$binDir}:{$originalPath}"); + putenv("SYNC_BUNNY_TEST_LOG={$logFile}"); + + try { + $this->artisan('sync:bunny --bunny') + ->expectsChoice('Which environment would you like to sync?', 'production', [ + 'production' => 'Production', + 'nightly' => 'Nightly', + ]) + ->expectsConfirmation('Are you sure you want to sync?', 'yes') + ->assertExitCode(0); + } finally { + putenv("PATH={$originalPath}"); + putenv('SYNC_BUNNY_TEST_LOG'); + } + + $log = file_exists($logFile) ? file_get_contents($logFile) : ''; + + expect($log) + ->not->toContain('gh repo clone') + ->not->toContain('gh pr create') + ->not->toContain('coollabsio/coolify-cdn'); Http::assertSent(fn ($request) => $request->method() === 'PUT' && $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify/upgrade-postgres.sh'); @@ -65,3 +109,105 @@ function createSyncBunnyFailingBinary(string $binDir, string $name): void Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge') && $request['url'] === 'https://cdn.coollabs.io/coolify/upgrade-postgres.sh'); }); + +it('selects the environment and release files to sync to GitHub', function (string $targetDirectory, string $environment, array $selectedBasenames) { + Http::fake([ + 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([], 200), + ]); + + $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid(); + $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log'; + + mkdir($binDir, 0755, true); + + createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH' +#!/bin/sh +printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" +if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then + mkdir -p "$4" +fi +exit 0 +SH); + + createFakeSyncBunnyBinary($binDir, 'git', <<<'SH' +#!/bin/sh +printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" +if [ "$1" = "status" ]; then + printf 'M json/releases.json\n' +fi +if [ "$1" = "diff" ]; then + if [ -f json/coolify/nightly/releases.json ]; then + printf 'json/coolify/nightly/releases.json\n' + else + printf 'json/coolify/releases.json\n' + fi +fi +exit 0 +SH); + + $originalPath = getenv('PATH') ?: ''; + putenv("PATH={$binDir}:{$originalPath}"); + putenv("SYNC_BUNNY_TEST_LOG={$logFile}"); + + $allBasenames = [ + 'releases.json', + 'versions.json', + 'docker-compose.yml', + 'docker-compose.prod.yml', + '.env.production', + 'install.sh', + 'upgrade.sh', + 'upgrade-postgres.sh', + 'service-templates-latest.json', + ]; + $allTargets = array_map(fn (string $file) => "$targetDirectory/$file", $allBasenames); + $selectedTargets = array_map(fn (string $file) => "$targetDirectory/$file", $selectedBasenames); + + try { + $this->artisan('sync:bunny') + ->expectsChoice('Which environment would you like to sync?', $environment, [ + 'production' => 'Production', + 'nightly' => 'Nightly', + ]) + ->expectsChoice('Which files would you like to sync?', $selectedTargets, $allTargets) + ->assertExitCode(0); + } finally { + putenv("PATH={$originalPath}"); + putenv('SYNC_BUNNY_TEST_LOG'); + } + + $log = file_get_contents($logFile); + + expect($log) + ->toContain('gh pr create --repo coollabsio/coollabs-cdn') + ->not->toContain('coollabsio/coolify-cdn'); + + foreach ($selectedTargets as $selectedTarget) { + expect($log)->toContain($selectedTarget); + } + + foreach (array_diff($allTargets, $selectedTargets) as $unselectedTarget) { + expect($log)->not->toContain($unselectedTarget); + } + + $pullRequestCommand = substr($log, strrpos($log, 'gh pr create')); + + expect($pullRequestCommand) + ->toContain("$targetDirectory/releases.json") + ->not->toContain("$targetDirectory/versions.json"); + + Http::assertSentCount(1); +})->with([ + 'select production files' => ['json/coolify', 'production', ['releases.json', 'versions.json']], + 'select nightly with all files selected by default' => ['json/coolify/nightly', 'nightly', [ + 'releases.json', + 'versions.json', + 'docker-compose.yml', + 'docker-compose.prod.yml', + '.env.production', + 'install.sh', + 'upgrade.sh', + 'upgrade-postgres.sh', + 'service-templates-latest.json', + ]], +]); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index 20b7c0adc..c6c823633 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -66,12 +66,16 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat $application = snapshotTestApplication(); markSnapshotTestApplicationDeployed($application); - $application->update(['fqdn' => 'https://new.example.com']); + $domains = 'https://new.example.com,https://another.example.com'; + $application->update(['fqdn' => $domains]); $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + $change = collect($diff->changes())->firstWhere('label', 'Domains'); expect($diff->isChanged())->toBeTrue() ->and($diff->requiresBuild())->toBeFalse() - ->and(collect($diff->changes())->pluck('label'))->toContain('Domains'); + ->and($change)->not->toBeNull() + ->and($change['expandable'])->toBeTrue() + ->and($change['new_full_value'])->toBe($domains); }); it('detects environment variable value changes without exposing secret values', function () { diff --git a/versions.json b/versions.json index 751db0754..9c9a405aa 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.2.0" + "version": "4.1.2" }, "nightly": { - "version": "4.2.1" + "version": "4.2.0" }, "helper": { "version": "1.0.14" 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 196/211] 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 197/211] 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 198/211] 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 199/211] 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 200/211] 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 201/211] 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 202/211] 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 203/211] 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 204/211] 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 205/211] 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 206/211] 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 207/211] 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 208/211] 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 @@