Skip to content

Commit 15684fd

Browse files
authored
feat(api): add endpoint to retrieve database and service logs (#6293)
2 parents b6a4c73 + ff5cfd4 commit 15684fd

8 files changed

Lines changed: 672 additions & 10 deletions

File tree

app/Http/Controllers/Api/ApplicationsController.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2017,6 +2017,13 @@ public function application_by_uuid(Request $request)
20172017
default: 100,
20182018
)
20192019
),
2020+
new OA\Parameter(
2021+
name: 'show_timestamps',
2022+
in: 'query',
2023+
description: 'Show timestamps in the logs.',
2024+
required: false,
2025+
schema: new OA\Schema(type: 'boolean', default: false),
2026+
),
20202027
],
20212028
responses: [
20222029
new OA\Response(
@@ -2080,8 +2087,9 @@ public function logs_by_uuid(Request $request)
20802087
], 400);
20812088
}
20822089

2083-
$lines = $request->query->get('lines', 100) ?: 100;
2084-
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines);
2090+
$lines = normalizeLogLines($request->query('lines'));
2091+
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
2092+
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps);
20852093

20862094
return response()->json([
20872095
'logs' => $logs,

app/Http/Controllers/Api/DatabasesController.php

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2247,6 +2247,116 @@ public function create_database(Request $request, NewDatabaseTypes $type)
22472247
return response()->json(['message' => 'Invalid database type requested.'], 400);
22482248
}
22492249

2250+
#[OA\Get(
2251+
summary: 'Get database logs.',
2252+
description: 'Get database logs by UUID.',
2253+
path: '/databases/{uuid}/logs',
2254+
operationId: 'get-database-logs-by-uuid',
2255+
security: [
2256+
['bearerAuth' => []],
2257+
],
2258+
tags: ['Databases'],
2259+
parameters: [
2260+
new OA\Parameter(
2261+
name: 'uuid',
2262+
in: 'path',
2263+
description: 'UUID of the database.',
2264+
required: true,
2265+
schema: new OA\Schema(
2266+
type: 'string',
2267+
format: 'uuid',
2268+
)
2269+
),
2270+
new OA\Parameter(
2271+
name: 'lines',
2272+
in: 'query',
2273+
description: 'Number of lines to show from the end of the logs.',
2274+
required: false,
2275+
schema: new OA\Schema(
2276+
type: 'integer',
2277+
format: 'int32',
2278+
default: 100,
2279+
)
2280+
),
2281+
new OA\Parameter(
2282+
name: 'show_timestamps',
2283+
in: 'query',
2284+
description: 'Show timestamps in the logs.',
2285+
required: false,
2286+
schema: new OA\Schema(type: 'boolean', default: false),
2287+
),
2288+
],
2289+
responses: [
2290+
new OA\Response(
2291+
response: 200,
2292+
description: 'Get database logs by UUID.',
2293+
content: [
2294+
new OA\MediaType(
2295+
mediaType: 'application/json',
2296+
schema: new OA\Schema(
2297+
type: 'object',
2298+
properties: [
2299+
'logs' => ['type' => 'string'],
2300+
]
2301+
)
2302+
),
2303+
]
2304+
),
2305+
new OA\Response(
2306+
response: 401,
2307+
ref: '#/components/responses/401',
2308+
),
2309+
new OA\Response(
2310+
response: 400,
2311+
ref: '#/components/responses/400',
2312+
),
2313+
new OA\Response(
2314+
response: 404,
2315+
ref: '#/components/responses/404',
2316+
),
2317+
]
2318+
)]
2319+
public function logs_by_uuid(Request $request)
2320+
{
2321+
$teamId = getTeamIdFromToken();
2322+
if (is_null($teamId)) {
2323+
return invalidTokenResponse();
2324+
}
2325+
$uuid = $request->route('uuid');
2326+
if (! $uuid) {
2327+
return response()->json(['message' => 'UUID is required.'], 400);
2328+
}
2329+
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
2330+
if (! $database) {
2331+
return response()->json(['message' => 'Database not found.'], 404);
2332+
}
2333+
2334+
$containers = getCurrentDatabaseContainerStatus($database->destination->server, $database->id);
2335+
2336+
if ($containers->count() == 0) {
2337+
return response()->json([
2338+
'message' => 'Database is not running.',
2339+
], 400);
2340+
}
2341+
2342+
$container = $containers->first();
2343+
2344+
$status = getContainerStatus($database->destination->server, $container['Names']);
2345+
if ($status !== 'running') {
2346+
return response()->json([
2347+
'message' => 'Database is not running.',
2348+
], 400);
2349+
}
2350+
2351+
$lines = normalizeLogLines($request->query('lines'));
2352+
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
2353+
$logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps);
2354+
2355+
return response()->json([
2356+
'logs' => $logs,
2357+
]);
2358+
}
2359+
22502360
#[OA\Delete(
22512361
summary: 'Delete',
22522362
description: 'Delete database by UUID.',

app/Http/Controllers/Api/ServicesController.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,125 @@ public function service_by_uuid(Request $request)
731731
return response()->json($this->removeSensitiveData($service));
732732
}
733733

734+
#[OA\Get(
735+
summary: 'Get service logs.',
736+
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}`.',
737+
path: '/services/{uuid}/logs',
738+
operationId: 'get-service-logs-by-uuid',
739+
security: [
740+
['bearerAuth' => []],
741+
],
742+
tags: ['Services'],
743+
parameters: [
744+
new OA\Parameter(
745+
name: 'uuid',
746+
in: 'path',
747+
description: 'UUID of the service.',
748+
required: true,
749+
schema: new OA\Schema(
750+
type: 'string',
751+
format: 'uuid',
752+
)
753+
),
754+
new OA\Parameter(
755+
name: 'sub_service_name',
756+
in: 'query',
757+
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.',
758+
required: true,
759+
schema: new OA\Schema(type: 'string', example: 'appwrite-console'),
760+
),
761+
new OA\Parameter(
762+
name: 'lines',
763+
in: 'query',
764+
description: 'Number of lines to show from the end of the logs.',
765+
required: false,
766+
schema: new OA\Schema(
767+
type: 'integer',
768+
format: 'int32',
769+
default: 100,
770+
)
771+
),
772+
new OA\Parameter(
773+
name: 'show_timestamps',
774+
in: 'query',
775+
description: 'Show timestamps in the logs.',
776+
required: false,
777+
schema: new OA\Schema(type: 'boolean', default: false),
778+
),
779+
],
780+
responses: [
781+
new OA\Response(
782+
response: 200,
783+
description: 'Get service logs by UUID.',
784+
content: [
785+
new OA\MediaType(
786+
mediaType: 'application/json',
787+
schema: new OA\Schema(
788+
type: 'object',
789+
properties: [
790+
'logs' => ['type' => 'string'],
791+
]
792+
)
793+
),
794+
]
795+
),
796+
new OA\Response(
797+
response: 401,
798+
ref: '#/components/responses/401',
799+
),
800+
new OA\Response(
801+
response: 400,
802+
ref: '#/components/responses/400',
803+
),
804+
new OA\Response(
805+
response: 404,
806+
ref: '#/components/responses/404',
807+
),
808+
]
809+
)]
810+
public function logs_by_uuid(Request $request)
811+
{
812+
$teamId = getTeamIdFromToken();
813+
if (is_null($teamId)) {
814+
return invalidTokenResponse();
815+
}
816+
$uuid = $request->route('uuid');
817+
if (! $uuid) {
818+
return response()->json(['message' => 'UUID is required.'], 400);
819+
}
820+
$subServiceName = $request->query->get('sub_service_name');
821+
if (! $subServiceName) {
822+
return response()->json(['message' => 'Sub service name is required.'], 400);
823+
}
824+
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
825+
if (! $service) {
826+
return response()->json(['message' => 'Service not found.'], 404);
827+
}
828+
829+
$name = "{$subServiceName}-{$service->uuid}";
830+
$containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name);
831+
$container = $containers->first();
832+
833+
if (! $container) {
834+
return response()->json(['message' => 'Container not found.'], 404);
835+
}
836+
837+
$status = getContainerStatus($service->destination->server, $container['Names']);
838+
if ($status !== 'running') {
839+
return response()->json([
840+
'message' => 'Container is not running.',
841+
], 400);
842+
}
843+
844+
$lines = normalizeLogLines($request->query('lines'));
845+
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
846+
$logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps);
847+
848+
return response()->json([
849+
'logs' => $logs,
850+
]);
851+
}
852+
734853
#[OA\Delete(
735854
summary: 'Delete',
736855
description: 'Delete service by UUID.',

bootstrap/helpers/docker.php

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,36 @@ function getCurrentServiceContainerStatus(Server $server, int $id): Collection
7272
return $containers;
7373
}
7474

75+
function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection
76+
{
77+
$containers = collect([]);
78+
if (! $server->isSwarm()) {
79+
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.databaseId={$id}' --format '{{json .}}' "], $server);
80+
$containers = format_docker_command_output_to_json($containers);
81+
82+
return $containers->filter();
83+
}
84+
85+
return $containers;
86+
}
87+
88+
function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection
89+
{
90+
return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $id), $name);
91+
}
92+
93+
function filterServiceSubContainersByName(Collection $containers, string $name): Collection
94+
{
95+
return $containers->filter(function ($container) use ($name) {
96+
$labels = data_get($container, 'Labels', []);
97+
if (is_string($labels)) {
98+
$labels = format_docker_labels_to_json($labels);
99+
}
100+
101+
return collect($labels)->get('coolify.name') === $name;
102+
})->values();
103+
}
104+
75105
function format_docker_command_output_to_json($rawOutput): Collection
76106
{
77107
$outputLines = explode(PHP_EOL, $rawOutput);
@@ -1247,18 +1277,38 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
12471277
}
12481278
}
12491279

1250-
function getContainerLogs(Server $server, string $container_id, int $lines = 100): string
1280+
function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int
1281+
{
1282+
$lines = filter_var($lines, FILTER_VALIDATE_INT);
1283+
if ($lines === false || $lines <= 0) {
1284+
return $default;
1285+
}
1286+
1287+
return min($lines, $max);
1288+
}
1289+
1290+
function parseLogTimestampFlag(mixed $showTimestamps): bool
1291+
{
1292+
return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
1293+
}
1294+
1295+
function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
12511296
{
1297+
$command = "docker logs -n {$lines}";
12521298
if ($server->isSwarm()) {
1253-
$output = instant_remote_process([
1254-
"docker service logs -n {$lines} {$container_id} 2>&1",
1255-
], $server);
1256-
} else {
1257-
$output = instant_remote_process([
1258-
"docker logs -n {$lines} {$container_id} 2>&1",
1259-
], $server);
1299+
$command = "docker service logs -n {$lines}";
1300+
}
1301+
1302+
if ($showTimestamps) {
1303+
$command .= ' --timestamps';
12601304
}
12611305

1306+
return "{$command} ".escapeshellarg($container_id).' 2>&1';
1307+
}
1308+
1309+
function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
1310+
{
1311+
$output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server);
12621312
$output = removeAnsiColors($output);
12631313

12641314
return $output;

0 commit comments

Comments
 (0)