fix(api): harden service_name filter for application logs

- Match the Compose service label with format_docker_labels_to_json()
  and skip containers without labels.
- Document service_name on the preview logs endpoint and remove the
  incorrect "ignored for non-Compose applications" claim. Every
  application runs as a Compose service, so a mismatch returns 404.
- Add regression tests for service selection, the default container,
  and the exact-match 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-01 15:27:11 +02:00
co-authored by Claude Opus 5.5
parent 6023f4974e
commit 8bbab724e2
2 changed files with 61 additions and 5 deletions
@@ -2481,7 +2481,7 @@ class ApplicationsController extends Controller
new OA\Parameter(
name: 'service_name',
in: 'query',
description: 'For Docker Compose applications, return logs only from the Compose service with this name. Returns 404 when no running container matches. Ignored for non-Compose applications.',
description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.',
required: false,
schema: new OA\Schema(type: 'string'),
),
@@ -2557,6 +2557,13 @@ class ApplicationsController extends Controller
required: false,
schema: new OA\Schema(type: 'boolean', default: false),
),
new OA\Parameter(
name: 'service_name',
in: 'query',
description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.',
required: false,
schema: new OA\Schema(type: 'string'),
),
],
responses: [
new OA\Response(response: 200, description: 'Preview runtime logs.', content: new OA\JsonContent(
@@ -2615,10 +2622,7 @@ class ApplicationsController extends Controller
$matchingContainer = $containers->first(function ($container) use ($serviceName) {
$labels = data_get($container, 'Labels');
return $labels !== null && preg_match(
'/(?:^|,)com\.docker\.compose\.service='.preg_quote($serviceName, '/').'(?:,|$)/',
$labels
) === 1;
return filled($labels) && format_docker_labels_to_json($labels)->get('com.docker.compose.service') === $serviceName;
});
if (! $matchingContainer) {
@@ -448,3 +448,55 @@ describe('PATCH /api/v1/applications/{uuid}/previews/{pull_request_id}', functio
->assertJsonValidationErrors('docker_compose_domains');
});
});
describe('GET /api/v1/applications/{uuid}/logs service_name', function () {
beforeEach(function () {
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server->update(['private_key_id' => $privateKey->id]);
Process::fake(function ($process) {
if (str_contains($process->command, 'docker ps -a')) {
return Process::result(output: implode(PHP_EOL, [
json_encode([
'ID' => 'web-container',
'Names' => "web-{$this->application->uuid}",
'Labels' => "coolify.applicationId={$this->application->id},com.docker.compose.service=web",
]),
json_encode([
'ID' => 'worker-container',
'Names' => "worker-{$this->application->uuid}",
'Labels' => "coolify.applicationId={$this->application->id},com.docker.compose.service=worker",
]),
]));
}
if (str_contains($process->command, 'docker inspect')) {
return Process::result(output: json_encode(['State' => ['Status' => 'running']]));
}
if (str_contains($process->command, 'docker logs')) {
return Process::result(output: str_contains($process->command, 'worker-container') ? 'worker log' : 'web log');
}
return Process::result();
});
});
test('returns logs from the container of the requested service', function () {
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/logs?service_name=worker")
->assertOk()
->assertJson(['logs' => 'worker log']);
});
test('returns logs from the first container without service_name', function () {
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/logs")
->assertOk()
->assertJson(['logs' => 'web log']);
});
test('returns 404 when no container matches service_name exactly', function () {
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/logs?service_name=work")
->assertNotFound()
->assertJson(['message' => "No running container found for service_name 'work'."]);
});
});