diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts new file mode 100644 index 0000000..fba2ac1 --- /dev/null +++ b/src/commands/destroy.ts @@ -0,0 +1,7 @@ +import { constructActionContext, logger, rosStackDelete } from '../common'; + +export const destroyStack = async (stackName: string) => { + const context = constructActionContext({ stackName }); + logger.info(`Destroying stack ${stackName}...`); + await rosStackDelete(context); +}; diff --git a/src/commands/index.ts b/src/commands/index.ts index 3978f89..90c94e4 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -6,6 +6,7 @@ import { logger, getVersion } from '../common'; import { validate } from './validate'; import { deploy } from './deploy'; import { template } from './template'; +import { destroyStack } from './destroy'; const program = new Command(); @@ -62,4 +63,11 @@ program template(stackName, { format, location: file, stage }); }); +program + .command('destroy ') + .description('destroy serverless stack') + .action(async (stackName) => { + await destroyStack(stackName); + }); + program.parse(); diff --git a/src/common/rosClient.ts b/src/common/rosClient.ts index e60f4e0..6929620 100644 --- a/src/common/rosClient.ts +++ b/src/common/rosClient.ts @@ -3,6 +3,7 @@ import ROS20190910, { CreateStackRequest, CreateStackRequestParameters, CreateStackRequestTags, + DeleteStackRequest, GetStackRequest, ListStacksRequest, UpdateStackRequest, @@ -172,3 +173,26 @@ export const rosStackDeploy = async ( logger.info(`createStack success! stackName:${stack?.stackName}, stackId:${stack?.stackId}`); } }; + +export const rosStackDelete = async ({ + stackName, + region, +}: Pick) => { + const stackInfo = await getStackByName(stackName, region); + if (!stackInfo) { + logger.warn(`Stack: ${stackName} not exists, skipped! 🚫`); + return; + } + try { + const deleteStackRequest = new DeleteStackRequest({ + regionId: region, + stackId: stackInfo.stackId, + }); + await client.deleteStack(deleteStackRequest); + await getStackActionResult(stackInfo.stackId as string, region); + logger.info(`Stack: ${stackName} deleted! ♻️`); + } catch (err) { + logger.error(`Stack: ${stackName} delete failed! ❌, error: ${JSON.stringify(err)}`); + throw new Error(JSON.stringify(err)); + } +}; diff --git a/tests/common/rosClient.test.ts b/tests/common/rosClient.test.ts index 386c8b4..45f36a4 100644 --- a/tests/common/rosClient.test.ts +++ b/tests/common/rosClient.test.ts @@ -1,4 +1,4 @@ -import { logger, rosStackDeploy } from '../../src/common'; +import { logger, rosStackDelete, rosStackDeploy } from '../../src/common'; import { context } from '../fixtures/contextFixture'; import { lang } from '../../src/lang'; @@ -6,7 +6,7 @@ const mockedCreateStack = jest.fn(); const mockedUpdateStack = jest.fn(); const mockedListStacks = jest.fn(); const mockedGetStack = jest.fn(); - +const mockedDeleteStack = jest.fn(); jest.mock('@alicloud/ros20190910', () => ({ ...jest.requireActual('@alicloud/ros20190910'), __esModule: true, @@ -15,6 +15,7 @@ jest.mock('@alicloud/ros20190910', () => ({ updateStack: () => mockedUpdateStack(), listStacks: () => mockedListStacks(), getStack: () => mockedGetStack(), + deleteStack: () => mockedDeleteStack(), })), })); @@ -25,64 +26,105 @@ describe('Unit test for rosClient', () => { jest.clearAllMocks(); }); - it('should create a new stack if it does not exist', async () => { - mockedListStacks.mockResolvedValue({ statusCode: 200, body: { stacks: [] } }); - mockedCreateStack.mockResolvedValue({ body: { stackId: 'newStackId' } }); - mockedGetStack.mockResolvedValue({ body: { status: 'CREATE_COMPLETE' } }); - - await rosStackDeploy('newStack', {}, context); + describe('Unit tes for rosStackDeploy', () => { + it('should create a new stack if it does not exist', async () => { + mockedListStacks.mockResolvedValue({ statusCode: 200, body: { stacks: [] } }); + mockedCreateStack.mockResolvedValue({ body: { stackId: 'newStackId' } }); + mockedGetStack.mockResolvedValue({ body: { status: 'CREATE_COMPLETE' } }); - expect(mockedCreateStack).toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('createStack success')); - }); + await rosStackDeploy('newStack', {}, context); - it('should update an existing stack if it exists', async () => { - mockedListStacks.mockResolvedValue({ - statusCode: 200, - body: { stacks: [{ stackId: 'existingStackId', Status: 'CREATE_COMPLETE' }] }, + expect(mockedCreateStack).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('createStack success')); }); - mockedUpdateStack.mockResolvedValue({ body: { stackId: 'existingStackId' } }); - mockedGetStack.mockResolvedValue({ body: { status: 'UPDATE_COMPLETE' } }); - await rosStackDeploy('existingStack', {}, context); + it('should update an existing stack if it exists', async () => { + mockedListStacks.mockResolvedValue({ + statusCode: 200, + body: { stacks: [{ stackId: 'existingStackId', Status: 'CREATE_COMPLETE' }] }, + }); + mockedUpdateStack.mockResolvedValue({ body: { stackId: 'existingStackId' } }); + mockedGetStack.mockResolvedValue({ body: { status: 'UPDATE_COMPLETE' } }); - expect(mockedUpdateStack).toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('stackUpdate success')); - }); + await rosStackDeploy('existingStack', {}, context); - it('should throw an error if the stack is in progress', async () => { - mockedListStacks.mockResolvedValue({ - statusCode: 200, - body: { stacks: [{ stackId: 'inProgressStackId', Status: 'CREATE_IN_PROGRESS' }] }, + expect(mockedUpdateStack).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('stackUpdate success')); }); - await expect(rosStackDeploy('inProgressStack', {}, context)).rejects.toThrow( - 'fail to update stack, because stack status is CREATE_IN_PROGRESS', - ); - }); + it('should throw an error if the stack is in progress', async () => { + mockedListStacks.mockResolvedValue({ + statusCode: 200, + body: { stacks: [{ stackId: 'inProgressStackId', Status: 'CREATE_IN_PROGRESS' }] }, + }); - it('should notify user with warning logs when update completely same stack', async () => { - mockedListStacks.mockResolvedValue({ - statusCode: 200, - body: { stacks: [{ stackId: 'existingStackId', Status: 'CREATE_COMPLETE' }] }, + await expect(rosStackDeploy('inProgressStack', {}, context)).rejects.toThrow( + 'fail to update stack, because stack status is CREATE_IN_PROGRESS', + ); }); - mockedUpdateStack.mockRejectedValueOnce({ - data: { statusCode: 400, Message: 'Update the completely same stack' }, + + it('should notify user with warning logs when update completely same stack', async () => { + mockedListStacks.mockResolvedValue({ + statusCode: 200, + body: { stacks: [{ stackId: 'existingStackId', Status: 'CREATE_COMPLETE' }] }, + }); + mockedUpdateStack.mockRejectedValueOnce({ + data: { statusCode: 400, Message: 'Update the completely same stack' }, + }); + mockedGetStack.mockResolvedValue({ body: { status: 'UPDATE_COMPLETE' } }); + + await rosStackDeploy('existingStack', {}, context); + + expect(logger.warn).toHaveBeenCalledWith(`${lang.__('UPDATE_COMPLETELY_SAME_STACK')}`); }); - mockedGetStack.mockResolvedValue({ body: { status: 'UPDATE_COMPLETE' } }); - await rosStackDeploy('existingStack', {}, context); + it('should throw error when deploy stack failed', async () => { + mockedListStacks.mockResolvedValue({ statusCode: 200, body: { stacks: [] } }); + mockedCreateStack.mockResolvedValueOnce({ body: { stackId: 'newStackId' } }); + mockedGetStack.mockResolvedValue({ body: { status: 'ROLLBACK_COMPLETE' } }); - expect(logger.warn).toHaveBeenCalledWith(`${lang.__('UPDATE_COMPLETELY_SAME_STACK')}`); + await expect(rosStackDeploy('newStack', {}, context)).rejects.toThrow( + `Stack operation failed with status: ROLLBACK_COMPLETE`, + ); + }); }); - it('should throw error when deploy stack failed', async () => { - mockedListStacks.mockResolvedValue({ statusCode: 200, body: { stacks: [] } }); - mockedCreateStack.mockResolvedValueOnce({ body: { stackId: 'newStackId' } }); - mockedGetStack.mockResolvedValue({ body: { status: 'ROLLBACK_COMPLETE' } }); + describe('Unit test for rosStackDelete', () => { + it('should delete the stack when the provided stack is exists', async () => { + mockedListStacks.mockResolvedValue({ + statusCode: 200, + body: { stacks: [{ stackId: 'stackToDelete', Status: 'UPDATE_COMPLETE' }] }, + }); + mockedDeleteStack.mockResolvedValue({ body: { stackId: 'stackToDelete' } }); - await expect(rosStackDeploy('newStack', {}, context)).rejects.toThrow( - `Stack operation failed with status: ROLLBACK_COMPLETE`, - ); + mockedGetStack.mockResolvedValueOnce({ body: { status: 'DELETE_COMPLETE' } }); + + await rosStackDelete(context); + + expect(logger.info).toHaveBeenCalledWith('stack status: DELETE_COMPLETE'); + expect(logger.info).toHaveBeenCalledWith('Stack: testStack deleted! ♻️'); + }); + + it('should throw an error when the stack does not exist', async () => { + mockedListStacks.mockResolvedValue({ statusCode: 404, body: { stacks: [] } }); + await rosStackDelete(context); + + expect(logger.warn).toHaveBeenCalledWith('Stack: testStack not exists, skipped! 🚫'); + }); + + it('should throw error when delete stack failed', async () => { + mockedListStacks.mockResolvedValue({ + statusCode: 200, + body: { stacks: [{ stackId: 'stackToDelete', Status: 'UPDATE_COMPLETE' }] }, + }); + mockedDeleteStack.mockRejectedValue({ data: { statusCode: 400, Message: 'DELETE_FAILED' } }); + + await expect(rosStackDelete(context)).rejects.toThrow( + JSON.stringify({ statusCode: 400, Message: 'DELETE_FAILED' }), + ); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Stack: testStack delete failed! ❌'), + ); + }); }); });