fix(api): normalize log endpoint query handling

Clamp log line counts, parse timestamp flags consistently, and filter
service subcontainers by Coolify labels. Document log endpoint timestamp
parameters and database/service log routes in OpenAPI.
This commit is contained in:
Andras Bacsai 2026-07-06 23:58:12 +02:00
parent adc4b3091f
commit ff5cfd4253
7 changed files with 417 additions and 19 deletions

View file

@ -2087,8 +2087,8 @@ public function logs_by_uuid(Request $request)
], 400); ], 400);
} }
$lines = $request->query->get('lines', 100); $lines = normalizeLogLines($request->query('lines'));
$showTimestamps = $request->query->get('show_timestamps', false); $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps); $logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([ return response()->json([

View file

@ -2246,6 +2246,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(['message' => 'Invalid database type requested.'], 400); return response()->json(['message' => 'Invalid database type requested.'], 400);
} }
#[OA\Get( #[OA\Get(
summary: 'Get database logs.', summary: 'Get database logs.',
description: 'Get database logs by UUID.', description: 'Get database logs by UUID.',
@ -2347,8 +2348,8 @@ public function logs_by_uuid(Request $request)
], 400); ], 400);
} }
$lines = $request->query->get('lines', 100); $lines = normalizeLogLines($request->query('lines'));
$showTimestamps = $request->query->get('show_timestamps', false); $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps); $logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([ return response()->json([

View file

@ -733,7 +733,7 @@ public function service_by_uuid(Request $request)
#[OA\Get( #[OA\Get(
summary: 'Get service logs.', summary: 'Get service logs.',
description: 'Get service logs by UUID.', 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', path: '/services/{uuid}/logs',
operationId: 'get-service-logs-by-uuid', operationId: 'get-service-logs-by-uuid',
security: [ security: [
@ -754,9 +754,9 @@ public function service_by_uuid(Request $request)
new OA\Parameter( new OA\Parameter(
name: 'sub_service_name', name: 'sub_service_name',
in: 'query', in: 'query',
description: 'Sub service name.', 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, required: true,
schema: new OA\Schema(type: 'string'), schema: new OA\Schema(type: 'string', example: 'appwrite-console'),
), ),
new OA\Parameter( new OA\Parameter(
name: 'lines', name: 'lines',
@ -841,8 +841,8 @@ public function logs_by_uuid(Request $request)
], 400); ], 400);
} }
$lines = $request->query->get('lines', 100); $lines = normalizeLogLines($request->query('lines'));
$showTimestamps = $request->query->get('show_timestamps', false); $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps); $logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([ return response()->json([

View file

@ -87,15 +87,19 @@ function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection
function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection
{ {
$containers = collect([]); return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $id), $name);
if (! $server->isSwarm()) { }
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --filter='label=coolify.name={$name}' --format '{{json .}}' "], $server);
$containers = format_docker_command_output_to_json($containers);
return $containers->filter(); 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 $containers; return collect($labels)->get('coolify.name') === $name;
})->values();
} }
function format_docker_command_output_to_json($rawOutput): Collection function format_docker_command_output_to_json($rawOutput): Collection
@ -1273,7 +1277,22 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
} }
} }
function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int
{
$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}"; $command = "docker logs -n {$lines}";
if ($server->isSwarm()) { if ($server->isSwarm()) {
@ -1284,7 +1303,12 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
$command .= ' --timestamps'; $command .= ' --timestamps';
} }
$output = instant_remote_process(["{$command} {$container_id} 2>&1"], $server); 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); $output = removeAnsiColors($output);
return $output; return $output;

View file

@ -2675,6 +2675,16 @@
"format": "int32", "format": "int32",
"default": 100 "default": 100
} }
},
{
"name": "show_timestamps",
"in": "query",
"description": "Show timestamps in the logs.",
"required": false,
"schema": {
"type": "boolean",
"default": false
}
} }
], ],
"responses": { "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}": { "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions\/{execution_uuid}": {
"delete": { "delete": {
"tags": [ "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": { "\/services\/{uuid}\/envs": {
"get": { "get": {
"tags": [ "tags": [

View file

@ -1716,6 +1716,14 @@ paths:
type: integer type: integer
format: int32 format: int32
default: 100 default: 100
-
name: show_timestamps
in: query
description: 'Show timestamps in the logs.'
required: false
schema:
type: boolean
default: false
responses: responses:
'200': '200':
description: 'Get application logs by UUID.' description: 'Get application logs by UUID.'
@ -3908,6 +3916,57 @@ paths:
security: security:
- -
bearerAuth: [] 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}': '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}':
delete: delete:
tags: tags:
@ -7150,6 +7209,65 @@ paths:
security: security:
- -
bearerAuth: [] 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': '/services/{uuid}/envs':
get: get:
tags: tags:

View file

@ -0,0 +1,87 @@
<?php
use App\Models\Server;
it('normalizes requested log line counts', function () {
if (! function_exists('normalizeLogLines')) {
expect(function_exists('normalizeLogLines'))->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}');
});