mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
69bc72a3b5
commit
1082b598c4
@@ -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
@@ -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
@@ -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']);
|
||||
});
|
||||
@@ -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'."]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user