diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 3c3d75b..77a7724 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -5,7 +5,7 @@ export const deploy = async ( stackName: string, options: { location: string; parameters: { [key: string]: string } }, ) => { - const context = constructActionContext(options); + const context = constructActionContext({ ...options, stackName }); logger.info('Validating yaml...'); const iac = parseYaml(context.iacLocation); logger.info('Yaml is valid! 🎉'); diff --git a/src/common/actionContext.ts b/src/common/actionContext.ts index 0f37d75..2eb9393 100644 --- a/src/common/actionContext.ts +++ b/src/common/actionContext.ts @@ -10,9 +10,11 @@ export const constructActionContext = (config?: { location?: string; parameters?: { [key: string]: string }; stage?: string; + stackName?: string; }): ActionContext => { return { stage: config?.stage ?? 'default', + stackName: config?.stackName ?? '', region: config?.region ?? process.env.ROS_REGION_ID ?? process.env.ALIYUN_REGION ?? 'cn-hangzhou', accessKeyId: config?.accessKeyId ?? (process.env.ALIYUN_ACCESS_KEY_ID as string), diff --git a/src/common/iacHelper.ts b/src/common/iacHelper.ts new file mode 100644 index 0000000..6b63bbf --- /dev/null +++ b/src/common/iacHelper.ts @@ -0,0 +1,49 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import * as ros from '@alicloud/ros-cdk-core'; + +export const resolveCode = (location: string): string => { + const filePath = path.resolve(process.cwd(), location); + const fileContent = fs.readFileSync(filePath); + + return fileContent.toString('base64'); +}; + +export const replaceVars = (value: T, stage: string): T => { + if (typeof value === 'string') { + const matchVar = value.match(/^\$\{vars\.(\w+)}$/); + const containsVar = value.match(/\$\{vars\.(\w+)}/); + const matchMap = value.match(/^\$\{stages\.(\w+)}$/); + const containsMap = value.match(/\$\{stages\.(\w+)}/); + if (matchVar?.length) { + return ros.Fn.ref(matchVar[1]) as T; + } + if (matchMap?.length) { + return ros.Fn.findInMap('stages', '', matchMap[1]) as T; + } + if (containsMap?.length && containsVar?.length) { + return ros.Fn.sub( + value.replace(/\$\{stages\.(\w+)}/g, '${$1}').replace(/\$\{vars\.(\w+)}/g, '${$1}'), + ) as T; + } + if (containsVar?.length) { + return ros.Fn.sub(value.replace(/\$\{vars\.(\w+)}/g, '${$1}')) as T; + } + if (containsMap?.length) { + return ros.Fn.sub(value.replace(/\$\{stages\.(\w+)}/g, '${$1}')) as T; + } + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => replaceVars(item, stage)) as T; + } + + if (typeof value === 'object' && value !== null) { + return Object.fromEntries( + Object.entries(value).map(([key, val]) => [key, replaceVars(val, stage)]), + ) as T; + } + + return value; +}; diff --git a/src/common/index.ts b/src/common/index.ts index 233aa34..f8ef3cf 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -3,3 +3,4 @@ export * from './logger'; export * from './getVersion'; export * from './rosClient'; export * from './actionContext'; +export * from './iacHelper'; diff --git a/src/common/rosClient.ts b/src/common/rosClient.ts index c996343..034e048 100644 --- a/src/common/rosClient.ts +++ b/src/common/rosClient.ts @@ -52,13 +52,7 @@ const updateStack = async (stackId: string, templateBody: unknown, context: Acti parameterValue: Util.assertAsString(parameter.key), }), ); - console.log( - 'parameters:', - JSON.stringify({ - parameters, - contextParam: context.parameters, - }), - ); + const createStackRequest = new UpdateStackRequest({ regionId: context.region, stackId, diff --git a/src/stack/iacStack.ts b/src/stack/iacStack.ts index 285827d..89386b7 100644 --- a/src/stack/iacStack.ts +++ b/src/stack/iacStack.ts @@ -1,61 +1,15 @@ import * as ros from '@alicloud/ros-cdk-core'; -import { ActionContext, EventTypes, ServerlessIac } from '../types'; import { RosParameterType } from '@alicloud/ros-cdk-core'; +import { ActionContext, EventTypes, ServerlessIac } from '../types'; import * as fc from '@alicloud/ros-cdk-fc'; import * as ram from '@alicloud/ros-cdk-ram'; import * as agw from '@alicloud/ros-cdk-apigateway'; -import path from 'node:path'; -import fs from 'node:fs'; - -const resolveCode = (location: string): string => { - const filePath = path.resolve(process.cwd(), location); - const fileContent = fs.readFileSync(filePath); - - return fileContent.toString('base64'); -}; - -const replaceVars = (value: T, stage: string): T => { - if (typeof value === 'string') { - const matchVar = value.match(/^\$\{vars\.(\w+)}$/); - const containsVar = value.match(/\$\{vars\.(\w+)}/); - const matchMap = value.match(/^\$\{stages\.(\w+)}$/); - const containsMap = value.match(/\$\{stages\.(\w+)}/); - if (matchVar?.length) { - return ros.Fn.ref(matchVar[1]) as T; - } - if (matchMap?.length) { - return ros.Fn.findInMap('stages', '', matchMap[1]) as T; - } - if (containsMap?.length && containsVar?.length) { - return ros.Fn.sub( - value.replace(/\$\{stages\.(\w+)}/g, '${$1}').replace(/\$\{vars\.(\w+)}/g, '${$1}'), - ) as T; - } - if (containsVar?.length) { - return ros.Fn.sub(value.replace(/\$\{vars\.(\w+)}/g, '${$1}')) as T; - } - if (containsMap?.length) { - return ros.Fn.sub(value.replace(/\$\{stages\.(\w+)}/g, '${$1}')) as T; - } - return value; - } - - if (Array.isArray(value)) { - return value.map((item) => replaceVars(item, stage)) as T; - } - - if (typeof value === 'object' && value !== null) { - return Object.fromEntries( - Object.entries(value).map(([key, val]) => [key, replaceVars(val, stage)]), - ) as T; - } - - return value; -}; +import { replaceVars, resolveCode } from '../common'; export class IacStack extends ros.Stack { constructor(scope: ros.Construct, iac: ServerlessIac, context: ActionContext) { super(scope, iac.service, { + stackName: context.stackName, tags: iac.tags.reduce((acc: { [key: string]: string }, tag) => { acc[tag.key] = replaceVars(tag.value, context.stage); return acc; @@ -70,7 +24,7 @@ export class IacStack extends ros.Stack { defaultValue: value, }), ); - console.log('stages:', iac.stages); + // Define Mappings new ros.RosMapping(this, 'stages', { mapping: replaceVars(iac.stages, context.stage) }); @@ -111,8 +65,8 @@ export class IacStack extends ros.Stack { func.addDependsOn(service); }); - const apiGateway = iac.events.find((event) => event.type === EventTypes.API_GATEWAY); - if (apiGateway) { + const apiGateway = iac.events?.filter((event) => event.type === EventTypes.API_GATEWAY); + if (apiGateway?.length) { const gatewayAccessRole = new ram.RosRole( this, replaceVars(`${iac.service}_role`, context.stage), @@ -161,43 +115,41 @@ export class IacStack extends ros.Stack { true, ); - iac.events - .filter((event) => event.type === EventTypes.API_GATEWAY) - .forEach((event) => { - event.triggers.forEach((trigger) => { - const key = `${trigger.method}_${trigger.path}`.toLowerCase().replace(/\//g, '_'); + apiGateway.forEach((event) => { + event.triggers.forEach((trigger) => { + const key = `${trigger.method}_${trigger.path}`.toLowerCase().replace(/\//g, '_'); - const api = new agw.RosApi( - this, - replaceVars(`${event.key}_api_${key}`, context.stage), - { - apiName: replaceVars(`${event.name}_api_${key}`, context.stage), - groupId: apiGatewayGroup.attrGroupId, - visibility: 'PRIVATE', - requestConfig: { - requestProtocol: 'HTTP', - requestHttpMethod: replaceVars(trigger.method, context.stage), - requestPath: replaceVars(trigger.path, context.stage), - requestMode: 'PASSTHROUGH', - }, - serviceConfig: { - serviceProtocol: 'FunctionCompute', - functionComputeConfig: { - fcRegionId: context.region, - serviceName: service.serviceName, - functionName: trigger.backend, - roleArn: gatewayAccessRole.attrArn, - }, + const api = new agw.RosApi( + this, + replaceVars(`${event.key}_api_${key}`, context.stage), + { + apiName: replaceVars(`${event.name}_api_${key}`, context.stage), + groupId: apiGatewayGroup.attrGroupId, + visibility: 'PRIVATE', + requestConfig: { + requestProtocol: 'HTTP', + requestHttpMethod: replaceVars(trigger.method, context.stage), + requestPath: replaceVars(trigger.path, context.stage), + requestMode: 'PASSTHROUGH', + }, + serviceConfig: { + serviceProtocol: 'FunctionCompute', + functionComputeConfig: { + fcRegionId: context.region, + serviceName: service.serviceName, + functionName: trigger.backend, + roleArn: gatewayAccessRole.attrArn, }, - resultSample: 'ServerlessInsight resultSample', - resultType: 'JSON', - tags: replaceVars(iac.tags, context.stage), }, - true, - ); - api.addDependsOn(apiGatewayGroup); - }); + resultSample: 'ServerlessInsight resultSample', + resultType: 'JSON', + tags: replaceVars(iac.tags, context.stage), + }, + true, + ); + api.addDependsOn(apiGatewayGroup); }); + }); } } } diff --git a/src/types.ts b/src/types.ts index 9e74a00..0199244 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,11 +65,12 @@ export type ServerlessIac = { service: string; tags: Array<{ key: string; value: string }>; functions: Array; - events: Array; + events?: Array; }; export type ActionContext = { stage: string; + stackName: string; region: string; accessKeyId: string; accessKeySecret: string; diff --git a/tests/fixtures/deployFixture.ts b/tests/fixtures/deployFixture.ts new file mode 100644 index 0000000..6d1e9f3 --- /dev/null +++ b/tests/fixtures/deployFixture.ts @@ -0,0 +1,215 @@ +import { ServerlessIac } from '../../src/types'; + +export const oneFcOneGatewayIac = { + service: 'my-demo-service', + version: '0.0.1', + provider: 'aliyun', + vars: { + region: 'cn-hangzhou', + account_id: 1234567890, + }, + stages: { + dev: { + region: '${vars:region}', + account_id: '${vars:account_id}', + }, + }, + functions: [ + { + key: 'hello_fn', + name: 'hello_fn', + runtime: 'nodejs18', + handler: 'index.handler', + code: 'artifact.zip', + memory: 128, + timeout: 10, + environment: { + NODE_ENV: 'production', + }, + }, + ], + events: [ + { + type: 'API_GATEWAY', + key: 'gateway_event', + name: 'gateway_event', + triggers: [ + { + method: 'GET', + path: '/api/hello', + backend: 'demo_fn_gateway', + }, + ], + }, + ], + tags: [ + { + key: 'owner', + value: 'geek-fun', + }, + ], +} as ServerlessIac; + +export const oneFcOneGatewayRos = { + Description: 'my-demo-service stack', + Mappings: { + stages: { dev: { account_id: '${vars:account_id}', region: '${vars:region}' } }, + }, + Metadata: { 'ALIYUN::ROS::Interface': { TemplateTags: ['Create by ROS CDK'] } }, + Parameters: { + account_id: { Default: 1234567890, Type: 'String' }, + region: { Default: 'cn-hangzhou', Type: 'String' }, + }, + ROSTemplateFormatVersion: '2015-09-01', + Resources: { + gateway_event_api_get__api_hello: { + Properties: { + ApiName: 'gateway_event_api_get__api_hello', + GroupId: { 'Fn::GetAtt': ['my-demo-service_apigroup', 'GroupId'] }, + RequestConfig: { + RequestHttpMethod: 'GET', + RequestMode: 'PASSTHROUGH', + RequestPath: '/api/hello', + RequestProtocol: 'HTTP', + }, + ResultSample: 'ServerlessInsight resultSample', + ResultType: 'JSON', + ServiceConfig: { + FunctionComputeConfig: { + FunctionName: 'demo_fn_gateway', + RoleArn: { 'Fn::GetAtt': ['my-demo-service_role', 'Arn'] }, + ServiceName: 'my-demo-service-service', + }, + ServiceProtocol: 'FunctionCompute', + }, + Tags: [{ Key: 'owner', Value: 'geek-fun' }], + Visibility: 'PRIVATE', + }, + Type: 'ALIYUN::ApiGateway::Api', + }, + hello_fn: { + Properties: { + Code: { ZipFile: 'resolved-code' }, + EnvironmentVariables: { NODE_ENV: 'production' }, + FunctionName: 'hello_fn', + Handler: 'index.handler', + MemorySize: 128, + Runtime: 'nodejs18', + ServiceName: 'my-demo-service-service', + Timeout: 10, + }, + Type: 'ALIYUN::FC::Function', + }, + 'my-demo-service-service': { + Properties: { + ServiceName: 'my-demo-service-service', + Tags: [{ Key: 'owner', Value: 'geek-fun' }], + }, + Type: 'ALIYUN::FC::Service', + }, + 'my-demo-service_apigroup': { + Properties: { + GroupName: 'my-demo-service_apigroup', + Tags: [{ Key: 'owner', Value: 'geek-fun' }], + }, + Type: 'ALIYUN::ApiGateway::Group', + }, + 'my-demo-service_role': { + Properties: { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { Service: ['apigateway.aliyuncs.com'] }, + }, + ], + Version: '1', + }, + Description: 'my-demo-service role', + Policies: [ + { + PolicyDocument: { + Statement: [{ Action: ['fc:InvokeFunction'], Effect: 'Allow', Resource: ['*'] }], + Version: '1', + }, + PolicyName: 'my-demo-service-policy', + }, + ], + RoleName: 'my-demo-service-gateway-access-role', + }, + Type: 'ALIYUN::RAM::Role', + }, + }, +}; + +export const oneFcIac = { + service: 'my-demo-service', + version: '0.0.1', + provider: 'aliyun', + vars: { + region: 'cn-hangzhou', + account_id: 1234567890, + }, + stages: { + dev: { + region: '${vars:region}', + account_id: '${vars:account_id}', + }, + }, + functions: [ + { + key: 'hello_fn', + name: 'hello_fn', + runtime: 'nodejs18', + handler: 'index.handler', + code: 'artifact.zip', + memory: 128, + timeout: 10, + environment: { + NODE_ENV: 'production', + }, + }, + ], + tags: [ + { + key: 'owner', + value: 'geek-fun', + }, + ], +} as ServerlessIac; + +export const oneFcRos = { + Description: 'my-demo-service stack', + Mappings: { + stages: { dev: { account_id: '${vars:account_id}', region: '${vars:region}' } }, + }, + Metadata: { 'ALIYUN::ROS::Interface': { TemplateTags: ['Create by ROS CDK'] } }, + Parameters: { + account_id: { Default: 1234567890, Type: 'String' }, + region: { Default: 'cn-hangzhou', Type: 'String' }, + }, + ROSTemplateFormatVersion: '2015-09-01', + Resources: { + hello_fn: { + Properties: { + Code: { ZipFile: 'resolved-code' }, + EnvironmentVariables: { NODE_ENV: 'production' }, + FunctionName: 'hello_fn', + Handler: 'index.handler', + MemorySize: 128, + Runtime: 'nodejs18', + ServiceName: 'my-demo-service-service', + Timeout: 10, + }, + Type: 'ALIYUN::FC::Function', + }, + 'my-demo-service-service': { + Properties: { + ServiceName: 'my-demo-service-service', + Tags: [{ Key: 'owner', Value: 'geek-fun' }], + }, + Type: 'ALIYUN::FC::Service', + }, + }, +}; diff --git a/tests/stack/deploy.test.ts b/tests/stack/deploy.test.ts new file mode 100644 index 0000000..06a21af --- /dev/null +++ b/tests/stack/deploy.test.ts @@ -0,0 +1,43 @@ +import { deployStack } from '../../src/stack'; +import { ActionContext } from '../../src/types'; +import { + oneFcIac, + oneFcOneGatewayIac, + oneFcOneGatewayRos, + oneFcRos, +} from '../fixtures/deployFixture'; + +const mockedRosStackDeploy = jest.fn(); +const mockedResolveCode = jest.fn(); +jest.mock('../../src/common', () => ({ + ...jest.requireActual('../../src/common'), + rosStackDeploy: (...args: unknown[]) => mockedRosStackDeploy(...args), + resolveCode: (path: string) => mockedResolveCode(path), +})); + +describe('Unit tests for stack deployment', () => { + beforeEach(() => { + mockedRosStackDeploy.mockRestore(); + mockedResolveCode.mockReturnValueOnce('resolved-code'); + }); + + it('should deploy generated stack when iac is valid', async () => { + const stackName = 'my-demo-stack'; + mockedRosStackDeploy.mockResolvedValueOnce(stackName); + + await deployStack(stackName, oneFcOneGatewayIac, { stackName } as ActionContext); + + expect(mockedRosStackDeploy).toHaveBeenCalledTimes(1); + expect(mockedRosStackDeploy).toHaveBeenCalledWith(stackName, oneFcOneGatewayRos, { stackName }); + }); + + it('should deploy generated stack when only one FC specified', async () => { + const stackName = 'my-demo-stack-fc-only'; + mockedRosStackDeploy.mockResolvedValueOnce(stackName); + + await deployStack(stackName, oneFcIac, { stackName } as ActionContext); + + expect(mockedRosStackDeploy).toHaveBeenCalledTimes(1); + expect(mockedRosStackDeploy).toHaveBeenCalledWith(stackName, oneFcRos, { stackName }); + }); +});