From 23f24f842537dac91cd31824df2c89d42409a77b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:41:55 +0200 Subject: [PATCH] feat(api): expose runtime logs for preview deployments (#11884) --- .../Api/ApplicationsController.php | 73 +++++++++++++- openapi.json | 98 +++++++++++++++++++ openapi.yaml | 68 +++++++++++++ routes/api.php | 1 + tests/Feature/ApplicationPreviewApiTest.php | 57 +++++++++++ 5 files changed, 296 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 8cf61ef69b..15c0abf241 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -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([ diff --git a/openapi.json b/openapi.json index ce08cb3d9b..500ffdd58b 100644 --- a/openapi.json +++ b/openapi.json @@ -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": [ diff --git a/openapi.yaml b/openapi.yaml index 62888a0b0f..393aa2e60e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: diff --git a/routes/api.php b/routes/api.php index e7ee9d4ec2..9834a078d1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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']); diff --git a/tests/Feature/ApplicationPreviewApiTest.php b/tests/Feature/ApplicationPreviewApiTest.php index a994833990..829259fae3 100644 --- a/tests/Feature/ApplicationPreviewApiTest.php +++ b/tests/Feature/ApplicationPreviewApiTest.php @@ -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);