From 63008fceb3d74643e8508e0856394804e06ce961 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 15:15:00 +0100 Subject: [PATCH 01/21] feat(api): add ownedByCurrentTeamAPI scope to Environment model --- app/Models/Environment.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Models/Environment.php b/app/Models/Environment.php index d4e614e6e..ae027a585 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -42,6 +42,11 @@ public static function ownedByCurrentTeam() return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name'); } + public static function ownedByCurrentTeamAPI(int $teamId) + { + return Environment::whereRelation('project.team', 'id', $teamId)->orderBy('name'); + } + public function isEmpty() { return $this->applications()->count() == 0 && From d8178df838f041c8ebe3a920ed6a2d71e1d6f879 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 15:48:00 +0100 Subject: [PATCH 02/21] 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 03/21] feat(api): add POST /move endpoints for applications, databases, and services --- .../Api/ApplicationsController.php | 93 +++++++++++++++++++ .../Controllers/Api/DatabasesController.php | 93 +++++++++++++++++++ .../Controllers/Api/ServicesController.php | 93 +++++++++++++++++++ routes/api.php | 3 + 4 files changed, 282 insertions(+) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 4b0cfc6ab..492dd8c93 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3828,6 +3828,99 @@ public function action_restart(Request $request) ); } + #[OA\Post( + summary: 'Move', + description: 'Move application to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.', + path: '/applications/{uuid}/move', + operationId: 'move-application-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the application to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Application moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Application moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + return moveResourceToEnvironment($request, $application, 'Application', $teamId); + } + private function validateDataApplications(Request $request, Server $server) { $teamId = getTeamIdFromToken(); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index f7a62cf90..c37edf6cd 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2503,6 +2503,99 @@ public function list_backup_executions(Request $request) ]); } + #[OA\Post( + summary: 'Move', + description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.', + path: '/databases/{uuid}/move', + operationId: 'move-database-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the database to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Database moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Database moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + return moveResourceToEnvironment($request, $database, 'Database', $teamId); + } + #[OA\Get( summary: 'Start', description: 'Start database. `Post` request is also accepted.', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 32097443e..a4a74463a 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1591,6 +1591,99 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable deleted.']); } + #[OA\Post( + summary: 'Move', + description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.', + path: '/services/{uuid}/move', + operationId: 'move-service-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the service to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Service moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Service moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('update', $service); + + return moveResourceToEnvironment($request, $service, 'Service', $teamId); + } + #[OA\Get( summary: 'Start', description: 'Start service. `Post` request is also accepted.', diff --git a/routes/api.php b/routes/api.php index 8b28177f3..a3dfc02d5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -121,6 +121,7 @@ Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/move', [ApplicationsController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); @@ -152,6 +153,7 @@ Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); + Route::post('/databases/{uuid}/move', [DatabasesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); @@ -169,6 +171,7 @@ Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::post('/services/{uuid}/move', [ServicesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); From 94700347f80f5280a1e6f52ebb58543ba9ec1473 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 17:05:00 +0100 Subject: [PATCH 04/21] 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 05/21] fix(test): align test setup with project conventions --- tests/Feature/MoveResourceApiTest.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/Feature/MoveResourceApiTest.php b/tests/Feature/MoveResourceApiTest.php index 80d50122b..faf4f699b 100644 --- a/tests/Feature/MoveResourceApiTest.php +++ b/tests/Feature/MoveResourceApiTest.php @@ -2,6 +2,7 @@ use App\Models\Application; use App\Models\Environment; +use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; use App\Models\Service; @@ -14,6 +15,8 @@ uses(RefreshDatabase::class); beforeEach(function () { + InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -24,12 +27,12 @@ $this->bearerToken = $this->token->plainTextToken; $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); - $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->environment = $this->project->environments()->first(); $this->targetProject = Project::factory()->create(['team_id' => $this->team->id]); - $this->targetEnvironment = Environment::factory()->create(['project_id' => $this->targetProject->id]); + $this->targetEnvironment = $this->targetProject->environments()->first(); }); describe('POST /api/v1/applications/{uuid}/move', function () { @@ -170,7 +173,11 @@ describe('POST /api/v1/databases/{uuid}/move', function () { test('moves database to another environment', function () { - $database = StandalonePostgresql::factory()->create([ + $database = StandalonePostgresql::create([ + 'name' => 'test-pg', + 'postgres_user' => 'postgres', + 'postgres_password' => 'secret', + 'postgres_db' => 'testdb', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), From 8ad65a0ef8607f7db7e1b36ec5f55a86e2a430ca Mon Sep 17 00:00:00 2001 From: Bakr Date: Sun, 29 Mar 2026 06:03:47 +0300 Subject: [PATCH 06/21] eat(api): add service-applications API to manage service applications --- .../Service/DeployServiceApplication.php | 58 ++ .../Service/RestartServiceApplication.php | 24 + .../Service/StopServiceApplication.php | 24 + .../UpdateServiceApplicationFromApi.php | 107 +++ .../Api/ServiceApplicationsController.php | 754 ++++++++++++++++++ app/Policies/ServiceApplicationPolicy.php | 11 +- app/Support/ServiceComposeUrl.php | 55 ++ routes/api.php | 9 + tests/Feature/ServiceApplicationsApiTest.php | 258 ++++++ 9 files changed, 1298 insertions(+), 2 deletions(-) create mode 100644 app/Actions/Service/DeployServiceApplication.php create mode 100644 app/Actions/Service/RestartServiceApplication.php create mode 100644 app/Actions/Service/StopServiceApplication.php create mode 100644 app/Actions/Service/UpdateServiceApplicationFromApi.php create mode 100644 app/Http/Controllers/Api/ServiceApplicationsController.php create mode 100644 app/Support/ServiceComposeUrl.php create mode 100644 tests/Feature/ServiceApplicationsApiTest.php diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php new file mode 100644 index 000000000..f79437a26 --- /dev/null +++ b/app/Actions/Service/DeployServiceApplication.php @@ -0,0 +1,58 @@ +service; + $composeServiceName = $serviceApplication->name; + + $service->parse(); + $service->saveComposeConfigs(); + $service->isConfigurationChanged(save: true); + + $workdir = $service->workdir(); + $commands = collect([ + "echo 'Saved configuration files to {$workdir}.'", + "touch {$workdir}/.env", + ]); + + if ($pullLatestImages) { + $commands->push('echo Pulling image for service.'); + $commands->push("docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} pull {$composeServiceName}"); + } + + if ($service->networks()->count() > 0) { + $commands->push('echo Creating Docker network.'); + $commands->push("docker network inspect {$service->uuid} >/dev/null 2>&1 || docker network create --attachable {$service->uuid}"); + } + + $upCommand = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --no-deps"; + if ($forceRebuild) { + $upCommand .= ' --build'; + } + $upCommand .= " {$composeServiceName}"; + $commands->push('echo Starting service container.'); + $commands->push($upCommand); + + $commands->push("docker network connect {$service->uuid} coolify-proxy >/dev/null 2>&1 || true"); + + if (data_get($service, 'connect_to_docker_network')) { + $compose = data_get($service, 'docker_compose', []); + $network = $service->destination->network; + $commands->push("docker network connect --alias {$composeServiceName}-{$service->uuid} {$network} {$composeServiceName}-{$service->uuid} >/dev/null 2>&1 || true"); + } + + return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); + } +} diff --git a/app/Actions/Service/RestartServiceApplication.php b/app/Actions/Service/RestartServiceApplication.php new file mode 100644 index 000000000..f5a7883e5 --- /dev/null +++ b/app/Actions/Service/RestartServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = $serviceApplication->name.'-'.$service->uuid; + + instant_remote_process([ + "docker restart {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php new file mode 100644 index 000000000..d34f1f3c8 --- /dev/null +++ b/app/Actions/Service/StopServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = $serviceApplication->name.'-'.$service->uuid; + + instant_remote_process([ + "docker stop {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php new file mode 100644 index 000000000..8a2ce6ecd --- /dev/null +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -0,0 +1,107 @@ +boolean('force_domain_override'); + + if ($request->has('url')) { + $urlRaw = $request->input('url'); + if ($urlRaw !== null && ! is_string($urlRaw)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['url' => 'The url must be a string.'], + ], 422); + } + + $parsed = ServiceComposeUrl::validateUrlString( + is_string($urlRaw) ? $urlRaw : null, + $forceDomainOverride + ); + + if (count($parsed['errors']) > 0) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $parsed['errors'], + ], 422); + } + + if ($parsed['normalized'] !== null) { + $containerUrls = str($parsed['normalized']) + ->explode(',') + ->map(fn ($url) => str(trim((string) $url))->lower()); + + $result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $serviceApplication->uuid); + if (isset($result['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$result['error']], + ], 422); + } + + if ($result['hasConflicts'] && ! $forceDomainOverride) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + } + + $serviceApplication->fqdn = $parsed['normalized']; + } + + if ($request->has('human_name')) { + $serviceApplication->human_name = $request->input('human_name'); + } + + if ($request->has('description')) { + $serviceApplication->description = $request->input('description'); + } + + if ($request->has('image')) { + $serviceApplication->image = $request->input('image'); + } + + if ($request->has('exclude_from_status')) { + $serviceApplication->exclude_from_status = $request->boolean('exclude_from_status'); + } + + if ($request->has('is_gzip_enabled')) { + $serviceApplication->is_gzip_enabled = $request->boolean('is_gzip_enabled'); + } + + if ($request->has('is_stripprefix_enabled')) { + $serviceApplication->is_stripprefix_enabled = $request->boolean('is_stripprefix_enabled'); + } + + if ($request->has('is_log_drain_enabled')) { + $enabled = $request->boolean('is_log_drain_enabled'); + $server = $serviceApplication->service->destination->server; + if ($enabled && ! $server->isLogDrainEnabled()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'is_log_drain_enabled' => 'Log drain is not enabled on the server for this service.', + ], + ], 422); + } + $serviceApplication->is_log_drain_enabled = $enabled; + } + + $serviceApplication->save(); + $serviceApplication->refresh(); + + updateCompose($serviceApplication); + + return null; + } +} diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php new file mode 100644 index 000000000..da3a8b337 --- /dev/null +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -0,0 +1,754 @@ +makeHidden([ + 'id', + 'resourceable', + 'resourceable_id', + 'resourceable_type', + ]); + + return serializeApiResponse($serviceApplication); + } + + private function resolveService(Request $request, int $teamId): ?Service + { + $uuid = $request->route('uuid'); + if (! $uuid) { + return null; + } + + return Service::whereRelation('environment.project.team', 'id', $teamId) + ->whereUuid($uuid) + ->first(); + } + + private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication + { + $appUuid = $request->route('app_uuid'); + if (! $appUuid) { + return null; + } + + return $service->applications() + ->where('uuid', $appUuid) + ->with(['service.destination.server']) + ->first(); + } + + private function swarmNotSupportedResponse(): JsonResponse + { + return response()->json([ + 'message' => 'This operation is not supported for Swarm servers yet.', + ], 501); + } + + #[OA\Get( + summary: 'List service applications', + description: 'List compose service applications (containers) for a single service.', + path: '/services/{uuid}/applications', + operationId: 'list-service-applications-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Service applications for this service.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(type: 'object') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function index(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('view', $service); + + $items = $service->applications() + ->get() + ->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa)); + + return response()->json($items); + } + + #[OA\Get( + summary: 'Get service application', + description: 'Get a single compose service application by service UUID and application UUID.', + path: '/services/{uuid}/applications/{app_uuid}', + operationId: 'get-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Service application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(type: 'object') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function show(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('view', $serviceApplication); + + return response()->json($this->removeSensitiveData($serviceApplication)); + } + + #[OA\Patch( + summary: 'Update service application', + description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).', + path: '/services/{uuid}/applications/{app_uuid}', + operationId: 'patch-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'force_domain_override', + in: 'query', + description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + requestBody: new OA\RequestBody( + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'url' => new OA\Property( + property: 'url', + type: 'string', + nullable: true, + description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.' + ), + 'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true), + 'description' => new OA\Property(property: 'description', type: 'string', nullable: true), + 'image' => new OA\Property(property: 'image', type: 'string', nullable: true), + 'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true), + 'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true), + 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), + 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), + ] + ) + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Updated service application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(type: 'object') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 409, + description: 'Domain conflicts (unless force_domain_override).', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('update', $serviceApplication); + + $allowedFields = [ + 'url', + 'human_name', + 'description', + 'image', + 'exclude_from_status', + 'is_log_drain_enabled', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + ]; + + $validationRules = [ + 'url' => 'nullable|string', + 'human_name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'image' => 'nullable|string', + 'exclude_from_status' => 'sometimes|boolean', + 'is_log_drain_enabled' => 'sometimes|boolean', + 'is_gzip_enabled' => 'sometimes|boolean', + 'is_stripprefix_enabled' => 'sometimes|boolean', + ]; + + $validator = Validator::make($request->all(), $validationRules); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId); + if ($response instanceof JsonResponse) { + return $response; + } + + $serviceApplication->refresh(); + + return response()->json($this->removeSensitiveData($serviceApplication)); + } + + #[OA\Get( + summary: 'Get service application logs', + description: 'Get Docker logs for a single compose service container.', + path: '/services/{uuid}/applications/{app_uuid}/logs', + operationId: 'get-service-application-logs-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema(type: 'integer', format: 'int32', default: 100) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Logs.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => new OA\Property(property: 'logs', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function logs_by_uuid(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('view', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + $containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid; + + $status = getContainerStatus($server, $containerName); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Service application container is not running.', + ], 400); + } + + $lines = (int) ($request->query('lines', 100) ?: 100); + $logs = getContainerLogs($server, $containerName, $lines); + + return response()->json([ + 'logs' => $logs, + ]); + } + + #[OA\Get( + summary: 'Start or redeploy service application container', + description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.', + path: '/services/{uuid}/applications/{app_uuid}/start', + operationId: 'start-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'force', + in: 'query', + description: 'When true, passes --build to docker compose up.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + new OA\Parameter( + name: 'latest', + in: 'query', + description: 'When true, pulls the image for this compose service before up.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Deploy request queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function action_start(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + $pullLatest = $request->boolean('latest', false); + $forceRebuild = $request->boolean('force', false); + + DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild); + + return response()->json([ + 'message' => 'Service application deploy request queued.', + ], 200); + } + + #[OA\Get( + summary: 'Restart service application container', + description: 'Restarts a single compose service container (docker restart).', + path: '/services/{uuid}/applications/{app_uuid}/restart', + operationId: 'restart-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Restart queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function action_restart(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + RestartServiceApplication::dispatch($serviceApplication); + + return response()->json([ + 'message' => 'Service application restart request queued.', + ], 200); + } + + #[OA\Get( + summary: 'Stop service application container', + description: 'Stops a single compose service container (docker stop).', + path: '/services/{uuid}/applications/{app_uuid}/stop', + operationId: 'stop-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Stop queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function action_stop(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + StopServiceApplication::dispatch($serviceApplication); + + return response()->json([ + 'message' => 'Service application stop request queued.', + ], 200); + } +} diff --git a/app/Policies/ServiceApplicationPolicy.php b/app/Policies/ServiceApplicationPolicy.php index af380a90f..619b885ff 100644 --- a/app/Policies/ServiceApplicationPolicy.php +++ b/app/Policies/ServiceApplicationPolicy.php @@ -30,8 +30,15 @@ public function create(User $user): bool */ public function update(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('update', $serviceApplication->service); - return true; + return Gate::allows('update', $serviceApplication->service); + } + + /** + * Determine whether the user can deploy or run lifecycle actions on the parent service stack. + */ + public function deploy(User $user, ServiceApplication $serviceApplication): bool + { + return Gate::allows('deploy', $serviceApplication->service); } /** diff --git a/app/Support/ServiceComposeUrl.php b/app/Support/ServiceComposeUrl.php new file mode 100644 index 000000000..cdeb75e58 --- /dev/null +++ b/app/Support/ServiceComposeUrl.php @@ -0,0 +1,55 @@ +, normalized: ?string} + */ + public static function validateUrlString(?string $urlValue, bool $forceDomainOverride = false): array + { + $errors = []; + + if ($urlValue === null || $urlValue === '') { + return ['errors' => [], 'normalized' => null]; + } + + $urls = str($urlValue) + ->replaceStart(',', '') + ->replaceEnd(',', '') + ->trim() + ->explode(',') + ->map(fn ($url) => trim((string) $url)) + ->filter(); + + foreach ($urls as $url) { + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = "Invalid URL: {$url}"; + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'], true)) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; + } + } + + $duplicates = $urls->duplicates()->unique()->values(); + if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { + $errors[] = 'The current request contains duplicate URLs: '.implode(', ', $duplicates->toArray()).'. Use force_domain_override=true to proceed.'; + } + + if (count($errors) > 0) { + return ['errors' => $errors, 'normalized' => null]; + } + + $normalized = $urls + ->map(fn ($u) => str($u)->lower()->value()) + ->unique() + ->filter(fn ($u) => filled($u)) + ->implode(','); + + return ['errors' => [], 'normalized' => $normalized !== '' ? $normalized : null]; + } +} diff --git a/routes/api.php b/routes/api.php index 0d3edcced..57ec3934a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,6 +12,7 @@ use App\Http\Controllers\Api\ScheduledTasksController; use App\Http\Controllers\Api\SecurityController; use App\Http\Controllers\Api\ServersController; +use App\Http\Controllers\Api\ServiceApplicationsController; use App\Http\Controllers\Api\ServicesController; use App\Http\Controllers\Api\TeamController; use App\Http\Middleware\ApiAllowed; @@ -193,6 +194,14 @@ Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); + Route::get('/services/{uuid}/applications', [ServiceApplicationsController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'update'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/logs', [ServiceApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/start', [ServiceApplicationsController::class, 'action_start'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/restart', [ServiceApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/stop', [ServiceApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); + Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); diff --git a/tests/Feature/ServiceApplicationsApiTest.php b/tests/Feature/ServiceApplicationsApiTest.php new file mode 100644 index 000000000..d4f12350e --- /dev/null +++ b/tests/Feature/ServiceApplicationsApiTest.php @@ -0,0 +1,258 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function createServiceWithApplicationForApiTest(object $ctx): object +{ + $service = Service::factory()->create([ + 'environment_id' => $ctx->environment->id, + 'server_id' => $ctx->server->id, + 'destination_id' => $ctx->destination->id, + 'destination_type' => $ctx->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + + $sa = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'web', + 'service_id' => $service->id, + 'image' => 'nginx:alpine', + ]); + + return (object) ['service' => $service, 'serviceApplication' => $sa]; +} + +function createServiceWithoutApplicationsForApiTest(object $ctx): Service +{ + return Service::factory()->create([ + 'environment_id' => $ctx->environment->id, + 'server_id' => $ctx->server->id, + 'destination_id' => $ctx->destination->id, + 'destination_type' => $ctx->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); +} + +describe('GET /api/v1/services/{uuid}/applications', function () { + test('returns empty array when service has no applications', function () { + $service = createServiceWithoutApplicationsForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$service->uuid}/applications"); + + $response->assertStatus(200); + $response->assertJson([]); + }); + + test('lists service applications for the service', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications"); + + $response->assertStatus(200); + $response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid]); + }); + + test('returns 404 when service does not exist', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000001/applications'); + + $response->assertStatus(404); + $response->assertJsonFragment(['message' => 'Service not found.']); + }); +}); + +describe('GET /api/v1/services/{uuid}/applications/{app_uuid}', function () { + test('returns 404 for unknown service', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000002/applications/non-existent-uuid-12345'); + + $response->assertStatus(404); + $response->assertJsonFragment(['message' => 'Service not found.']); + }); + + test('returns 404 when application uuid is not under service', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/00000000-0000-0000-0000-000000000003"); + + $response->assertStatus(404); + $response->assertJsonFragment(['message' => 'Service application not found.']); + }); + + test('returns service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}"); + + $response->assertStatus(200); + $response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid, 'name' => 'web']); + }); +}); + +describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () { + test('returns 400 without valid token', function () { + $response = $this->patchJson('/api/v1/services/some-uuid/applications/some-app', [ + 'human_name' => 'x', + ], ['Accept' => 'application/json', 'Content-Type' => 'application/json']); + + $response->assertStatus(400); + }); + + test('updates human_name', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'human_name' => 'Web UI', + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['human_name' => 'Web UI']); + $ctx->serviceApplication->refresh(); + expect($ctx->serviceApplication->human_name)->toBe('Web UI'); + }); + + test('returns 422 for invalid url scheme', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'url' => 'ftp://example.com', + ]); + + $response->assertStatus(422); + }); + + test('returns 422 when enabling log drain but server has no log drain', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'is_log_drain_enabled' => true, + ]); + + $response->assertStatus(422); + expect((string) $response->json('errors.is_log_drain_enabled.0'))->toContain('Log drain'); + }); +}); + +describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/restart', function () { + test('returns 400 without valid token', function () { + $response = $this->postJson('/api/v1/services/some-uuid/applications/some-app/restart'); + + $response->assertStatus(400); + }); + + test('queues restart for a service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/restart"); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Service application restart request queued.']); + RestartServiceApplication::assertPushed(); + }); +}); + +describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/start', function () { + test('queues deploy for a service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start?latest=1&force=1"); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Service application deploy request queued.']); + DeployServiceApplication::assertPushed(); + }); +}); + +describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/stop', function () { + test('queues stop for a service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/stop"); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Service application stop request queued.']); + StopServiceApplication::assertPushed(); + }); +}); + +describe('GET /api/v1/services/{uuid}/applications/{app_uuid}/logs', function () { + test('returns 400 when server is not functional', function () { + $ctx = createServiceWithApplicationForApiTest($this); + $this->server->settings->update([ + 'is_reachable' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/logs"); + + $response->assertStatus(400); + $response->assertJsonFragment(['message' => 'Server is not functional.']); + }); +}); From 23d5b854e980cb20d3d38f8aa20e5ea110f316fa Mon Sep 17 00:00:00 2001 From: Michael Jathe Date: Sun, 29 Mar 2026 16:02:05 +0200 Subject: [PATCH 07/21] feat(api): add tag management endpoints for applications, databases, and services Add CRUD tag endpoints (GET/POST/DELETE) as sub-resources for applications, databases, and services. Add team-level GET /tags endpoint. Extend all resource creation endpoints to accept an optional tags array. Uses a shared HandlesTagsApi trait to avoid duplication across controllers. Tags are race-safe via syncWithoutDetaching(), garbage-collected when orphaned, and sanitized (strip_tags + lowercase). --- .../Api/ApplicationsController.php | 186 +++++++- .../Api/Concerns/HandlesTagsApi.php | 142 ++++++ .../Controllers/Api/DatabasesController.php | 206 ++++++++- .../Controllers/Api/ServicesController.php | 171 +++++++- app/Http/Controllers/Api/TagsController.php | 61 +++ app/Models/Tag.php | 11 + bootstrap/helpers/api.php | 1 + routes/api.php | 15 + tests/Feature/TagApiTest.php | 410 ++++++++++++++++++ 9 files changed, 1191 insertions(+), 12 deletions(-) create mode 100644 app/Http/Controllers/Api/Concerns/HandlesTagsApi.php create mode 100644 app/Http/Controllers/Api/TagsController.php create mode 100644 tests/Feature/TagApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ad1f50ea2..d6e0de340 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -33,6 +33,18 @@ class ApplicationsController extends Controller { + use Concerns\HandlesTagsApi; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Application not found.'; + } + private function removeSensitiveData($application) { $application->makeHidden([ @@ -230,6 +242,7 @@ public function applications(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -395,6 +408,7 @@ public function create_public_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -560,6 +574,7 @@ public function create_private_gh_app_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -697,6 +712,7 @@ public function create_private_deploy_key_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -831,6 +847,7 @@ public function create_dockerfile_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -1006,7 +1023,7 @@ private function create_application(Request $request, $type) if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1020,6 +1037,8 @@ private function create_application(Request $request, $type) 'http_basic_auth_username' => 'string|nullable', 'http_basic_auth_password' => 'string|nullable', 'autogenerate_domain' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -1277,6 +1296,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($request->has('tags')) { + $this->attachTagsToResource($application, $request->tags, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1503,6 +1525,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($request->has('tags')) { + $this->attachTagsToResource($application, $request->tags, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1699,6 +1724,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($request->has('tags')) { + $this->attachTagsToResource($application, $request->tags, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1810,6 +1838,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($request->has('tags')) { + $this->attachTagsToResource($application, $request->tags, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1920,6 +1951,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($request->has('tags')) { + $this->attachTagsToResource($application, $request->tags, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1943,7 +1977,7 @@ private function create_application(Request $request, $type) 'domains' => data_get($application, 'fqdn'), ]))->setStatusCode(201); } elseif ($type === 'dockercompose') { - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -2017,6 +2051,10 @@ private function create_application(Request $request, $type) // Apply service-specific application prerequisites applyServiceApplicationPrerequisites($service); + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -4454,4 +4492,148 @@ public function delete_storage(Request $request): JsonResponse return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for an application by UUID.', + path: '/applications/{uuid}/tags', + operationId: 'list-tags-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to an application by UUID.', + path: '/applications/{uuid}/tags', + operationId: 'create-tag-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from an application by UUID.', + path: '/applications/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php new file mode 100644 index 000000000..b6d6d259a --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php @@ -0,0 +1,142 @@ +findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('view', $resource); + + return response()->json($resource->tags->map(TagsController::serializeTag(...))); + } + + public function createTag(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $resource = $this->findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('update', $resource); + + if ($request->has('tag_name') && $request->has('tag_names')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']], + ], 422); + } + + $validator = Validator::make($request->all(), [ + 'tag_name' => 'required_without:tag_names|string|min:2', + 'tag_names' => 'required_without:tag_name|array|min:1', + 'tag_names.*' => 'string|min:2', + ]); + + $extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $tagNames = $request->has('tag_names') ? $request->tag_names : [$request->tag_name]; + + $this->attachTagsToResource($resource, $tagNames, $teamId); + + return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201); + } + + public function deleteTag(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $resource = $this->findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('update', $resource); + + $tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first(); + if (! $tag) { + return response()->json(['message' => 'Tag not found.'], 404); + } + + $resource->tags()->detach($tag->id); + + if (DB::table('taggables')->where('tag_id', $tag->id)->count() === 0) { + $tag->delete(); + } + + return response()->json(['message' => 'Tag removed.']); + } + + protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void + { + foreach ($tagNames as $tagName) { + $tagName = strtolower(strip_tags($tagName)); + if (strlen($tagName) < 2) { + continue; + } + + $tag = Tag::where('team_id', $teamId)->where('name', $tagName)->first(); + if (! $tag) { + $tag = Tag::create([ + 'name' => $tagName, + 'team_id' => $teamId, + ]); + } + + $resource->tags()->syncWithoutDetaching([$tag->id]); + } + } +} diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 33d875758..c96bffa9b 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -27,6 +27,18 @@ class DatabasesController extends Controller { + use Concerns\HandlesTagsApi; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return queryDatabaseByUuidWithinTeam($uuid, $teamId); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Database not found.'; + } + private function removeSensitiveData($database) { $database->makeHidden([ @@ -1079,6 +1091,7 @@ public function update_backup(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1147,6 +1160,7 @@ public function create_database_postgresql(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1214,6 +1228,7 @@ public function create_database_clickhouse(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1282,6 +1297,7 @@ public function create_database_dragonfly(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1350,6 +1366,7 @@ public function create_database_redis(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1421,6 +1438,7 @@ public function create_database_keydb(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1492,6 +1510,7 @@ public function create_database_mariadb(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1560,6 +1579,7 @@ public function create_database_mysql(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1689,6 +1709,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', 'instant_deploy' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]); if ($validator->failed()) { return response()->json([ @@ -1707,7 +1729,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } } if ($type === NewDatabaseTypes::POSTGRESQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'postgres_user' => 'string', 'postgres_password' => 'string', @@ -1752,6 +1774,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('postgres_conf', $postgresConf); } $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1766,7 +1791,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MARIADB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'tags']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => 'string', 'clickhouse_admin_password' => 'string', @@ -1807,6 +1832,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('mariadb_conf', $mariadbConf); } $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1822,7 +1850,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MYSQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'mysql_root_password' => 'string', 'mysql_password' => 'string', @@ -1866,6 +1894,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('mysql_conf', $mysqlConf); } $database = create_standalone_mysql($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1881,7 +1912,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::REDIS) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'redis_password' => 'string', 'redis_conf' => 'string', @@ -1922,6 +1953,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('redis_conf', $redisConf); } $database = create_standalone_redis($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1937,7 +1971,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::DRAGONFLY) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password', 'tags']; $validator = customApiValidator($request->all(), [ 'dragonfly_password' => 'string', ]); @@ -1959,6 +1993,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) removeUnnecessaryFieldsFromRequest($request); $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1967,7 +2004,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'uuid' => $database->uuid, ]))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::KEYDB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'keydb_password' => 'string', 'keydb_conf' => 'string', @@ -2008,6 +2045,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('keydb_conf', $keydbConf); } $database = create_standalone_keydb($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2023,7 +2063,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password', 'tags']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => 'string', 'clickhouse_admin_password' => 'string', @@ -2044,6 +2084,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) } removeUnnecessaryFieldsFromRequest($request); $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2059,7 +2102,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MONGODB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'tags']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', 'mongo_initdb_root_username' => 'string', @@ -2102,6 +2145,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $request->offsetSet('mongo_conf', $mongoConf); } $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->all()); + if ($request->has('tags')) { + $this->attachTagsToResource($database, $request->tags, $teamId); + } if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -3856,4 +3902,148 @@ public function delete_storage(Request $request): JsonResponse return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for a database by UUID.', + path: '/databases/{uuid}/tags', + operationId: 'list-tags-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to a database by UUID.', + path: '/databases/{uuid}/tags', + operationId: 'create-tag-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from a database by UUID.', + path: '/databases/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index fbf4b9e56..7b60a6d0c 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -22,6 +22,18 @@ class ServicesController extends Controller { + use Concerns\HandlesTagsApi; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first(); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Service not found.'; + } + private function removeSensitiveData($service) { $service->makeHidden([ @@ -227,6 +239,7 @@ public function services(Request $request) ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'], ], ), ), @@ -293,7 +306,7 @@ public function services(Request $request) )] public function create_service(Request $request) { - $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -323,6 +336,8 @@ public function create_service(Request $request) 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -482,6 +497,10 @@ public function create_service(Request $request) } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -494,7 +513,7 @@ public function create_service(Request $request) return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); } elseif (filled($request->docker_compose_raw)) { - $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; $validationRules = [ 'project_uuid' => 'string|required', @@ -646,6 +665,10 @@ public function create_service(Request $request) } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -2458,4 +2481,148 @@ public function delete_storage(Request $request): JsonResponse return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for a service by UUID.', + path: '/services/{uuid}/tags', + operationId: 'list-tags-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to a service by UUID.', + path: '/services/{uuid}/tags', + operationId: 'create-tag-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from a service by UUID.', + path: '/services/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/TagsController.php b/app/Http/Controllers/Api/TagsController.php new file mode 100644 index 000000000..173a8ab7b --- /dev/null +++ b/app/Http/Controllers/Api/TagsController.php @@ -0,0 +1,61 @@ + $tag->uuid, + 'name' => $tag->name, + 'created_at' => $tag->created_at, + 'updated_at' => $tag->updated_at, + ]; + } + + #[OA\Get( + summary: 'List', + description: 'List all tags for the current team.', + path: '/tags', + operationId: 'list-tags', + security: [ + ['bearerAuth' => []], + ], + tags: ['Tags'], + responses: [ + new OA\Response( + response: 200, + description: 'All tags for the current team.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + ] + )] + public function tags(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $tags = Tag::where('team_id', $teamId)->orderBy('name')->get(); + + return response()->json($tags->map(self::serializeTag(...))); + } +} diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 3594d1072..221ef15bb 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -3,7 +3,18 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Tag model', + type: 'object', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'created_at', type: 'string'), + new OA\Property(property: 'updated_at', type: 'string'), + ] +)] class Tag extends BaseModel { use HasSafeStringAttribute; diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 3241276e1..e3a611ceb 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -194,4 +194,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('autogenerate_domain'); $request->offsetUnset('is_container_label_escape_enabled'); $request->offsetUnset('docker_compose_raw'); + $request->offsetUnset('tags'); } diff --git a/routes/api.php b/routes/api.php index 0d3edcced..716bcf286 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Api\SecurityController; use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServicesController; +use App\Http\Controllers\Api\TagsController; use App\Http\Controllers\Api\TeamController; use App\Http\Middleware\ApiAllowed; use App\Jobs\PushServerUpdateJob; @@ -98,6 +99,8 @@ Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); + Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); + Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']); Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']); @@ -125,6 +128,10 @@ Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::get('/applications/{uuid}/tags', [ApplicationsController::class, 'tags'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); @@ -167,6 +174,10 @@ Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/tags', [DatabasesController::class, 'tags'])->middleware(['api.ability:read']); + Route::post('/databases/{uuid}/tags', [DatabasesController::class, 'create_tag'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); @@ -189,6 +200,10 @@ Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/tags', [ServicesController::class, 'tags'])->middleware(['api.ability:read']); + Route::post('/services/{uuid}/tags', [ServicesController::class, 'create_tag'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); diff --git a/tests/Feature/TagApiTest.php b/tests/Feature/TagApiTest.php new file mode 100644 index 000000000..dd62c1062 --- /dev/null +++ b/tests/Feature/TagApiTest.php @@ -0,0 +1,410 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +function tagApiAuthHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('GET /api/v1/tags', function () { + test('returns all tags for current team', function () { + Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + Tag::create(['name' => 'staging', 'team_id' => $this->team->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson('/api/v1/tags'); + + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonFragment(['name' => 'production']); + $response->assertJsonFragment(['name' => 'staging']); + }); + + test('returns empty array when no tags exist', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson('/api/v1/tags'); + + $response->assertStatus(200); + $response->assertJsonCount(0); + }); + + test('does not return tags from other teams', function () { + $otherTeam = Team::factory()->create(); + Tag::create(['name' => 'other-team-tag', 'team_id' => $otherTeam->id]); + Tag::create(['name' => 'my-tag', 'team_id' => $this->team->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson('/api/v1/tags'); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'my-tag']); + $response->assertJsonMissing(['name' => 'other-team-tag']); + }); +}); + +describe('GET /api/v1/applications/{uuid}/tags', function () { + test('returns tags for an application', function () { + $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + $this->application->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$this->application->uuid}/tags"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'production']); + }); + + test('returns 404 for non-existent application', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson('/api/v1/applications/non-existent-uuid/tags'); + + $response->assertStatus(404); + }); + + test('returns empty array when application has no tags', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$this->application->uuid}/tags"); + + $response->assertStatus(200); + $response->assertJsonCount(0); + }); +}); + +describe('POST /api/v1/applications/{uuid}/tags', function () { + test('adds a single tag via tag_name', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'production', + ]); + + $response->assertStatus(201); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'production']); + + expect($this->application->tags()->count())->toBe(1); + }); + + test('adds multiple tags via tag_names array', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_names' => ['production', 'frontend'], + ]); + + $response->assertStatus(201); + $response->assertJsonCount(2); + + expect($this->application->tags()->count())->toBe(2); + }); + + test('reuses existing team tag instead of creating duplicate', function () { + Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'production', + ]); + + $response->assertStatus(201); + expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1); + }); + + test('rejects tag_name shorter than 2 characters', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'x', + ]); + + $response->assertStatus(422); + }); + + test('rejects both tag_name and tag_names provided simultaneously', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'production', + 'tag_names' => ['staging'], + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]); + }); + + test('skips duplicate tag already on resource', function () { + $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + $this->application->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'production', + ]); + + $response->assertStatus(201); + expect($this->application->tags()->count())->toBe(1); + }); + + test('returns 404 for non-existent application', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/non-existent-uuid/tags', [ + 'tag_name' => 'production', + ]); + + $response->assertStatus(404); + }); +}); + +describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () { + test('removes tag from application', function () { + $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + $this->application->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}"); + + $response->assertStatus(200); + expect($this->application->tags()->count())->toBe(0); + }); + + test('garbage-collects orphaned tag', function () { + $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + $this->application->tags()->attach($tag->id); + + $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}"); + + expect(Tag::find($tag->id))->toBeNull(); + }); + + test('keeps tag if still used by other resources', function () { + $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + $this->application->tags()->attach($tag->id); + + $otherApp = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $otherApp->tags()->attach($tag->id); + + $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}"); + + expect(Tag::find($tag->id))->not->toBeNull(); + }); + + test('returns 404 for non-existent tag', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid"); + + $response->assertStatus(404); + }); +}); + +describe('GET /api/v1/databases/{uuid}/tags', function () { + test('returns tags for a database', function () { + $database = StandalonePostgresql::create([ + 'name' => 'test-pg', + 'postgres_password' => 'testpassword', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]); + $database->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/databases/{$database->uuid}/tags"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'database-tag']); + }); +}); + +describe('POST /api/v1/databases/{uuid}/tags', function () { + test('adds tag to database', function () { + $database = StandalonePostgresql::create([ + 'name' => 'test-pg', + 'postgres_password' => 'testpassword', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/databases/{$database->uuid}/tags", [ + 'tag_name' => 'database-tag', + ]); + + $response->assertStatus(201); + expect($database->tags()->count())->toBe(1); + }); +}); + +describe('DELETE /api/v1/databases/{uuid}/tags/{tag_uuid}', function () { + test('removes tag from database', function () { + $database = StandalonePostgresql::create([ + 'name' => 'test-pg', + 'postgres_password' => 'testpassword', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]); + $database->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}"); + + $response->assertStatus(200); + expect($database->tags()->count())->toBe(0); + }); +}); + +describe('GET /api/v1/services/{uuid}/tags', function () { + test('returns tags for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]); + $service->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/services/{$service->uuid}/tags"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'service-tag']); + }); +}); + +describe('POST /api/v1/services/{uuid}/tags', function () { + test('adds tag to service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/services/{$service->uuid}/tags", [ + 'tag_name' => 'service-tag', + ]); + + $response->assertStatus(201); + expect($service->tags()->count())->toBe(1); + }); +}); + +describe('DELETE /api/v1/services/{uuid}/tags/{tag_uuid}', function () { + test('removes tag from service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]); + $service->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}"); + + $response->assertStatus(200); + expect($service->tags()->count())->toBe(0); + }); +}); + +describe('Tag name sanitization', function () { + test('strips HTML tags from tag names', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [ + 'tag_name' => 'production', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'alert("xss")production']); + $response->assertJsonMissing(['name' => 'production', ]); - $response->assertStatus(201); + $response->assertCreated(); $response->assertJsonFragment(['name' => 'alert("xss")production']); $response->assertJsonMissing(['name' => '