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 01/81] 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 @@ class DatabasesController extends Controller 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::group([ 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 02/81] 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 @@ class ServicesController extends Controller 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::group([ 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 03/81] 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 @@ class ServicesController extends Controller #[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 @@ class ServicesController extends Controller ) ), 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 @@ class ServicesController extends Controller 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 @@ class ServicesController extends Controller ], 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::group([ 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 04/81] 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 @@ class ServicesController extends Controller 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 05/81] 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 @@ class ApplicationsController extends Controller 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 @@ class ApplicationsController extends Controller ], 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 @@ class DatabasesController extends Controller 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 @@ class DatabasesController extends Controller ], 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 @@ class ServicesController extends Controller 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 @@ class ServicesController extends Controller ], 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 63008fceb3d74643e8508e0856394804e06ce961 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 15:15:00 +0100 Subject: [PATCH 06/81] 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 @@ class Environment extends BaseModel 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 07/81] 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 08/81] 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 @@ class ApplicationsController extends Controller ); } + #[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 @@ class DatabasesController extends Controller ]); } + #[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 @@ class ServicesController extends Controller 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::group([ 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::group([ 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::group([ 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 09/81] 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 10/81] 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 @@ use Illuminate\Foundation\Testing\RefreshDatabase; 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 @@ beforeEach(function () { $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/applications/{uuid}/move', function () { 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 11/81] 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 @@ class ServiceApplicationPolicy */ 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\ResourcesController; 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::group([ 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 12/81] 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 @@ use Visus\Cuid2\Cuid2; 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 @@ class ApplicationsController extends Controller '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 @@ class ApplicationsController extends Controller '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 @@ class ApplicationsController extends Controller '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 @@ class ApplicationsController extends Controller '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 @@ class ApplicationsController extends Controller '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 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', '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 @@ class ApplicationsController extends Controller '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 @@ class ApplicationsController extends Controller $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 @@ class ApplicationsController extends Controller $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 @@ class ApplicationsController extends Controller $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 @@ class ApplicationsController extends Controller $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 @@ class ApplicationsController extends Controller $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 @@ class ApplicationsController extends Controller '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 @@ class ApplicationsController extends Controller // 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 @@ class ApplicationsController extends Controller 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 @@ use OpenApi\Attributes as OA; 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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller } } 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 @@ class DatabasesController extends Controller $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 @@ class DatabasesController extends Controller 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 @@ class DatabasesController extends Controller $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 @@ class DatabasesController extends Controller 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 @@ class DatabasesController extends Controller $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 @@ class DatabasesController extends Controller 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 @@ class DatabasesController extends Controller $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 @@ class DatabasesController extends Controller 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 @@ class DatabasesController extends Controller 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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller $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 @@ class DatabasesController extends Controller 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 @@ class DatabasesController extends Controller } 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 @@ class DatabasesController extends Controller 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 @@ class DatabasesController extends Controller $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 @@ class DatabasesController extends Controller 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 @@ use Symfony\Component\Yaml\Yaml; 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 @@ class ServicesController extends Controller ], '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 @@ class ServicesController extends Controller )] 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 @@ class ServicesController extends Controller '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 @@ class ServicesController extends Controller } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -494,7 +513,7 @@ class ServicesController extends Controller 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 @@ class ServicesController extends Controller } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -2458,4 +2481,148 @@ class ServicesController extends Controller 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\ScheduledTasksController; 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::group([ 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::group([ 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::group([ 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::group([ 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 @@ 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 @@ @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 @@ test('github app badge appears before the save button in the title row', functio ->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 74/81] 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 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue $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 @@ it('skips a synchronized GitHub pull request preview when the head commit messag 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 75/81] 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 @@ it('skips a synchronized GitHub pull request preview when the head commit messag 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 76/81] 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 @@ class ApiTokenExpiringNotification extends CustomEmailNotification 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 @@ class DeploymentFailed extends CustomEmailNotification 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 @@ class DeploymentSuccess extends CustomEmailNotification 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 @@ class RestartLimitReached extends CustomEmailNotification 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 @@ class StatusChanged extends CustomEmailNotification 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 @@ class StatusChanged extends CustomEmailNotification $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 @@ namespace App\Notifications\Channels; 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 @@ class EmailChannel } $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 @@ class EmailChannel '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 @@ class EmailChannel $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 @@ class EmailChannel // 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 @@ use Illuminate\Support\Facades\Mail; class TransactionalEmailChannel { + public function __construct(private NotificationDeduplicator $deduplicator) {} + public function send(User $notifiable, Notification $notification): void { $settings = instanceSettings(); @@ -27,13 +30,19 @@ class TransactionalEmailChannel } $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 @@ class ContainerRestarted extends CustomEmailNotification 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 @@ class ContainerStopped extends CustomEmailNotification 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 @@ use Illuminate\Notifications\Messages\MailMessage; 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 @@ class BackupFailed extends CustomEmailNotification 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 @@ use Illuminate\Notifications\Messages\MailMessage; 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 @@ class BackupSuccess extends CustomEmailNotification 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 @@ use Illuminate\Notifications\Messages\MailMessage; 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 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification 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 @@ class TaskFailed extends CustomEmailNotification 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 @@ class TaskSuccess extends CustomEmailNotification 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 @@ class DockerCleanupFailed extends CustomEmailNotification 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 @@ class DockerCleanupSuccess extends CustomEmailNotification 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 @@ class ForceDisabled extends CustomEmailNotification 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 @@ class ForceEnabled extends CustomEmailNotification 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 @@ class HetznerDeletionFailed extends CustomEmailNotification 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 @@ class HighDiskUsage extends CustomEmailNotification 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 @@ class Reachable extends CustomEmailNotification 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 @@ class ServerPatchCheck extends CustomEmailNotification 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 @@ class TraefikVersionOutdated extends CustomEmailNotification 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 @@ class Unreachable extends CustomEmailNotification 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 @@ class SslExpirationNotification extends CustomEmailNotification 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 @@ class Test extends Notification implements ShouldQueue $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 @@ class EmailChangeVerification extends CustomEmailNotification $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 @@ class InvitationLink extends CustomEmailNotification $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 @@ class Test extends CustomEmailNotification $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 @@ it('does not show the restart limit warning for a normal manual stop', function 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 77/81] 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 @@ class ApiTokenExpiringNotification extends CustomEmailNotification 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 @@ class DeploymentFailed extends CustomEmailNotification 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 @@ class DeploymentSuccess extends CustomEmailNotification 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 @@ class RestartLimitReached extends CustomEmailNotification 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 @@ class StatusChanged extends CustomEmailNotification 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 @@ class StatusChanged extends CustomEmailNotification $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 @@ namespace App\Notifications\Channels; 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 @@ class EmailChannel } $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 @@ class EmailChannel '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 @@ class EmailChannel $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 @@ class EmailChannel // 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 @@ use Illuminate\Support\Facades\Mail; class TransactionalEmailChannel { - public function __construct(private NotificationDeduplicator $deduplicator) {} - public function send(User $notifiable, Notification $notification): void { $settings = instanceSettings(); @@ -30,19 +27,13 @@ class TransactionalEmailChannel } $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 @@ class ContainerRestarted extends CustomEmailNotification 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 @@ class ContainerStopped extends CustomEmailNotification 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 @@ use Illuminate\Notifications\Messages\MailMessage; 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 @@ class BackupFailed extends CustomEmailNotification 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 @@ use Illuminate\Notifications\Messages\MailMessage; 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 @@ class BackupSuccess extends CustomEmailNotification 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 @@ use Illuminate\Notifications\Messages\MailMessage; 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 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification 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 @@ class TaskFailed extends CustomEmailNotification 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 @@ class TaskSuccess extends CustomEmailNotification 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 @@ class DockerCleanupFailed extends CustomEmailNotification 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 @@ class DockerCleanupSuccess extends CustomEmailNotification 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 @@ class ForceDisabled extends CustomEmailNotification 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 @@ class ForceEnabled extends CustomEmailNotification 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 @@ class HetznerDeletionFailed extends CustomEmailNotification 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 @@ class HighDiskUsage extends CustomEmailNotification 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 @@ class Reachable extends CustomEmailNotification 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 @@ class ServerPatchCheck extends CustomEmailNotification 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 @@ class TraefikVersionOutdated extends CustomEmailNotification 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 @@ class Unreachable extends CustomEmailNotification 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 @@ class SslExpirationNotification extends CustomEmailNotification 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 @@ class Test extends Notification implements ShouldQueue $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 @@ class EmailChangeVerification extends CustomEmailNotification $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 @@ class InvitationLink extends CustomEmailNotification $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 @@ class Test extends CustomEmailNotification $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 @@ it('does not show the restart limit warning for a normal manual stop', function 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 78/81] 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 @@ Thank you so much! ### Small Sponsors -Movavi +Movavi ABXY LaunchFast Boilerplates Vanaways @@ -113,39 +113,39 @@ Thank you so much! 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 79/81] 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 80/81] 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 @@ return [ '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 81/81] 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 @@ namespace App\Console\Commands; 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 @@ class SyncBunny extends Command $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 @@ class SyncBunny extends Command $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 @@ class SyncBunny extends Command $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 @@ class SyncBunny extends Command } } - exec('rm -rf '.escapeshellarg($diffTmpDir)); + $this->removeTemporaryDirectory($diffTmpDir); if (! $hasChanges) { $this->newLine(); @@ -167,49 +390,55 @@ class SyncBunny extends Command 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 @@ class SyncBunny extends Command $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 @@ return [ ], '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 @@ afterEach(function () { 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 @@ it('syncs postgres upgrade script to BunnyCDN during full sync', function () { 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 @@ it('detects redeploy-only domain changes', function () { $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"