diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php index 363166bcc..9181c537d 100644 --- a/app/Actions/Service/DeployServiceApplication.php +++ b/app/Actions/Service/DeployServiceApplication.php @@ -3,6 +3,7 @@ namespace App\Actions\Service; use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use Lorisleiva\Actions\Concerns\AsAction; use Spatie\Activitylog\Contracts\Activity; @@ -12,7 +13,7 @@ class DeployServiceApplication public string $jobQueue = 'high'; - public function handle(ServiceApplication $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity + public function handle(ServiceApplication|ServiceDatabase $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity { $service = $serviceApplication->service; $composeServiceName = $serviceApplication->name; diff --git a/app/Actions/Service/RestartServiceApplication.php b/app/Actions/Service/RestartServiceApplication.php index c83cbe660..ebee6e2f2 100644 --- a/app/Actions/Service/RestartServiceApplication.php +++ b/app/Actions/Service/RestartServiceApplication.php @@ -3,6 +3,7 @@ namespace App\Actions\Service; use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use Lorisleiva\Actions\Concerns\AsAction; class RestartServiceApplication @@ -11,7 +12,7 @@ class RestartServiceApplication public string $jobQueue = 'high'; - public function handle(ServiceApplication $serviceApplication): void + public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void { $service = $serviceApplication->service; $server = $service->destination->server; diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php index cc1afbb96..724e1a254 100644 --- a/app/Actions/Service/StopServiceApplication.php +++ b/app/Actions/Service/StopServiceApplication.php @@ -3,6 +3,7 @@ namespace App\Actions\Service; use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use Lorisleiva\Actions\Concerns\AsAction; class StopServiceApplication @@ -11,7 +12,7 @@ class StopServiceApplication public string $jobQueue = 'high'; - public function handle(ServiceApplication $serviceApplication): void + public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void { $service = $serviceApplication->service; $server = $service->destination->server; diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index a144b3ea6..74b6c9db1 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -424,6 +424,33 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update ), ] )] + #[OA\Post( + summary: 'Get service application logs', + description: 'Get Docker logs for a single compose service container.', + path: '/services/{uuid}/applications/{app_uuid}/logs', + operationId: 'post-service-application-logs-by-service-and-app-uuid', + security: [['bearerAuth' => []]], + tags: ['Service applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Logs.', + content: new OA\JsonContent( + type: 'object', + properties: [new OA\Property(property: 'logs', type: 'string')], + ), + ), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] public function logs_by_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); @@ -540,6 +567,34 @@ public function logs_by_uuid(Request $request): JsonResponse ), ] )] + #[OA\Post( + summary: 'Start or redeploy service application container', + description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.', + path: '/services/{uuid}/applications/{app_uuid}/start', + operationId: 'post-start-service-application-by-service-and-app-uuid', + security: [['bearerAuth' => []]], + tags: ['Service applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)), + new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Deploy request queued.', + content: new OA\JsonContent( + type: 'object', + properties: [new OA\Property(property: 'message', type: 'string')], + ), + ), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] public function action_start(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); @@ -635,6 +690,32 @@ public function action_start(Request $request): JsonResponse ), ] )] + #[OA\Post( + summary: 'Restart service application container', + description: 'Restarts a single compose service container.', + path: '/services/{uuid}/applications/{app_uuid}/restart', + operationId: 'post-restart-service-application-by-service-and-app-uuid', + security: [['bearerAuth' => []]], + tags: ['Service applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Restart queued.', + content: new OA\JsonContent( + type: 'object', + properties: [new OA\Property(property: 'message', type: 'string')], + ), + ), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] public function action_restart(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); @@ -727,6 +808,32 @@ public function action_restart(Request $request): JsonResponse ), ] )] + #[OA\Post( + summary: 'Stop service application container', + description: 'Stops a single compose service container.', + path: '/services/{uuid}/applications/{app_uuid}/stop', + operationId: 'post-stop-service-application-by-service-and-app-uuid', + security: [['bearerAuth' => []]], + tags: ['Service applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Stop queued.', + content: new OA\JsonContent( + type: 'object', + properties: [new OA\Property(property: 'message', type: 'string')], + ), + ), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] public function action_stop(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php new file mode 100644 index 000000000..7befc3273 --- /dev/null +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -0,0 +1,452 @@ +makeHidden([ + 'id', + 'service', + 'service_id', + 'resourceable', + 'resourceable_id', + 'resourceable_type', + ]); + + $serialized = serializeApiResponse($serviceDatabase); + + if ($serialized instanceof Collection) { + return $serialized->all(); + } + + return (array) $serialized; + } + + private function resolveService(Request $request, int $teamId): ?Service + { + return Service::whereRelation('environment.project.team', 'id', $teamId) + ->whereUuid($request->route('uuid')) + ->first(); + } + + private function resolveServiceDatabase(Request $request, Service $service): ?ServiceDatabase + { + return $service->databases() + ->where('uuid', $request->route('database_uuid')) + ->with(['service.destination.server']) + ->first(); + } + + private function swarmNotSupportedResponse(): JsonResponse + { + return response()->json([ + 'message' => 'This operation is not supported for Swarm servers yet.', + ], 501); + } + + #[OA\Get( + summary: 'List service databases', + description: 'List compose databases for a single service.', + path: '/services/{uuid}/databases', + operationId: 'list-service-databases-by-service-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Service databases.', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'object'))), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function index(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('view', $service); + + $databases = $service->databases() + ->get() + ->map(fn (ServiceDatabase $database) => $this->removeSensitiveData($database)); + + return response()->json($databases); + } + + #[OA\Get( + summary: 'Get service database', + description: 'Get a compose database by service UUID and database UUID.', + path: '/services/{uuid}/databases/{database_uuid}', + operationId: 'get-service-database-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Service database.', content: new OA\JsonContent(type: 'object')), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function show(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceDatabase = $this->resolveServiceDatabase($request, $service); + if (! $serviceDatabase) { + return response()->json(['message' => 'Service database not found.'], 404); + } + + $this->authorize('view', $serviceDatabase); + + return response()->json($this->removeSensitiveData($serviceDatabase)); + } + + #[OA\Patch( + summary: 'Update service database', + description: 'Update mutable fields for a compose service database.', + path: '/services/{uuid}/databases/{database_uuid}', + operationId: 'patch-service-database-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'human_name', type: 'string', nullable: true), + new OA\Property(property: 'description', type: 'string', nullable: true), + new OA\Property(property: 'image', type: 'string'), + new OA\Property(property: 'exclude_from_status', type: 'boolean'), + new OA\Property(property: 'is_log_drain_enabled', type: 'boolean'), + new OA\Property(property: 'is_public', type: 'boolean'), + new OA\Property(property: 'public_port', type: 'integer', nullable: true, minimum: 1, maximum: 65535), + new OA\Property(property: 'public_port_timeout', type: 'integer', nullable: true, minimum: 1), + ], + additionalProperties: false, + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Updated service database.', content: new OA\JsonContent(type: 'object')), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $invalidRequest = validateIncomingRequest($request); + if ($invalidRequest instanceof JsonResponse) { + return $invalidRequest; + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceDatabase = $this->resolveServiceDatabase($request, $service); + if (! $serviceDatabase) { + return response()->json(['message' => 'Service database not found.'], 404); + } + + $this->authorize('update', $serviceDatabase); + + $payload = $request->json()->all(); + if (empty($payload)) { + $payload = $request->request->all(); + } + + $allowedFields = [ + 'human_name', + 'description', + 'image', + 'exclude_from_status', + 'is_log_drain_enabled', + 'is_public', + 'public_port', + 'public_port_timeout', + ]; + $validator = Validator::make($payload, [ + 'human_name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'image' => 'sometimes|string', + 'exclude_from_status' => 'sometimes|boolean', + 'is_log_drain_enabled' => 'sometimes|boolean', + 'is_public' => 'sometimes|boolean', + 'public_port' => 'nullable|integer|min:1|max:65535', + 'public_port_timeout' => 'nullable|integer|min:1', + ]); + + $extraFields = array_diff(array_keys($payload), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $server = $serviceDatabase->service->destination->server; + if (($payload['is_log_drain_enabled'] ?? false) && ! $server->isLogDrainEnabled()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.']], + ], 422); + } + + $isPublic = $payload['is_public'] ?? $serviceDatabase->is_public; + $publicPort = $payload['public_port'] ?? $serviceDatabase->public_port; + if ($isPublic && ! $publicPort) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['public_port' => ['A public port is required when the database is public.']], + ], 422); + } + if ($isPublic && isPublicPortAlreadyUsed($server, $publicPort, $serviceDatabase->id)) { + return response()->json(['message' => 'Public port already used by another database.'], 400); + } + + $shouldStartProxy = ($payload['is_public'] ?? null) === true && ! $serviceDatabase->is_public; + $shouldStopProxy = ($payload['is_public'] ?? null) === false && $serviceDatabase->is_public; + + $serviceDatabase->fill($payload); + $serviceDatabase->save(); + $serviceDatabase->refresh(); + updateCompose($serviceDatabase); + + if ($shouldStartProxy) { + StartDatabaseProxy::dispatch($serviceDatabase); + } elseif ($shouldStopProxy) { + StopDatabaseProxy::dispatch($serviceDatabase); + } + + auditLog('api.service_database.updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_database_uuid' => $serviceDatabase->uuid, + 'changed_fields' => array_keys($payload), + ]); + + return response()->json($this->removeSensitiveData($serviceDatabase)); + } + + #[OA\Get( + summary: 'Get service database logs', + description: 'Get Docker logs for a compose database container.', + path: '/services/{uuid}/databases/{database_uuid}/logs', + operationId: 'get-service-database-logs-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)), + ], + responses: [ + new OA\Response(response: 200, description: 'Logs.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'logs', type: 'string')])), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] + public function logs(Request $request): JsonResponse + { + $resolved = $this->resolveDatabaseRequest($request, 'view'); + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + [$serviceDatabase, $server] = $resolved; + $containerName = $serviceDatabase->name.'-'.$serviceDatabase->service->uuid; + if (getContainerStatus($server, $containerName) !== 'running') { + return response()->json(['message' => 'Service database container is not running.'], 400); + } + + $lines = (int) ($request->query('lines', 100) ?: 100); + + return response()->json([ + 'logs' => getContainerLogs($server, $containerName, $lines), + ]); + } + + #[OA\Post( + summary: 'Start or redeploy service database container', + description: 'Run docker compose up for a single compose database.', + path: '/services/{uuid}/databases/{database_uuid}/start', + operationId: 'start-service-database-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)), + new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)), + ], + responses: [ + new OA\Response(response: 200, description: 'Deploy request queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] + public function start(Request $request): JsonResponse + { + $resolved = $this->resolveDatabaseRequest($request, 'deploy'); + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + [$serviceDatabase] = $resolved; + DeployServiceApplication::dispatch( + $serviceDatabase, + $request->boolean('latest'), + $request->boolean('force'), + ); + + return response()->json(['message' => 'Service database deploy request queued.']); + } + + #[OA\Post( + summary: 'Restart service database container', + description: 'Restart a compose database container.', + path: '/services/{uuid}/databases/{database_uuid}/restart', + operationId: 'restart-service-database-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Restart queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] + public function restart(Request $request): JsonResponse + { + $resolved = $this->resolveDatabaseRequest($request, 'deploy'); + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + [$serviceDatabase] = $resolved; + RestartServiceApplication::dispatch($serviceDatabase); + + return response()->json(['message' => 'Service database restart request queued.']); + } + + #[OA\Post( + summary: 'Stop service database container', + description: 'Stop a compose database container.', + path: '/services/{uuid}/databases/{database_uuid}/stop', + operationId: 'stop-service-database-by-service-and-database-uuid', + security: [['bearerAuth' => []]], + tags: ['Service databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Stop queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 501, description: 'Swarm not supported.'), + ] + )] + public function stop(Request $request): JsonResponse + { + $resolved = $this->resolveDatabaseRequest($request, 'deploy'); + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + [$serviceDatabase] = $resolved; + StopServiceApplication::dispatch($serviceDatabase); + + return response()->json(['message' => 'Service database stop request queued.']); + } + + private function resolveDatabaseRequest(Request $request, string $ability): array|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceDatabase = $this->resolveServiceDatabase($request, $service); + if (! $serviceDatabase) { + return response()->json(['message' => 'Service database not found.'], 404); + } + + $this->authorize($ability, $serviceDatabase); + + $server = $serviceDatabase->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + if (! $server->isFunctional()) { + return response()->json(['message' => 'Server is not functional.'], 400); + } + + return [$serviceDatabase, $server]; + } +} diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 69801f985..603d11a7f 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -33,6 +33,13 @@ class ServiceDatabase extends BaseModel ]; protected $casts = [ + 'exclude_from_status' => 'boolean', + 'is_public' => 'boolean', + 'is_log_drain_enabled' => 'boolean', + 'is_include_timestamps' => 'boolean', + 'is_gzip_enabled' => 'boolean', + 'is_stripprefix_enabled' => 'boolean', + 'public_port' => 'integer', 'public_port_timeout' => 'integer', ]; diff --git a/app/Policies/ServiceDatabasePolicy.php b/app/Policies/ServiceDatabasePolicy.php index 88cb15115..8af168ab2 100644 --- a/app/Policies/ServiceDatabasePolicy.php +++ b/app/Policies/ServiceDatabasePolicy.php @@ -32,6 +32,14 @@ public function update(User $user, ServiceDatabase $serviceDatabase): bool return Gate::allows('update', $serviceDatabase->service); } + /** + * Determine whether the user can deploy or run lifecycle actions on the parent service stack. + */ + public function deploy(User $user, ServiceDatabase $serviceDatabase): bool + { + return Gate::allows('deploy', $serviceDatabase->service); + } + /** * Determine whether the user can delete the model. */ diff --git a/routes/api.php b/routes/api.php index e9fee3e55..a33af37c8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -16,6 +16,7 @@ use App\Http\Controllers\Api\SentinelController; use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServiceApplicationsController; +use App\Http\Controllers\Api\ServiceDatabasesController; use App\Http\Controllers\Api\ServicesController; use App\Http\Controllers\Api\TagsController; use App\Http\Controllers\Api\TeamController; @@ -245,6 +246,14 @@ Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/restart', [ServiceApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/stop', [ServiceApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); + Route::get('/services/{uuid}/databases', [ServiceDatabasesController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'update'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/databases/{database_uuid}/logs', [ServiceDatabasesController::class, 'logs'])->middleware(['api.ability:read']); + Route::post('/services/{uuid}/databases/{database_uuid}/start', [ServiceDatabasesController::class, 'start'])->middleware(['api.ability:deploy']); + Route::post('/services/{uuid}/databases/{database_uuid}/restart', [ServiceDatabasesController::class, 'restart'])->middleware(['api.ability:deploy']); + Route::post('/services/{uuid}/databases/{database_uuid}/stop', [ServiceDatabasesController::class, 'stop'])->middleware(['api.ability:deploy']); + Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); diff --git a/tests/Feature/ServiceDatabasesApiTest.php b/tests/Feature/ServiceDatabasesApiTest.php new file mode 100644 index 000000000..f0e033e92 --- /dev/null +++ b/tests/Feature/ServiceDatabasesApiTest.php @@ -0,0 +1,165 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->bearerToken = createBearerTokenForServiceDatabasesApiTest($this->user, $this->team); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function createBearerTokenForServiceDatabasesApiTest(User $user, Team $team, array $abilities = ['*']): string +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +function createServiceWithDatabaseForApiTest(object $context): object +{ + $service = Service::factory()->create([ + 'environment_id' => $context->environment->id, + 'server_id' => $context->server->id, + 'destination_id' => $context->destination->id, + 'destination_type' => $context->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n postgres:\n image: postgres:17-alpine\n", + ]); + + $serviceDatabase = ServiceDatabase::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'postgres', + 'service_id' => $service->id, + 'image' => 'postgres:17-alpine', + 'is_public' => false, + ]); + + return (object) ['service' => $service, 'serviceDatabase' => $serviceDatabase]; +} + +test('lists and gets databases belonging to a service', function () { + $context = createServiceWithDatabaseForApiTest($this); + $headers = ['Authorization' => 'Bearer '.$this->bearerToken]; + + $this->withHeaders($headers) + ->getJson("/api/v1/services/{$context->service->uuid}/databases") + ->assertOk() + ->assertJsonFragment(['uuid' => $context->serviceDatabase->uuid]); + + $this->withHeaders($headers) + ->getJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}") + ->assertOk() + ->assertJsonFragment(['name' => 'postgres']); +}); + +test('does not return a database from another service', function () { + $context = createServiceWithDatabaseForApiTest($this); + $otherContext = createServiceWithDatabaseForApiTest($this); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) + ->getJson("/api/v1/services/{$context->service->uuid}/databases/{$otherContext->serviceDatabase->uuid}") + ->assertNotFound() + ->assertJsonFragment(['message' => 'Service database not found.']); +}); + +test('updates supported service database fields and rejects unknown fields', function () { + $context = createServiceWithDatabaseForApiTest($this); + $url = "/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}"; + $headers = ['Authorization' => 'Bearer '.$this->bearerToken]; + + $this->withHeaders($headers)->patchJson($url, [ + 'human_name' => 'Primary database', + 'description' => 'Stores production data', + 'public_port' => 5432, + 'public_port_timeout' => 1800, + 'exclude_from_status' => true, + ])->assertOk()->assertJsonFragment([ + 'human_name' => 'Primary database', + 'public_port' => 5432, + ]); + + $context->serviceDatabase->refresh(); + expect($context->serviceDatabase->human_name)->toBe('Primary database') + ->and($context->serviceDatabase->public_port_timeout)->toBe(1800) + ->and($context->serviceDatabase->exclude_from_status)->toBeTrue(); + + $this->withHeaders($headers) + ->patchJson($url, ['unsupported' => true]) + ->assertUnprocessable() + ->assertJsonPath('errors.unsupported.0', 'This field is not allowed.'); +}); + +test('queues lifecycle actions for a service database', function () { + $context = createServiceWithDatabaseForApiTest($this); + $baseUrl = "/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}"; + $headers = ['Authorization' => 'Bearer '.$this->bearerToken]; + + $this->withHeaders($headers)->postJson("{$baseUrl}/start?latest=1&force=1") + ->assertOk() + ->assertJsonFragment(['message' => 'Service database deploy request queued.']); + DeployServiceApplication::assertPushed(); + + $this->withHeaders($headers)->postJson("{$baseUrl}/restart") + ->assertOk() + ->assertJsonFragment(['message' => 'Service database restart request queued.']); + RestartServiceApplication::assertPushed(); + + $this->withHeaders($headers)->postJson("{$baseUrl}/stop") + ->assertOk() + ->assertJsonFragment(['message' => 'Service database stop request queued.']); + StopServiceApplication::assertPushed(); +}); + +test('requires deploy ability for service database lifecycle actions', function () { + $context = createServiceWithDatabaseForApiTest($this); + $writeToken = createBearerTokenForServiceDatabasesApiTest($this->user, $this->team, ['write']); + + $this->withHeaders(['Authorization' => 'Bearer '.$writeToken]) + ->postJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}/restart") + ->assertForbidden(); +}); + +test('returns a clear error when logs cannot be read from the server', function () { + $context = createServiceWithDatabaseForApiTest($this); + $this->server->settings->update(['is_reachable' => false]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) + ->getJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}/logs") + ->assertBadRequest() + ->assertJsonFragment(['message' => 'Server is not functional.']); +}); diff --git a/tests/Unit/ApplicationOpenApiTest.php b/tests/Unit/ApplicationOpenApiTest.php new file mode 100644 index 000000000..549aee854 --- /dev/null +++ b/tests/Unit/ApplicationOpenApiTest.php @@ -0,0 +1,27 @@ +getAttributes(Schema::class)[0] + ->newInstance(); + + $openApi = json_decode( + file_get_contents(__DIR__.'/../../openapi.json'), + true, + flags: JSON_THROW_ON_ERROR, + ); + + $settingsProperty = collect($schema->properties) + ->first(fn (mixed $property): bool => $property instanceof Property + && $property->property === 'settings'); + + expect($settingsProperty) + ->not->toBeNull() + ->and($openApi['components']['schemas']['Application']['properties']) + ->toHaveKey('settings') + ->not->toHaveKey(''); +}); diff --git a/tests/Unit/ServiceApplicationsOpenApiTest.php b/tests/Unit/ServiceApplicationsOpenApiTest.php new file mode 100644 index 000000000..dec4d10c4 --- /dev/null +++ b/tests/Unit/ServiceApplicationsOpenApiTest.php @@ -0,0 +1,32 @@ +toHaveKeys(['get', 'post']) + ->and($openApi['paths'][$path]['post']['responses']) + ->toHaveKeys(['200', '400', '401', '404', '501']); + } + + expect($openApi['paths'][$actionPaths[0]]['post']['responses']['200']['content']['application/json']['schema']['properties']) + ->toHaveKey('logs') + ->and($openApi['paths'][$actionPaths[1]]['post']['responses']['200']['content']['application/json']['schema']['properties']) + ->toHaveKey('message') + ->and($openApi['paths'][$actionPaths[2]]['post']['responses']['200']['content']['application/json']['schema']['properties']) + ->toHaveKey('message') + ->and($openApi['paths'][$actionPaths[3]]['post']['responses']['200']['content']['application/json']['schema']['properties']) + ->toHaveKey('message'); +}); diff --git a/tests/Unit/ServiceDatabasesOpenApiTest.php b/tests/Unit/ServiceDatabasesOpenApiTest.php new file mode 100644 index 000000000..1fb2b143d --- /dev/null +++ b/tests/Unit/ServiceDatabasesOpenApiTest.php @@ -0,0 +1,29 @@ +toHaveKey('/services/{uuid}/databases') + ->toHaveKey('/services/{uuid}/databases/{database_uuid}') + ->toHaveKey('/services/{uuid}/databases/{database_uuid}/logs') + ->toHaveKey('/services/{uuid}/databases/{database_uuid}/start') + ->toHaveKey('/services/{uuid}/databases/{database_uuid}/restart') + ->toHaveKey('/services/{uuid}/databases/{database_uuid}/stop') + ->and($openApi['paths']['/services/{uuid}/databases']['get']) + ->toBeArray() + ->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}']) + ->toHaveKeys(['get', 'patch']) + ->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/logs']['get']) + ->toBeArray() + ->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/start']['post']) + ->toBeArray() + ->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/restart']['post']) + ->toBeArray() + ->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/stop']['post']) + ->toBeArray(); +}); diff --git a/tests/Unit/ServicesOpenApiTest.php b/tests/Unit/ServicesOpenApiTest.php new file mode 100644 index 000000000..1786ce1a6 --- /dev/null +++ b/tests/Unit/ServicesOpenApiTest.php @@ -0,0 +1,28 @@ +getAttributes(Patch::class)[0]->newInstance(); + $documentedProperties = $patch->requestBody->content[0]->schema->properties; + + $openApi = json_decode( + file_get_contents(__DIR__.'/../../openapi.json'), + true, + flags: JSON_THROW_ON_ERROR, + ); + $generatedProperties = $openApi['paths']['/services/{uuid}']['patch']['requestBody']['content']['application/json']['schema']['properties']; + + $unsupportedFields = [ + 'project_uuid', + 'environment_name', + 'environment_uuid', + 'server_uuid', + 'destination_uuid', + ]; + + expect(array_keys($generatedProperties))->toBe(array_keys($documentedProperties)) + ->and(array_intersect($unsupportedFields, array_keys($documentedProperties)))->toBeEmpty(); +});