From a2d4346d60c092fc70d31706b73e4c81fa48e1f5 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 18:02:03 +0000 Subject: [PATCH] fix(gateway): finish lifecycle action audits --- app/gateway-api/src/adminRouter.ts | 22 +++++++-- app/gateway-api/test/adminOperations.test.ts | 48 +++++++++++++++++++- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 340a702..5baad8a 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -1332,11 +1332,6 @@ export const adminRouter = router({ SHUTDOWN: 'DISABLED', } as const; const mappedStatus = statusMap[input.action as keyof typeof statusMap]; - if (mappedStatus) { - await ctx.profiles.updateStatus(input.profileName, mappedStatus); - await ctx.orchestrator.reconcileNow(); - } - const meta = readMetaObject(profile.meta); const actionLog = Array.isArray(meta.adminActions) ? meta.adminActions.filter((entry) => entry && typeof entry === 'object') @@ -1354,6 +1349,23 @@ export const adminRouter = router({ adminActions: [...actionLog, actionRecord], }; await ctx.profiles.updateMeta(input.profileName, nextMeta); + if (mappedStatus) { + await ctx.profiles.updateStatus(input.profileName, mappedStatus); + await ctx.orchestrator.reconcileNow(); + const appliedActionRecord = { + ...actionRecord, + status: 'APPLIED', + handledAt: new Date().toISOString(), + handler: 'gateway-api', + detail: `profile status reconciled as ${mappedStatus}`, + }; + await ctx.profiles.updateMeta(input.profileName, { + ...nextMeta, + adminActions: [...actionLog, appliedActionRecord], + adminActionsUpdatedAt: appliedActionRecord.handledAt, + }); + return { ok: true, action: appliedActionRecord }; + } return { ok: true, action: actionRecord }; }), requestBuild: profileAdminProcedure diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 0701893..25ca96d 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -42,6 +42,7 @@ const buildCaller = async ( const createdRuntimeActions: Array> = []; const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = []; const updatedStatuses: GatewayProfileRecord['status'][] = []; + const updatedMetas: Record[] = []; let reconcileCount = 0; let storedNotice = options.initialNotice ?? ''; const profile = { @@ -65,7 +66,10 @@ const buildCaller = async ( return { ...profile, status }; }, updateBuildStatus: async () => profile, - updateMeta: async () => profile, + updateMeta: async (_profileName, meta) => { + updatedMetas.push(meta); + return profile; + }, listReservedToStart: async () => [], findQueuedBuild: async () => null, updateLastError: async () => {}, @@ -171,6 +175,7 @@ const buildCaller = async ( admin, flushes, updatedStatuses, + updatedMetas, getReconcileCount: () => reconcileCount, getStoredNotice: () => storedNotice, setStoredNotice: (notice: string) => { @@ -360,6 +365,47 @@ describe('admin runtime clock action API', () => { ).resolves.toMatchObject({ ok: true }); expect(harness.updatedStatuses).toEqual(['RUNNING']); expect(harness.getReconcileCount()).toBe(1); + expect(harness.updatedMetas).toHaveLength(2); + expect(harness.updatedMetas.at(-1)).toMatchObject({ + adminActions: [ + { + action: 'RESUME', + status: 'APPLIED', + handler: 'gateway-api', + detail: 'profile status reconciled as RUNNING', + }, + ], + }); + }); + + it.each([ + ['STOP', 'STOPPED'], + ['SHUTDOWN', 'DISABLED'], + ] as const)('records %s as terminal only after runtime reconciliation', async (action, expectedStatus) => { + const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'RUNNING' }); + + const result = await harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action, + }); + + expect(harness.updatedStatuses).toEqual([expectedStatus]); + expect(harness.getReconcileCount()).toBe(1); + expect(harness.updatedMetas).toHaveLength(2); + expect(harness.updatedMetas[0]).toMatchObject({ + adminActions: [{ action, status: 'REQUESTED' }], + }); + expect(harness.updatedMetas[1]).toMatchObject({ + adminActions: [ + { + action, + status: 'APPLIED', + handler: 'gateway-api', + detail: `profile status reconciled as ${expectedStatus}`, + }, + ], + }); + expect(result.action).toMatchObject({ action, status: 'APPLIED', handledAt: expect.any(String) }); }); it('rejects resume outside stopped and paused states without reconciliation', async () => {