feat(api): expose runtime logs for preview deployments (#11884)

This commit is contained in:
Andras Bacsai
2026-09-18 14:41:55 +02:00
committed by GitHub
parent 19949a6f6f
commit 23f24f8425
5 changed files with 296 additions and 1 deletions
@@ -2455,6 +2455,59 @@ class ApplicationsController extends Controller
),
]
)]
#[OA\Get(
summary: 'Get preview application logs.',
description: 'Get runtime container logs for a preview deployment by application UUID and pull request ID.',
path: '/applications/{uuid}/previews/{pull_request_id}/logs',
operationId: 'get-preview-application-logs-by-pull-request-id',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string'),
),
new OA\Parameter(
name: 'pull_request_id',
in: 'path',
description: 'Pull request ID of the preview deployment.',
required: true,
schema: new OA\Schema(type: 'integer', minimum: 1),
),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
required: false,
schema: new OA\Schema(oneOf: [
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
new OA\Schema(type: 'string', enum: ['all']),
])
),
new OA\Parameter(
name: 'show_timestamps',
in: 'query',
description: 'Show timestamps in the logs.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false),
),
],
responses: [
new OA\Response(response: 200, description: 'Preview runtime logs.', content: new OA\JsonContent(
type: 'object',
properties: [new OA\Property(property: 'logs', type: 'string')],
)),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function logs_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
@@ -2470,7 +2523,25 @@ class ApplicationsController extends Controller
return response()->json(['message' => 'Application not found.'], 404);
}
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id);
$this->authorize('view', $application);
$pullRequestId = null;
$pullRequestIdRaw = $request->route('pull_request_id');
if ($pullRequestIdRaw !== null) {
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
}
$pullRequestId = (int) $pullRequestIdRaw;
$previewExists = ApplicationPreview::where('application_id', $application->id)
->where('pull_request_id', $pullRequestId)
->exists();
if (! $previewExists) {
return response()->json(['message' => 'Preview not found.'], 404);
}
}
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pullRequestId);
if ($containers->count() == 0) {
return response()->json([
+98
View File
@@ -3508,6 +3508,104 @@
]
}
},
"\/applications\/{uuid}\/previews\/{pull_request_id}\/logs": {
"get": {
"tags": [
"Applications"
],
"summary": "Get preview application logs.",
"description": "Get runtime container logs for a preview deployment by application UUID and pull request ID.",
"operationId": "get-preview-application-logs-by-pull-request-id",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "pull_request_id",
"in": "path",
"description": "Pull request ID of the preview deployment.",
"required": true,
"schema": {
"type": "integer",
"minimum": 1
}
},
{
"name": "lines",
"in": "query",
"description": "Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.",
"required": false,
"schema": {
"oneOf": [
{
"type": "integer",
"format": "int32",
"default": 100,
"maximum": 10000,
"minimum": -1
},
{
"type": "string",
"enum": [
"all"
]
}
]
}
},
{
"name": "show_timestamps",
"in": "query",
"description": "Show timestamps in the logs.",
"required": false,
"schema": {
"type": "boolean",
"default": false
}
}
],
"responses": {
"200": {
"description": "Preview runtime logs.",
"content": {
"application\/json": {
"schema": {
"properties": {
"logs": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/applications\/{uuid}\/envs": {
"get": {
"tags": [
+68
View File
@@ -2307,6 +2307,74 @@ paths:
security:
-
bearerAuth: []
'/applications/{uuid}/previews/{pull_request_id}/logs':
get:
tags:
- Applications
summary: 'Get preview application logs.'
description: 'Get runtime container logs for a preview deployment by application UUID and pull request ID.'
operationId: get-preview-application-logs-by-pull-request-id
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
-
name: pull_request_id
in: path
description: 'Pull request ID of the preview deployment.'
required: true
schema:
type: integer
minimum: 1
-
name: lines
in: query
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.'
required: false
schema:
oneOf:
-
type: integer
format: int32
default: 100
maximum: 10000
minimum: -1
-
type: string
enum:
- all
-
name: show_timestamps
in: query
description: 'Show timestamps in the logs.'
required: false
schema:
type: boolean
default: false
responses:
'200':
description: 'Preview runtime logs.'
content:
application/json:
schema:
properties:
logs: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/applications/{uuid}/envs':
get:
tags:
+1
View File
@@ -244,6 +244,7 @@ Route::group([
Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
Route::get('/applications/{uuid}/previews/{pull_request_id}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']);
Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']);
@@ -5,6 +5,7 @@ use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -13,6 +14,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
@@ -133,6 +135,61 @@ describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', functi
});
});
describe('GET /api/v1/applications/{uuid}/previews/{pull_request_id}/logs', function () {
test('returns runtime logs from the selected preview container', function () {
createPreview($this->application, 42);
$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: json_encode([
'ID' => 'preview-container',
'Names' => "{$this->application->uuid}-pr-42",
'Labels' => "coolify.applicationId={$this->application->id},coolify.pullRequestId=42",
]));
}
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: 'preview runtime log');
}
return Process::result();
});
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/previews/42/logs")
->assertOk()
->assertJson(['logs' => 'preview runtime log']);
});
test('returns 404 when the preview does not exist', function () {
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/previews/42/logs")
->assertNotFound()
->assertJson(['message' => 'Preview not found.']);
});
test('rejects an invalid pull request id', function () {
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/previews/not-a-number/logs")
->assertUnprocessable()
->assertJson(['message' => 'Invalid pull_request_id.']);
});
test('uses the pull request id to select the preview container', function () {
$controller = file_get_contents(app_path('Http/Controllers/Api/ApplicationsController.php'));
$openApi = json_decode(file_get_contents(base_path('openapi.json')), true, flags: JSON_THROW_ON_ERROR);
expect($controller)
->toContain("\$request->route('pull_request_id')")
->toContain('getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pullRequestId)')
->and($openApi['paths'])
->toHaveKey('/applications/{uuid}/previews/{pull_request_id}/logs');
});
});
describe('PATCH /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
test('stores preview domain ports separately from portless public domains', function () {
$preview = createPreview($this->application, 42);