Skip to content

Commit 2a40df0

Browse files
mareklibraeloycoto
andauthored
feat(x2a): add action to resync project migration (#3196)
* feat(x2a): add action to resync project migration Signed-off-by: Marek Libra <marek.libra@gmail.com> * split syncModules --------- Signed-off-by: Marek Libra <marek.libra@gmail.com> Co-authored-by: Eloy Coto <eloy.coto@acalustra.com>
1 parent f90fb92 commit 2a40df0

68 files changed

Lines changed: 2502 additions & 410 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-x2a-mcp-extras': minor
3+
'@red-hat-developer-hub/backstage-plugin-x2a-backend': minor
4+
'@red-hat-developer-hub/backstage-plugin-x2a-common': minor
5+
'@red-hat-developer-hub/backstage-plugin-x2a-node': minor
6+
'@red-hat-developer-hub/backstage-plugin-x2a': minor
7+
---
8+
9+
The user can newly update the project migration plan in an external flow and then let the X2A resync the module list changes.

workspaces/x2a/plugins/x2a-backend/README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,7 @@ The plugin now properly handles AAP credentials from both system-wide configurat
196196

197197
- **Fixed authentication**: All project lookup operations now properly pass user credentials for authorization checks
198198
- **Consistent permissions**: The following endpoints now correctly validate user permissions:
199-
- `POST /projects/:projectId/run` (init phase)
200-
- `POST /projects/:projectId/modules` (create module)
199+
- `POST /projects/:projectId/run` (init phase, including migration plan resync via `refresh: true`)
201200
- `POST /projects/:projectId/modules/:moduleId/run` (analyze/migrate/publish phases)
202201

203202
## API Usage Examples

workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdModulesPostRequest.model.ts renamed to workspaces/x2a/plugins/x2a-backend/migrations/2026051900_add_modules_removed_at.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,26 @@
1414
* limitations under the License.
1515
*/
1616

17-
// ******************************************************************
18-
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
19-
// ******************************************************************
17+
import type { Knex } from 'knex';
2018

2119
/**
20+
* Adds removed_at column to modules table for soft-delete support.
21+
*
22+
* @public
23+
*/
24+
export async function up(knex: Knex): Promise<void> {
25+
await knex.schema.alterTable('modules', table => {
26+
table.timestamp('removed_at').nullable();
27+
});
28+
}
29+
30+
/**
31+
* Drops the removed_at column from modules table.
32+
*
2233
* @public
2334
*/
24-
export interface ProjectsProjectIdModulesPostRequest {
25-
/**
26-
* Module name
27-
*/
28-
name: string;
29-
/**
30-
* Path to the module in the source repository
31-
*/
32-
sourcePath: string;
35+
export async function down(knex: Knex): Promise<void> {
36+
await knex.schema.alterTable('modules', table => {
37+
table.dropColumn('removed_at');
38+
});
3339
}

workspaces/x2a/plugins/x2a-backend/src/__testUtils__/routerHelpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ export interface MockRouterDeps {
244244
createModule: jest.Mock;
245245
getModule: jest.Mock;
246246
deleteModule: jest.Mock;
247+
softDeleteModule: jest.Mock;
248+
restoreModule: jest.Mock;
249+
updateModule: jest.Mock;
247250
listJobs: jest.Mock;
248251
listJobsForProject: jest.Mock;
249252
listJobsForModule: jest.Mock;
@@ -289,6 +292,9 @@ export function createMockRouterDeps(): MockRouterDeps {
289292
createModule: jest.fn().mockResolvedValue({ id: 'mock-module-id' }),
290293
getModule: jest.fn(),
291294
deleteModule: jest.fn().mockResolvedValue(1),
295+
softDeleteModule: jest.fn().mockResolvedValue(1),
296+
restoreModule: jest.fn().mockResolvedValue(1),
297+
updateModule: jest.fn().mockResolvedValue(1),
292298
listJobs: jest.fn(),
293299
listJobsForProject: jest.fn(),
294300
listJobsForModule: jest.fn(),

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ const getX2aDatabaseServiceMock = (): typeof x2aDatabaseServiceRef.T => ({
120120
// modules
121121
createModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
122122
deleteModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
123+
softDeleteModule: jest
124+
.fn()
125+
.mockRejectedValue(new NotAllowedError('mock error')),
126+
restoreModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
127+
updateModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
123128
listModules: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
124129
getModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
125130
// jobs

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

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ describe('collectArtifacts routes (actions & signatures)', () => {
150150
mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined);
151151
mockDeps.x2aDatabase.listModules.mockResolvedValue(existingModules);
152152
mockDeps.x2aDatabase.createModule.mockResolvedValue({ id: randomUUID() });
153-
mockDeps.x2aDatabase.deleteModule.mockResolvedValue(1);
153+
mockDeps.x2aDatabase.softDeleteModule.mockResolvedValue(1);
154154

155155
const requestBody = { status: 'success', jobId, artifacts };
156156
const signature = signRequestBody(requestBody, callbackToken);
@@ -168,12 +168,75 @@ describe('collectArtifacts routes (actions & signatures)', () => {
168168
projectId,
169169
technology: undefined,
170170
});
171-
expect(mockDeps.x2aDatabase.deleteModule).toHaveBeenCalledTimes(1);
172-
expect(mockDeps.x2aDatabase.deleteModule).toHaveBeenCalledWith({
171+
expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledTimes(1);
172+
expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledWith({
173173
id: 'existing-2',
174174
});
175175
});
176176

177+
it('should soft-delete a module in success state when removed from metadata during resync', async () => {
178+
const metadataModules = [
179+
{ name: 'still-in-plan', path: '/cookbooks/still' },
180+
];
181+
const artifacts = [
182+
{
183+
id: randomUUID(),
184+
type: 'project_metadata',
185+
value: JSON.stringify(metadataModules),
186+
},
187+
];
188+
189+
const existingModules = [
190+
{
191+
id: 'module-success',
192+
name: 'removed-from-plan',
193+
sourcePath: '/cookbooks/removed',
194+
projectId,
195+
status: 'success',
196+
analyze: { id: 'job-a', status: 'success', phase: 'analyze' },
197+
},
198+
{
199+
id: 'module-kept',
200+
name: 'still-in-plan',
201+
sourcePath: '/cookbooks/still',
202+
projectId,
203+
},
204+
];
205+
206+
const job: Job & { callbackToken?: string } = {
207+
id: jobId,
208+
projectId,
209+
moduleId: undefined,
210+
phase: 'init',
211+
status: 'running',
212+
startedAt: new Date(),
213+
k8sJobName,
214+
callbackToken,
215+
};
216+
217+
mockDeps.x2aDatabase.getJob.mockResolvedValue(job);
218+
mockDeps.kubeService.getJobLogs.mockResolvedValue('logs');
219+
mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined);
220+
mockDeps.x2aDatabase.listModules.mockResolvedValue(existingModules);
221+
mockDeps.x2aDatabase.softDeleteModule.mockResolvedValue(1);
222+
223+
const requestBody = { status: 'success', jobId, artifacts };
224+
const signature = signRequestBody(requestBody, callbackToken);
225+
226+
const res = await request(app)
227+
.post(`/projects/${projectId}/collectArtifacts?phase=init`)
228+
.set('X-Callback-Signature', signature)
229+
.send(requestBody);
230+
231+
expect(res.status).toBe(200);
232+
expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledTimes(1);
233+
expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledWith({
234+
id: 'module-success',
235+
});
236+
expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled();
237+
expect(mockDeps.x2aDatabase.restoreModule).not.toHaveBeenCalled();
238+
});
239+
177240
it('should not trigger phase actions when no project_metadata artifact', async () => {
178241
const artifacts = [
179242
{
@@ -291,6 +354,100 @@ describe('collectArtifacts routes (actions & signatures)', () => {
291354
expect(mockDeps.x2aDatabase.listModules).not.toHaveBeenCalled();
292355
expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled();
293356
});
357+
358+
it('should restore previously soft-deleted modules when they reappear in metadata', async () => {
359+
const metadataModules = [
360+
{ name: 'restored-module', path: '/cookbooks/restored' },
361+
];
362+
const artifacts = [
363+
{
364+
id: randomUUID(),
365+
type: 'project_metadata',
366+
value: JSON.stringify(metadataModules),
367+
},
368+
];
369+
370+
const existingModules = [
371+
{
372+
id: 'existing-removed',
373+
name: 'restored-module',
374+
sourcePath: '/cookbooks/restored',
375+
projectId,
376+
removedAt: new Date('2026-01-01T00:00:00Z'),
377+
},
378+
];
379+
380+
const job: Job & { callbackToken?: string } = {
381+
id: jobId,
382+
projectId,
383+
moduleId: undefined,
384+
phase: 'init',
385+
status: 'running',
386+
startedAt: new Date(),
387+
k8sJobName,
388+
callbackToken,
389+
};
390+
391+
mockDeps.x2aDatabase.getJob.mockResolvedValue(job);
392+
mockDeps.kubeService.getJobLogs.mockResolvedValue('logs');
393+
mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined);
394+
mockDeps.x2aDatabase.listModules.mockResolvedValue(existingModules);
395+
mockDeps.x2aDatabase.restoreModule.mockResolvedValue(1);
396+
397+
const requestBody = { status: 'success', jobId, artifacts };
398+
const signature = signRequestBody(requestBody, callbackToken);
399+
400+
const res = await request(app)
401+
.post(`/projects/${projectId}/collectArtifacts?phase=init`)
402+
.set('X-Callback-Signature', signature)
403+
.send(requestBody);
404+
405+
expect(res.status).toBe(200);
406+
expect(mockDeps.x2aDatabase.restoreModule).toHaveBeenCalledTimes(1);
407+
expect(mockDeps.x2aDatabase.restoreModule).toHaveBeenCalledWith({
408+
id: 'existing-removed',
409+
});
410+
expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled();
411+
expect(mockDeps.x2aDatabase.softDeleteModule).not.toHaveBeenCalled();
412+
});
413+
414+
it('should handle malformed project_metadata JSON gracefully', async () => {
415+
const artifacts = [
416+
{
417+
id: randomUUID(),
418+
type: 'project_metadata',
419+
value: 'not valid json {{{',
420+
},
421+
];
422+
423+
const job: Job & { callbackToken?: string } = {
424+
id: jobId,
425+
projectId,
426+
moduleId: undefined,
427+
phase: 'init',
428+
status: 'running',
429+
startedAt: new Date(),
430+
k8sJobName,
431+
callbackToken,
432+
};
433+
434+
mockDeps.x2aDatabase.getJob.mockResolvedValue(job);
435+
mockDeps.kubeService.getJobLogs.mockResolvedValue('logs');
436+
mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined);
437+
438+
const requestBody = { status: 'success', jobId, artifacts };
439+
const signature = signRequestBody(requestBody, callbackToken);
440+
441+
const res = await request(app)
442+
.post(`/projects/${projectId}/collectArtifacts?phase=init`)
443+
.set('X-Callback-Signature', signature)
444+
.send(requestBody);
445+
446+
expect(res.status).toBe(200);
447+
expect(mockDeps.x2aDatabase.listModules).not.toHaveBeenCalled();
448+
expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled();
449+
expect(mockDeps.x2aDatabase.softDeleteModule).not.toHaveBeenCalled();
450+
});
294451
});
295452

296453
describe('graceful failure', () => {

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

Lines changed: 0 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -540,69 +540,4 @@ describe('createRouter – modules', () => {
540540
LONG_TEST_TIMEOUT,
541541
);
542542
});
543-
544-
describe('POST /projects/:projectId/modules', () => {
545-
it.each(supportedDatabaseIds)(
546-
'should create a module and return 201 - %p',
547-
async databaseId => {
548-
const { client } = await createDatabase(databaseId);
549-
const app = await createApp(client);
550-
551-
const createProjectRes = await request(app)
552-
.post('/projects')
553-
.send(mockInputProject);
554-
expect(createProjectRes.status).toBe(200);
555-
const projectId = createProjectRes.body.id;
556-
557-
const response = await request(app)
558-
.post(`/projects/${projectId}/modules`)
559-
.send({ name: 'New Module', sourcePath: '/src/module' });
560-
561-
expect(response.status).toBe(201);
562-
expect(response.body).toMatchObject({
563-
name: 'New Module',
564-
sourcePath: '/src/module',
565-
projectId,
566-
});
567-
expect(response.body.id).toBeDefined();
568-
},
569-
LONG_TEST_TIMEOUT,
570-
);
571-
572-
it.each(supportedDatabaseIds)(
573-
'should return 404 when project does not exist - %p',
574-
async databaseId => {
575-
const { client } = await createDatabase(databaseId);
576-
const app = await createApp(client);
577-
578-
const response = await request(app)
579-
.post(`/projects/${nonExistentId}/modules`)
580-
.send({ name: 'Module', sourcePath: '/path' });
581-
582-
expect(response.status).toBe(404);
583-
expect(response.body.error.message).toContain('not found');
584-
},
585-
);
586-
587-
it.each(supportedDatabaseIds)(
588-
'should return 400 when body is invalid - %p',
589-
async databaseId => {
590-
const { client } = await createDatabase(databaseId);
591-
const app = await createApp(client);
592-
593-
const createProjectRes = await request(app)
594-
.post('/projects')
595-
.send(mockInputProject);
596-
expect(createProjectRes.status).toBe(200);
597-
const projectId = createProjectRes.body.id;
598-
599-
const response = await request(app)
600-
.post(`/projects/${projectId}/modules`)
601-
.send({ name: 'Only name' });
602-
603-
expect(response.status).toBe(400);
604-
expect(response.body.error.name).toBe('InputError');
605-
},
606-
);
607-
});
608543
});

0 commit comments

Comments
 (0)