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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-27 17:03:52 +01:00
co-authored by Claude Opus 5.5
parent 69bc72a3b5
commit 1082b598c4
9 changed files with 136 additions and 33 deletions
@@ -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'));
@@ -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(
@@ -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);
@@ -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);
}
@@ -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(
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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
@@ -0,0 +1,125 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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<array<string, string>> $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']);
});
+1 -1
View File
@@ -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'."]);
});
});