Skip to content

Commit d13a155

Browse files
authored
feat(x2a): added /projects/:projectId/modules/:moduleId/log endpoint (#2249)
- Openapi definition and generated files. - Router defitinition: - Rbac - Check with project/module - Check if the job has been finished. - k8s integration if not finished with streaming support. - Test for the router. Signed-off-by: Eloy Coto <eloy.coto@acalustra.com>
1 parent 0a7c742 commit d13a155

12 files changed

Lines changed: 785 additions & 3 deletions

File tree

workspaces/x2a/plugins/x2a-backend/src/router.test.ts

Lines changed: 435 additions & 0 deletions
Large diffs are not rendered by default.

workspaces/x2a/plugins/x2a-backend/src/router.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,102 @@ export async function createRouter({
520520
},
521521
);
522522

523+
router.get('/projects/:projectId/modules/:moduleId/log', async (req, res) => {
524+
const endpoint = 'GET /projects/:projectId/modules/:moduleId/log';
525+
const { projectId, moduleId } = req.params;
526+
const streaming = req.query.streaming === true;
527+
const phase = req.query.phase as string | undefined;
528+
529+
// Validate phase parameter (required)
530+
if (!phase) {
531+
throw new InputError('phase query parameter is required');
532+
}
533+
534+
logger.info(
535+
`${endpoint} request: projectId=${projectId}, moduleId=${moduleId}, streaming=${streaming}, phase=${phase}`,
536+
);
537+
538+
// Get credentials and permissions
539+
const credentials = await httpAuth.credentials(req, { allow: ['user'] });
540+
const canViewAll = await isUserOfAdminViewPermission(
541+
req as unknown as Request,
542+
permissionsSvc,
543+
httpAuth,
544+
);
545+
546+
// Verify project exists and user has access
547+
const project = await x2aDatabase.getProject(
548+
{ projectId },
549+
{ credentials, canViewAll },
550+
);
551+
if (!project) {
552+
throw new NotFoundError(`Project not found`);
553+
}
554+
555+
// Verify module exists
556+
const module = await x2aDatabase.getModule({ id: moduleId });
557+
if (!module) {
558+
throw new NotFoundError(`Module not found`);
559+
}
560+
561+
// Verify module belongs to project
562+
if (module.projectId !== projectId) {
563+
throw new NotFoundError(`Module does not belong to project`);
564+
}
565+
566+
// Get latest job for module filtered by requested phase
567+
const jobs = await x2aDatabase.listJobs({
568+
moduleId,
569+
phase: phase as any,
570+
});
571+
572+
if (jobs.length === 0) {
573+
throw new NotFoundError(`No jobs found for module with phase '${phase}'`);
574+
}
575+
576+
const latestJob = jobs[0]; // Already sorted by started_at DESC in listJobs
577+
578+
// Validate the latest job phase matches requested phase (sanity check)
579+
if (latestJob.phase !== phase) {
580+
throw new InputError(
581+
`Latest job phase '${latestJob.phase}' does not match requested phase '${phase}'`,
582+
);
583+
}
584+
585+
// If job is finished, return logs from database
586+
if (latestJob.status === 'success' || latestJob.status === 'error') {
587+
logger.info(
588+
`Job ${latestJob.id} is finished (status: ${latestJob.status}), returning logs from database`,
589+
);
590+
res.setHeader('Content-Type', 'text/plain');
591+
res.send(latestJob.log || '');
592+
return;
593+
}
594+
595+
// Check if job has k8sJobName
596+
if (!latestJob.k8sJobName) {
597+
logger.warn(
598+
`Job ${latestJob.id} has no k8sJobName, returning empty logs`,
599+
);
600+
res.setHeader('Content-Type', 'text/plain');
601+
res.send('');
602+
return;
603+
}
604+
605+
// Get logs from Kubernetes
606+
const logs = await kubeService.getJobLogs(latestJob.k8sJobName, streaming);
607+
608+
// Set content type
609+
res.setHeader('Content-Type', 'text/plain');
610+
611+
// Handle streaming vs non-streaming
612+
if (streaming && typeof logs !== 'string') {
613+
logs.pipe(res);
614+
} else {
615+
res.send(logs as string);
616+
}
617+
});
618+
523619
// TODO: Implement /collectArtifacts endpoints for callback from Kubernetes jobs
524620
// These endpoints should use Backstage service-to-service authentication with static tokens
525621
// See: https://backstage.io/docs/auth/service-to-service-auth#static-tokens

workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,43 @@ paths:
305305
- jobId
306306
'404':
307307
description: Project or module not found
308+
/projects/{projectId}/modules/{moduleId}/log:
309+
get:
310+
summary: Returns logs for the latest job of a module
311+
parameters:
312+
- in: path
313+
name: projectId
314+
schema:
315+
type: string
316+
required: true
317+
description: Project UUID
318+
- in: path
319+
name: moduleId
320+
schema:
321+
type: string
322+
required: true
323+
description: Module UUID
324+
- in: query
325+
name: streaming
326+
schema:
327+
type: boolean
328+
required: false
329+
description: Whether to stream logs (text/plain) or return all at once
330+
- in: query
331+
name: phase
332+
schema:
333+
$ref: '#/components/schemas/Phase'
334+
required: true
335+
description: Migration phase to filter
336+
responses:
337+
'200':
338+
description: Module logs
339+
content:
340+
text/plain:
341+
schema:
342+
type: string
343+
'404':
344+
description: Module not found or no jobs exist
308345
components:
309346
schemas:
310347
Project:
@@ -404,3 +441,11 @@ components:
404441
required:
405442
- url
406443
- orgName
444+
445+
Phase:
446+
type: string
447+
enum:
448+
- analyze
449+
- migrate
450+
- publish
451+
description: Migration phases to execute

workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
2121
// ******************************************************************
2222
import { Module } from '../models/Module.model';
23+
import { Phase } from '../models/Phase.model';
2324
import { Project } from '../models/Project.model';
2425
import { ProjectsGet200Response } from '../models/ProjectsGet200Response.model';
2526
import { ProjectsPostRequest } from '../models/ProjectsPostRequest.model';
@@ -72,6 +73,20 @@ export type ProjectsProjectIdGet = {
7273
};
7374
response: Project | void;
7475
};
76+
/**
77+
* @public
78+
*/
79+
export type ProjectsProjectIdModulesModuleIdLogGet = {
80+
path: {
81+
projectId: string;
82+
moduleId: string;
83+
};
84+
query: {
85+
streaming?: boolean;
86+
phase: Phase;
87+
};
88+
response: string | void;
89+
};
7590
/**
7691
* @public
7792
*/
@@ -113,6 +128,8 @@ export type EndpointMap = {
113128

114129
'#get|/projects/{projectId}': ProjectsProjectIdGet;
115130

131+
'#get|/projects/{projectId}/modules/{moduleId}/log': ProjectsProjectIdModulesModuleIdLogGet;
132+
116133
'#post|/projects/{projectId}/modules/{moduleId}/run': ProjectsProjectIdModulesModuleIdRunPost;
117134

118135
'#post|/projects/{projectId}/modules': ProjectsProjectIdModulesPost;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
// ******************************************************************
18+
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
19+
// ******************************************************************
20+
21+
/**
22+
* @public
23+
*/
24+
export type Phase = 'analyze' | 'migrate' | 'publish';

workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
export * from '../models/AAPCredentials.model';
1818
export * from '../models/GitRepoAuth.model';
1919
export * from '../models/Module.model';
20+
export * from '../models/Phase.model';
2021
export * from '../models/Project.model';
2122
export * from '../models/ProjectsGet200Response.model';
2223
export * from '../models/ProjectsPostRequest.model';

workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,64 @@ export const spec = {
466466
}
467467
}
468468
}
469+
},
470+
"/projects/{projectId}/modules/{moduleId}/log": {
471+
"get": {
472+
"summary": "Returns logs for the latest job of a module",
473+
"parameters": [
474+
{
475+
"in": "path",
476+
"name": "projectId",
477+
"schema": {
478+
"type": "string"
479+
},
480+
"required": true,
481+
"description": "Project UUID"
482+
},
483+
{
484+
"in": "path",
485+
"name": "moduleId",
486+
"schema": {
487+
"type": "string"
488+
},
489+
"required": true,
490+
"description": "Module UUID"
491+
},
492+
{
493+
"in": "query",
494+
"name": "streaming",
495+
"schema": {
496+
"type": "boolean"
497+
},
498+
"required": false,
499+
"description": "Whether to stream logs (text/plain) or return all at once"
500+
},
501+
{
502+
"in": "query",
503+
"name": "phase",
504+
"schema": {
505+
"$ref": "#/components/schemas/Phase"
506+
},
507+
"required": true,
508+
"description": "Migration phase to filter"
509+
}
510+
],
511+
"responses": {
512+
"200": {
513+
"description": "Module logs",
514+
"content": {
515+
"text/plain": {
516+
"schema": {
517+
"type": "string"
518+
}
519+
}
520+
}
521+
},
522+
"404": {
523+
"description": "Module not found or no jobs exist"
524+
}
525+
}
526+
}
469527
}
470528
},
471529
"components": {
@@ -594,6 +652,15 @@ export const spec = {
594652
"url",
595653
"orgName"
596654
]
655+
},
656+
"Phase": {
657+
"type": "string",
658+
"enum": [
659+
"analyze",
660+
"migrate",
661+
"publish"
662+
],
663+
"description": "Migration phases to execute"
597664
}
598665
}
599666
}

workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -463,20 +463,31 @@ export class X2ADatabaseService {
463463
};
464464
}
465465

466-
async listJobs({ moduleId }: { moduleId: string }): Promise<Job[]> {
466+
async listJobs({
467+
moduleId,
468+
phase = null,
469+
}: {
470+
moduleId: string;
471+
phase?: MigrationPhase | null;
472+
}): Promise<Job[]> {
467473
this.#logger.info(`listJobs called for moduleId: ${moduleId}`);
468474

469475
// Fetch all jobs for the given module
470476
const rows = await this.#dbClient('jobs')
471477
.where('module_id', moduleId)
478+
.modify(queryBuilder => {
479+
if (phase) {
480+
queryBuilder.where('phase', phase);
481+
}
482+
})
472483
.select('*')
473484
.orderBy('started_at', 'desc');
474485

475486
if (rows.length === 0) {
476487
return [];
477488
}
478489

479-
const jobIds = rows.map(row => row.id);
490+
const jobIds = rows.map((row: any) => row.id);
480491

481492
// Fetch all artifacts for these jobs in a single query
482493
const artifactRows = await this.#dbClient('artifacts')
@@ -494,7 +505,7 @@ export class X2ADatabaseService {
494505
}
495506

496507
// Build jobs with their artifacts
497-
const jobs: Job[] = rows.map(row => {
508+
const jobs: Job[] = rows.map((row: any) => {
498509
const job = this.mapRowToJob(row);
499510
return {
500511
...job,

0 commit comments

Comments
 (0)