From 1082b598c450a249a9e9ca7982a1ae8093936e28 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:03:52 +0100 Subject: [PATCH] fix(api): return logs of stopped containers like the UI The log endpoints found containers with `docker ps -a`, then rejected every container that was not running. Remove that check, so failed deployments can be diagnosed through the API. Without service_name, the application endpoint prefers a running container. The old error stays when no container exists. Closes #9972 Co-Authored-By: Claude Opus 5.5 --- .../Api/ApplicationsController.php | 15 +-- .../Controllers/Api/DatabasesController.php | 7 - .../Api/ServiceApplicationsController.php | 3 +- .../Api/ServiceDatabasesController.php | 2 +- .../Controllers/Api/ServicesController.php | 7 - openapi.json | 4 +- openapi.yaml | 4 +- .../Api/StoppedContainerLogsApiTest.php | 125 ++++++++++++++++++ tests/Feature/ApplicationPreviewApiTest.php | 2 +- 9 files changed, 136 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/Api/StoppedContainerLogsApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index d5114dd7a3..7b280b45fd 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2640,7 +2640,7 @@ class ApplicationsController extends Controller 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.', + description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.', required: false, schema: new OA\Schema(type: 'string'), ), @@ -2719,7 +2719,7 @@ class ApplicationsController extends Controller 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.', + description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.', required: false, schema: new OA\Schema(type: 'string'), ), @@ -2786,20 +2786,13 @@ class ApplicationsController extends Controller if (! $matchingContainer) { return response()->json([ - 'message' => "No running container found for service_name '{$serviceName}'.", + 'message' => "No container found for service_name '{$serviceName}'.", ], 404); } $container = $matchingContainer; } else { - $container = $containers->first(); - } - - $status = getContainerStatus($application->destination->server, $container['Names']); - if ($status !== 'running') { - return response()->json([ - 'message' => 'Application is not running.', - ], 400); + $container = $containers->firstWhere('State', 'running') ?? $containers->first(); } $lines = normalizeLogLines($request->query('lines')); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 106216f7c9..93c89c0ed1 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2898,13 +2898,6 @@ class DatabasesController extends Controller $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 = sanitizeLogsForExport( diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index f6147855aa..bffff06ff2 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -533,8 +533,7 @@ class ServiceApplicationsController extends Controller $containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid; - $status = getContainerStatus($server, $containerName); - if ($status !== 'running') { + if (! is_array(getContainerStatus($server, $containerName, all_data: true))) { return response()->json([ 'message' => 'Service application container is not running.', ], 400); diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index fb4ad6d892..ad666fa5bc 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -405,7 +405,7 @@ class ServiceDatabasesController extends Controller [$serviceDatabase, $server] = $resolved; $containerName = $serviceDatabase->name.'-'.$serviceDatabase->service->uuid; - if (getContainerStatus($server, $containerName) !== 'running') { + if (! is_array(getContainerStatus($server, $containerName, all_data: true))) { return response()->json(['message' => 'Service database container is not running.'], 400); } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 8cb4235d6a..a4064f451d 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -993,13 +993,6 @@ class ServicesController extends Controller 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 = sanitizeLogsForExport( diff --git a/openapi.json b/openapi.json index 2640e3d628..6ed44cd78d 100644 --- a/openapi.json +++ b/openapi.json @@ -4343,7 +4343,7 @@ { "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.", + "description": "Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.", "required": false, "schema": { "type": "string" @@ -4447,7 +4447,7 @@ { "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.", + "description": "Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.", "required": false, "schema": { "type": "string" diff --git a/openapi.yaml b/openapi.yaml index 2340bff7d9..5e5476fcae 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2881,7 +2881,7 @@ paths: - 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.' + description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.' required: false schema: type: string @@ -2954,7 +2954,7 @@ paths: - 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.' + description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.' required: false schema: type: string diff --git a/tests/Feature/Api/StoppedContainerLogsApiTest.php b/tests/Feature/Api/StoppedContainerLogsApiTest.php new file mode 100644 index 0000000000..0ad0b3f65d --- /dev/null +++ b/tests/Feature/Api/StoppedContainerLogsApiTest.php @@ -0,0 +1,125 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $this->token = $user->createToken('stopped-container-logs', ['read', 'read:sensitive'], teamId: $team->id)->plainTextToken; + + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $destinationAttributes = ['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]; + + $this->application = Application::factory()->create(['environment_id' => $environment->id, ...$destinationAttributes]); + $this->database = StandalonePostgresql::query()->create([ + 'name' => 'stopped-database', + 'environment_id' => $environment->id, + 'postgres_password' => 'secret', + ...$destinationAttributes, + ]); + $this->service = Service::factory()->create(['environment_id' => $environment->id, 'server_id' => $server->id, ...$destinationAttributes]); + $this->serviceApplication = ServiceApplication::create(['name' => 'web', 'service_id' => $this->service->id]); + $this->serviceDatabase = ServiceDatabase::create(['name' => 'db', 'service_id' => $this->service->id]); +}); + +/** + * @param list> $containers `docker ps -a` rows + */ +function fakeDockerWithContainers(array $containers): void +{ + Process::fake(function ($process) use ($containers) { + $script = remoteProcessScript($process); + + return match (true) { + str_contains($script, 'docker ps -a') => Process::result(output: collect($containers)->map(fn ($row) => json_encode($row))->implode(PHP_EOL)), + str_contains($script, 'docker inspect') => $containers === [] + ? Process::result(errorOutput: 'Error: No such object', exitCode: 1) + : Process::result(output: json_encode(['State' => ['Status' => $containers[0]['State']]])), + str_contains($script, 'docker logs') => Process::result(output: str_contains($script, 'running-id') ? 'running container output' : 'stopped container output'), + default => Process::result(), + }; + }); +} + +dataset('log endpoints', [ + 'application' => [ + fn () => "/api/v1/applications/{$this->application->uuid}/logs", + fn () => ['Labels' => "coolify.applicationId={$this->application->id}"], + 'Application is not running.', + ], + 'database' => [ + fn () => "/api/v1/databases/{$this->database->uuid}/logs", + fn () => ['Labels' => "coolify.databaseUuid={$this->database->uuid}"], + 'Database is not running.', + ], + 'service sub-container' => [ + fn () => "/api/v1/services/{$this->service->uuid}/logs?sub_service_name=web", + fn () => ['Labels' => "coolify.name=web-{$this->service->uuid}"], + 'Container not found.', + ], + 'service application' => [ + fn () => "/api/v1/services/{$this->service->uuid}/applications/{$this->serviceApplication->uuid}/logs", + fn () => [], + 'Service application container is not running.', + ], + 'service database' => [ + fn () => "/api/v1/services/{$this->service->uuid}/databases/{$this->serviceDatabase->uuid}/logs", + fn () => [], + 'Service database container is not running.', + ], +]); + +it('returns the logs of a stopped container', function (string $url, array $container) { + fakeDockerWithContainers([['ID' => 'stopped-id', 'Names' => 'stopped-name', 'State' => 'exited', ...$container]]); + + $this->withToken($this->token)->getJson($url) + ->assertOk() + ->assertExactJson(['logs' => 'stopped container output']); +})->with('log endpoints'); + +it('keeps the error when no container exists', function (string $url, array $container, string $message) { + fakeDockerWithContainers([]); + + $this->withToken($this->token)->getJson($url) + ->assertJsonPath('message', $message); + + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'docker logs')); +})->with('log endpoints'); + +it('prefers the running application container over a stopped one', function () { + $labels = "coolify.applicationId={$this->application->id}"; + fakeDockerWithContainers([ + ['ID' => 'stopped-id', 'Names' => 'stopped-name', 'State' => 'exited', 'Labels' => $labels], + ['ID' => 'running-id', 'Names' => 'running-name', 'State' => 'running', 'Labels' => $labels], + ]); + + $this->withToken($this->token)->getJson("/api/v1/applications/{$this->application->uuid}/logs") + ->assertOk() + ->assertExactJson(['logs' => 'running container output']); +}); diff --git a/tests/Feature/ApplicationPreviewApiTest.php b/tests/Feature/ApplicationPreviewApiTest.php index bd1939ee97..03c554440b 100644 --- a/tests/Feature/ApplicationPreviewApiTest.php +++ b/tests/Feature/ApplicationPreviewApiTest.php @@ -498,7 +498,7 @@ describe('GET /api/v1/applications/{uuid}/logs service_name', 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'."]); + ->assertJson(['message' => "No container found for service_name 'work'."]); }); });