diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 790fdf200..127130530 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2017,6 +2017,13 @@ public function application_by_uuid(Request $request) default: 100, ) ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), ], responses: [ new OA\Response( @@ -2080,8 +2087,9 @@ public function logs_by_uuid(Request $request) ], 400); } - $lines = $request->query->get('lines', 100) ?: 100; - $logs = getContainerLogs($application->destination->server, $container['ID'], $lines); + $lines = normalizeLogLines($request->query('lines')); + $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); + $logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps); return response()->json([ 'logs' => $logs, diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 912f81728..a9f12006f 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2247,6 +2247,116 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(['message' => 'Invalid database type requested.'], 400); } + #[OA\Get( + summary: 'Get database logs.', + description: 'Get database logs by UUID.', + path: '/databases/{uuid}/logs', + operationId: 'get-database-logs-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema( + type: 'integer', + format: 'int32', + default: 100, + ) + ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get database logs by UUID.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function logs_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $containers = getCurrentDatabaseContainerStatus($database->destination->server, $database->id); + + if ($containers->count() == 0) { + return response()->json([ + 'message' => 'Database is not running.', + ], 400); + } + + $container = $containers->first(); + + $status = getContainerStatus($database->destination->server, $container['Names']); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Database is not running.', + ], 400); + } + + $lines = normalizeLogLines($request->query('lines')); + $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); + $logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps); + + return response()->json([ + 'logs' => $logs, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete database by UUID.', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 97fd41c5c..3b37e73b9 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -731,6 +731,125 @@ public function service_by_uuid(Request $request) return response()->json($this->removeSensitiveData($service)); } + #[OA\Get( + summary: 'Get service logs.', + description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.', + path: '/services/{uuid}/logs', + operationId: 'get-service-logs-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'sub_service_name', + in: 'query', + description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.', + required: true, + schema: new OA\Schema(type: 'string', example: 'appwrite-console'), + ), + 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, + ) + ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get service logs by UUID.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function logs_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $subServiceName = $request->query->get('sub_service_name'); + if (! $subServiceName) { + return response()->json(['message' => 'Sub service name is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $name = "{$subServiceName}-{$service->uuid}"; + $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name); + $container = $containers->first(); + + if (! $container) { + return response()->json(['message' => 'Container not found.'], 404); + } + + $status = getContainerStatus($service->destination->server, $container['Names']); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Container is not running.', + ], 400); + } + + $lines = normalizeLogLines($request->query('lines')); + $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); + $logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps); + + return response()->json([ + 'logs' => $logs, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete service by UUID.', diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 2f7cc95ef..e6643a337 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -72,6 +72,36 @@ function getCurrentServiceContainerStatus(Server $server, int $id): Collection return $containers; } +function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection +{ + $containers = collect([]); + if (! $server->isSwarm()) { + $containers = instant_remote_process(["docker ps -a --filter='label=coolify.databaseId={$id}' --format '{{json .}}' "], $server); + $containers = format_docker_command_output_to_json($containers); + + return $containers->filter(); + } + + return $containers; +} + +function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection +{ + return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $id), $name); +} + +function filterServiceSubContainersByName(Collection $containers, string $name): Collection +{ + return $containers->filter(function ($container) use ($name) { + $labels = data_get($container, 'Labels', []); + if (is_string($labels)) { + $labels = format_docker_labels_to_json($labels); + } + + return collect($labels)->get('coolify.name') === $name; + })->values(); +} + function format_docker_command_output_to_json($rawOutput): Collection { $outputLines = explode(PHP_EOL, $rawOutput); @@ -1247,18 +1277,38 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable } } -function getContainerLogs(Server $server, string $container_id, int $lines = 100): string +function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int { - if ($server->isSwarm()) { - $output = instant_remote_process([ - "docker service logs -n {$lines} {$container_id} 2>&1", - ], $server); - } else { - $output = instant_remote_process([ - "docker logs -n {$lines} {$container_id} 2>&1", - ], $server); + $lines = filter_var($lines, FILTER_VALIDATE_INT); + if ($lines === false || $lines <= 0) { + return $default; } + return min($lines, $max); +} + +function parseLogTimestampFlag(mixed $showTimestamps): bool +{ + return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; +} + +function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +{ + $command = "docker logs -n {$lines}"; + if ($server->isSwarm()) { + $command = "docker service logs -n {$lines}"; + } + + if ($showTimestamps) { + $command .= ' --timestamps'; + } + + return "{$command} ".escapeshellarg($container_id).' 2>&1'; +} + +function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +{ + $output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server); $output = removeAnsiColors($output); return $output; diff --git a/openapi.json b/openapi.json index ca445ade0..816cdb535 100644 --- a/openapi.json +++ b/openapi.json @@ -2675,6 +2675,16 @@ "format": "int32", "default": 100 } + }, + { + "name": "show_timestamps", + "in": "query", + "description": "Show timestamps in the logs.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { @@ -5952,6 +5962,80 @@ ] } }, + "\/databases\/{uuid}\/logs": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Get database logs.", + "description": "Get database logs by UUID.", + "operationId": "get-database-logs-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "lines", + "in": "query", + "description": "Number of lines to show from the end of the logs.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "show_timestamps", + "in": "query", + "description": "Show timestamps in the logs.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Get database logs by UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions\/{execution_uuid}": { "delete": { "tags": [ @@ -11293,6 +11377,90 @@ ] } }, + "\/services\/{uuid}\/logs": { + "get": { + "tags": [ + "Services" + ], + "summary": "Get service logs.", + "description": "Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET \/services\/{uuid}`.", + "operationId": "get-service-logs-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "sub_service_name", + "in": "query", + "description": "Sub-service name from `GET \/services\/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.", + "required": true, + "schema": { + "type": "string", + "example": "appwrite-console" + } + }, + { + "name": "lines", + "in": "query", + "description": "Number of lines to show from the end of the logs.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "show_timestamps", + "in": "query", + "description": "Show timestamps in the logs.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Get service logs by UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/services\/{uuid}\/envs": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index 6182cacd3..dacacece1 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1716,6 +1716,14 @@ paths: type: integer format: int32 default: 100 + - + name: show_timestamps + in: query + description: 'Show timestamps in the logs.' + required: false + schema: + type: boolean + default: false responses: '200': description: 'Get application logs by UUID.' @@ -3908,6 +3916,57 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/logs': + get: + tags: + - Databases + summary: 'Get database logs.' + description: 'Get database logs by UUID.' + operationId: get-database-logs-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + format: uuid + - + name: lines + in: query + description: 'Number of lines to show from the end of the logs.' + required: false + schema: + type: integer + format: int32 + default: 100 + - + name: show_timestamps + in: query + description: 'Show timestamps in the logs.' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Get database logs by UUID.' + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}': delete: tags: @@ -7150,6 +7209,65 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/logs': + get: + tags: + - Services + summary: 'Get service logs.' + description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.' + operationId: get-service-logs-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + format: uuid + - + name: sub_service_name + in: query + description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.' + required: true + schema: + type: string + example: appwrite-console + - + name: lines + in: query + description: 'Number of lines to show from the end of the logs.' + required: false + schema: + type: integer + format: int32 + default: 100 + - + name: show_timestamps + in: query + description: 'Show timestamps in the logs.' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Get service logs by UUID.' + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] '/services/{uuid}/envs': get: tags: diff --git a/routes/api.php b/routes/api.php index e44070d25..a0034f0c6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -160,6 +160,7 @@ Route::post('/databases/{uuid}/backups', [DatabasesController::class, 'create_backup'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/logs', [DatabasesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); Route::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']); @@ -195,6 +196,7 @@ Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); 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']); diff --git a/tests/Unit/Api/LogEndpointHelpersTest.php b/tests/Unit/Api/LogEndpointHelpersTest.php new file mode 100644 index 000000000..0b0107e73 --- /dev/null +++ b/tests/Unit/Api/LogEndpointHelpersTest.php @@ -0,0 +1,87 @@ +toBeTrue(); + + return; + } + + expect(normalizeLogLines(null))->toBe(100) + ->and(normalizeLogLines(''))->toBe(100) + ->and(normalizeLogLines('abc'))->toBe(100) + ->and(normalizeLogLines('0'))->toBe(100) + ->and(normalizeLogLines('-5'))->toBe(100) + ->and(normalizeLogLines('50'))->toBe(50) + ->and(normalizeLogLines('50000'))->toBe(10000); +}); + +it('parses show_timestamps query values as booleans', function () { + if (! function_exists('parseLogTimestampFlag')) { + expect(function_exists('parseLogTimestampFlag'))->toBeTrue(); + + return; + } + + expect(parseLogTimestampFlag('true'))->toBeTrue() + ->and(parseLogTimestampFlag('1'))->toBeTrue() + ->and(parseLogTimestampFlag(true))->toBeTrue() + ->and(parseLogTimestampFlag('false'))->toBeFalse() + ->and(parseLogTimestampFlag('0'))->toBeFalse() + ->and(parseLogTimestampFlag(false))->toBeFalse() + ->and(parseLogTimestampFlag('not-a-bool'))->toBeFalse() + ->and(parseLogTimestampFlag(null))->toBeFalse(); +}); + +it('builds docker log commands with options before an escaped container id', function () { + if (! function_exists('buildContainerLogsCommand')) { + expect(function_exists('buildContainerLogsCommand'))->toBeTrue(); + + return; + } + + $server = new Server; + $server->settings = ['is_swarm_manager' => false]; + + expect(buildContainerLogsCommand($server, 'container-1', 25, true)) + ->toBe("docker logs -n 25 --timestamps 'container-1' 2>&1"); +}); + +it('builds swarm service log commands with options before an escaped service id', function () { + if (! function_exists('buildContainerLogsCommand')) { + expect(function_exists('buildContainerLogsCommand'))->toBeTrue(); + + return; + } + + $server = new Server; + $server->settings = ['is_swarm_manager' => true]; + + expect(buildContainerLogsCommand($server, "service'name", 25, true)) + ->toBe("docker service logs -n 25 --timestamps 'service'\\''name' 2>&1"); +}); + +it('filters service sub containers in PHP instead of using user input in shell filters', function () { + if (! function_exists('filterServiceSubContainersByName')) { + expect(function_exists('filterServiceSubContainersByName'))->toBeTrue(); + + return; + } + + $containers = collect([ + ['ID' => 'first', 'Labels' => 'coolify.serviceId=10,coolify.name=app-service-uuid,coolify.type=service'], + ['ID' => 'second', 'Labels' => 'coolify.serviceId=10,coolify.name=db-service-uuid,coolify.type=service'], + ['ID' => 'third', 'Labels' => ['coolify.name' => 'app-service-uuid']], + ]); + + expect(filterServiceSubContainersByName($containers, 'app-service-uuid')->pluck('ID')->all()) + ->toBe(['first', 'third']); +}); + +it('does not interpolate the requested service name into the docker ps shell command', function () { + $source = file_get_contents(__DIR__.'/../../../bootstrap/helpers/docker.php'); + + expect($source)->not->toContain('coolify.name={$name}'); +});