From 8bbab724e2bc15194b077e5001d52e07c036eb23 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:27:11 +0200 Subject: [PATCH] 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 --- .../Api/ApplicationsController.php | 14 +++-- tests/Feature/ApplicationPreviewApiTest.php | 52 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 2149081e78..ffa7c2aff9 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -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) { diff --git a/tests/Feature/ApplicationPreviewApiTest.php b/tests/Feature/ApplicationPreviewApiTest.php index bd00eba0e0..0078b5538c 100644 --- a/tests/Feature/ApplicationPreviewApiTest.php +++ b/tests/Feature/ApplicationPreviewApiTest.php @@ -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'."]); + }); +});