diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 468bc0e..53c803d 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -8,7 +8,7 @@ export const deploy = async ( ) => { const context = constructActionContext({ ...options, stackName }); logger.info('Validating yaml...'); - const iac = parseYaml(context.iacLocation); + const iac = parseYaml(context); logger.info('Yaml is valid! 🎉'); logger.info('Deploying stack...'); diff --git a/src/commands/template.ts b/src/commands/template.ts index f65e3c6..24d4a25 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -9,7 +9,7 @@ export const template = ( options: { format: TemplateFormat; location: string; stage: string | undefined }, ) => { const context = constructActionContext({ ...options, stackName }); - const iac = parseYaml(context.iacLocation); + const iac = parseYaml(context); const { template } = generateStackTemplate(stackName, iac, context); const output = diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 66f2cb0..1f12e89 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -3,7 +3,7 @@ import { parseYaml } from '../parser'; export const validate = (location: string | undefined, stage: string | undefined) => { const context = constructActionContext({ location, stage }); - parseYaml(context.iacLocation); + parseYaml(context); logger.info('Yaml is valid! 🎉'); logger.debug('Yaml is valid! debug🎉'); }; diff --git a/src/parser/index.ts b/src/parser/index.ts index 7d6b7cc..5dcd13e 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from 'node:fs'; -import { ServerlessIac, ServerlessIacRaw } from '../types'; +import { ActionContext, ServerlessIac, ServerlessIacRaw } from '../types'; import { parseFunction } from './functionParser'; import { parseEvent } from './eventParser'; import { parseDatabase } from './databaseParser'; @@ -27,10 +27,10 @@ const transformYaml = (iacJson: ServerlessIacRaw): ServerlessIac => { }; }; -export const parseYaml = (yamlPath: string): ServerlessIac => { - validateExistence(yamlPath); +export const parseYaml = (context: ActionContext): ServerlessIac => { + validateExistence(context.iacLocation); - const yamlContent = readFileSync(yamlPath, 'utf8'); + const yamlContent = readFileSync(context.iacLocation, 'utf8'); const iacJson = parse(yamlContent) as ServerlessIacRaw; validateYaml(iacJson); diff --git a/src/parser/tagParser.ts b/src/parser/tagParser.ts index 54389a2..34ed9ad 100644 --- a/src/parser/tagParser.ts +++ b/src/parser/tagParser.ts @@ -1,9 +1,8 @@ import { TagDomain, Tags } from '../types'; -import { isEmpty } from 'lodash'; -export const parseTag = (tags?: Tags): Array => { - const tagList = [{ key: 'iac-provider', value: 'ServerlessInsight' }]; - if (isEmpty(tags)) return tagList; - - return [...tagList, ...Object.entries(tags).map(([key, value]) => ({ key, value }))]; +export const parseTag = (tags: Tags | undefined): Array => { + return [ + { key: 'iac-provider', value: 'ServerlessInsight' }, + ...Object.entries(tags ?? {}).map(([key, value]) => ({ key, value })), + ]; }; diff --git a/src/stack/deploy.ts b/src/stack/deploy.ts index a053cc6..359c374 100644 --- a/src/stack/deploy.ts +++ b/src/stack/deploy.ts @@ -1,7 +1,7 @@ import * as ros from '@alicloud/ros-cdk-core'; import { ActionContext, ServerlessIac } from '../types'; import { logger, rosStackDeploy } from '../common'; -import { IacStack } from './iacStack'; +import { RosStack } from './rosStack/rosStack'; export const generateStackTemplate = ( stackName: string, @@ -9,7 +9,7 @@ export const generateStackTemplate = ( context: ActionContext, ) => { const app = new ros.App(); - new IacStack(app, iac, context); + new RosStack(app, iac, context); const assembly = app.synth(); const stackArtifact = assembly.getStackByName(stackName); diff --git a/src/stack/iacStack.ts b/src/stack/iacStack.ts deleted file mode 100644 index da70bb8..0000000 --- a/src/stack/iacStack.ts +++ /dev/null @@ -1,255 +0,0 @@ -import * as ros from '@alicloud/ros-cdk-core'; -import { RosParameterType } from '@alicloud/ros-cdk-core'; -import { - ActionContext, - DatabaseEngineMode, - DatabaseEnum, - EventTypes, - ServerlessIac, -} from '../types'; -import * as fc from '@alicloud/ros-cdk-fc3'; -import { RosFunction } from '@alicloud/ros-cdk-fc3/lib/fc3.generated'; -import * as ram from '@alicloud/ros-cdk-ram'; -import * as agw from '@alicloud/ros-cdk-apigateway'; -import * as oss from '@alicloud/ros-cdk-oss'; -import * as ossDeployment from '@alicloud/ros-cdk-ossdeployment'; -import * as esServerless from '@alicloud/ros-cdk-elasticsearchserverless'; -import { - CODE_ZIP_SIZE_LIMIT, - getFileSource, - readCodeSize, - replaceReference, - resolveCode, -} from '../common'; -import { isEmpty } from 'lodash'; - -export class IacStack extends ros.Stack { - private readonly service: string; - - constructor(scope: ros.Construct, iac: ServerlessIac, context: ActionContext) { - super(scope, replaceReference(iac.service, context), { - stackName: context.stackName, - tags: iac.tags?.reduce((acc: { [key: string]: string }, tag) => { - acc[tag.key] = replaceReference(tag.value, context); - return acc; - }, {}), - }); - this.service = replaceReference(iac.service, context); - - // Define Parameters - if (iac.vars) { - Object.entries(iac.vars).map( - ([id, value]) => - new ros.RosParameter(this, id, { - type: RosParameterType.STRING, - defaultValue: value, - }), - ); - } - - // Define Mappings - if (iac.stages) { - new ros.RosMapping(this, 'stages', { mapping: replaceReference(iac.stages, context) }); - } - - new ros.RosInfo(this, ros.RosInfo.description, `${this.service} stack`); - - const fileSources = iac.functions - ?.filter(({ code }) => readCodeSize(code) > CODE_ZIP_SIZE_LIMIT) - .map(({ code, name }) => { - const fcName = replaceReference(name, context); - - return { fcName, ...getFileSource(fcName, code) }; - }); - - let destinationBucket: oss.Bucket; - if (!isEmpty(fileSources)) { - // creat oss to store code - destinationBucket = new oss.Bucket( - this, - replaceReference(`${this.service}_artifacts_bucket`, context), - { - bucketName: `${this.service}-artifacts-bucket`, - serverSideEncryptionConfiguration: { sseAlgorithm: 'KMS' }, - }, - true, - ); - new ossDeployment.BucketDeployment( - this, - `${this.service}_artifacts_code_deployment`, - { - sources: fileSources!.map(({ source }) => source), - destinationBucket, - timeout: 300, - logMonitoring: false, // 是否开启日志监控,设为false则不开启 - }, - true, - ); - } - - iac.functions?.forEach((fnc) => { - let code: RosFunction.CodeProperty = { - zipFile: resolveCode(fnc.code), - }; - if (readCodeSize(fnc.code) > CODE_ZIP_SIZE_LIMIT) { - code = { - ossBucketName: destinationBucket.attrName, - ossObjectName: fileSources?.find( - ({ fcName }) => fcName === replaceReference(fnc.name, context), - )?.objectKey, - }; - } - new fc.RosFunction( - this, - fnc.key, - { - functionName: replaceReference(fnc.name, context), - handler: replaceReference(fnc.handler, context), - runtime: replaceReference(fnc.runtime, context), - memorySize: replaceReference(fnc.memory, context), - timeout: replaceReference(fnc.timeout, context), - environmentVariables: replaceReference(fnc.environment, context), - code, - }, - true, - ); - }); - - const apiGateway = iac.events?.filter((event) => event.type === EventTypes.API_GATEWAY); - if (apiGateway?.length) { - const gatewayAccessRole = new ram.RosRole( - this, - replaceReference(`${this.service}_role`, context), - { - roleName: replaceReference(`${this.service}-gateway-access-role`, context), - description: replaceReference(`${this.service} role`, context), - assumeRolePolicyDocument: { - version: '1', - statement: [ - { - action: 'sts:AssumeRole', - effect: 'Allow', - principal: { - service: ['apigateway.aliyuncs.com'], - }, - }, - ], - }, - policies: [ - { - policyName: replaceReference(`${this.service}-policy`, context), - policyDocument: { - version: '1', - statement: [ - { - action: ['fc:InvokeFunction'], - effect: 'Allow', - // @TODO implement at least permission granting - resource: ['*'], - }, - ], - }, - }, - ], - }, - true, - ); - - const apiGatewayGroup = new agw.RosGroup( - this, - replaceReference(`${this.service}_apigroup`, context), - { - groupName: replaceReference(`${this.service}_apigroup`, context), - tags: replaceReference(iac.tags, context), - }, - true, - ); - - // new agw.RosCustomDomain( - // this, - // 'customDomain', - // { - // domainName: 'example.com', - // certificateName: 'example.com', - // certificateBody: 'example.com', - // certificatePrivateKey: 'example.com', - // groupId: apiGatewayGroup.attrGroupId, - // }, - // true, - // ); - - apiGateway.forEach((event) => { - event.triggers.forEach((trigger) => { - const key = `${trigger.method}_${trigger.path}`.toLowerCase().replace(/\//g, '_'); - - const api = new agw.RosApi( - this, - replaceReference(`${event.key}_api_${key}`, context), - { - apiName: replaceReference(`${event.name}_api_${key}`, context), - groupId: apiGatewayGroup.attrGroupId, - visibility: 'PRIVATE', - requestConfig: { - requestProtocol: 'HTTP', - requestHttpMethod: replaceReference(trigger.method, context), - requestPath: replaceReference(trigger.path, context), - requestMode: 'PASSTHROUGH', - }, - serviceConfig: { - serviceProtocol: 'FunctionCompute', - functionComputeConfig: { - fcRegionId: context.region, - functionName: replaceReference(trigger.backend, context), - roleArn: gatewayAccessRole.attrArn, - fcVersion: '3.0', - }, - }, - resultSample: 'ServerlessInsight resultSample', - resultType: 'JSON', - tags: replaceReference(iac.tags, context), - }, - true, - ); - api.addDependsOn(apiGatewayGroup); - }); - }); - } - iac.databases?.forEach((db) => { - if ([DatabaseEnum.ELASTICSEARCH_SERVERLESS].includes(db.type)) { - new esServerless.App( - this, - replaceReference(db.key, context), - { - appName: replaceReference(db.name, context), - appVersion: db.version, - authentication: { - basicAuth: [ - { - password: replaceReference(db.security.basicAuth.password, context), - }, - ], - }, - quotaInfo: { - cu: db.cu, - storage: db.storageSize, - appType: db.engineMode === DatabaseEngineMode.TIMESERIES ? 'TRIAL' : 'STANDARD', - }, - // network: [ - // { - // type: 'PUBLIC_KIBANA', - // enabled: true, - // whiteIpGroup: [{ groupName: 'default', ips: ['0.0.0.0/24'] }], - // }, - // { - // type: 'PUBLIC_ES', - // enabled: true, - // whiteIpGroup: [{ groupName: 'default', ips: ['0.0.0.0/24'] }], - // }, - // ], - }, - true, - ); - } - }); - } -} diff --git a/src/stack/rosStack/database.ts b/src/stack/rosStack/database.ts new file mode 100644 index 0000000..84bb30f --- /dev/null +++ b/src/stack/rosStack/database.ts @@ -0,0 +1,52 @@ +import * as ros from '@alicloud/ros-cdk-core'; +import { replaceReference } from '../../common'; +import { ActionContext, DatabaseDomain, DatabaseEngineMode, DatabaseEnum } from '../../types'; +import { isEmpty } from 'lodash'; +import * as esServerless from '@alicloud/ros-cdk-elasticsearchserverless'; + +export const resolveDatabases = ( + scope: ros.Construct, + databases: Array | undefined, + context: ActionContext, +) => { + if (isEmpty(databases)) { + return undefined; + } + databases!.forEach((db) => { + if ([DatabaseEnum.ELASTICSEARCH_SERVERLESS].includes(db.type)) { + new esServerless.App( + scope, + replaceReference(db.key, context), + { + appName: replaceReference(db.name, context), + appVersion: db.version, + authentication: { + basicAuth: [ + { + password: replaceReference(db.security.basicAuth.password, context), + }, + ], + }, + quotaInfo: { + cu: db.cu, + storage: db.storageSize, + appType: db.engineMode === DatabaseEngineMode.TIMESERIES ? 'TRIAL' : 'STANDARD', + }, + // network: [ + // { + // type: 'PUBLIC_KIBANA', + // enabled: true, + // whiteIpGroup: [{ groupName: 'default', ips: ['0.0.0.0/24'] }], + // }, + // { + // type: 'PUBLIC_ES', + // enabled: true, + // whiteIpGroup: [{ groupName: 'default', ips: ['0.0.0.0/24'] }], + // }, + // ], + }, + true, + ); + } + }); +}; diff --git a/src/stack/rosStack/event.ts b/src/stack/rosStack/event.ts new file mode 100644 index 0000000..68e44ce --- /dev/null +++ b/src/stack/rosStack/event.ts @@ -0,0 +1,117 @@ +import * as ros from '@alicloud/ros-cdk-core'; +import { ActionContext, EventDomain, EventTypes, ServerlessIac } from '../../types'; +import * as ram from '@alicloud/ros-cdk-ram'; +import { replaceReference } from '../../common'; +import * as agw from '@alicloud/ros-cdk-apigateway'; +import { isEmpty } from 'lodash'; + +export const resolveEvents = ( + scope: ros.Construct, + events: Array | undefined, + tags: ServerlessIac['tags'] | undefined, + context: ActionContext, + service: string, +) => { + if (isEmpty(events)) { + return undefined; + } + const apiGateway = events!.filter((event) => event.type === EventTypes.API_GATEWAY); + if (apiGateway?.length) { + const gatewayAccessRole = new ram.RosRole( + scope, + replaceReference(`${service}_role`, context), + { + roleName: replaceReference(`${service}-gateway-access-role`, context), + description: replaceReference(`${service} role`, context), + assumeRolePolicyDocument: { + version: '1', + statement: [ + { + action: 'sts:AssumeRole', + effect: 'Allow', + principal: { + service: ['apigateway.aliyuncs.com'], + }, + }, + ], + }, + policies: [ + { + policyName: replaceReference(`${service}-policy`, context), + policyDocument: { + version: '1', + statement: [ + { + action: ['fc:InvokeFunction'], + effect: 'Allow', + // @TODO implement at least permission granting + resource: ['*'], + }, + ], + }, + }, + ], + }, + true, + ); + + const apiGatewayGroup = new agw.RosGroup( + scope, + replaceReference(`${service}_apigroup`, context), + { + groupName: replaceReference(`${service}_apigroup`, context), + tags: replaceReference(tags, context), + }, + true, + ); + + // new agw.RosCustomDomain( + // this, + // 'customDomain', + // { + // domainName: 'example.com', + // certificateName: 'example.com', + // certificateBody: 'example.com', + // certificatePrivateKey: 'example.com', + // groupId: apiGatewayGroup.attrGroupId, + // }, + // true, + // ); + + apiGateway.forEach((event) => { + event.triggers.forEach((trigger) => { + const key = `${trigger.method}_${trigger.path}`.toLowerCase().replace(/\//g, '_'); + + const api = new agw.RosApi( + scope, + replaceReference(`${event.key}_api_${key}`, context), + { + apiName: replaceReference(`${event.name}_api_${key}`, context), + groupId: apiGatewayGroup.attrGroupId, + visibility: 'PRIVATE', + requestConfig: { + requestProtocol: 'HTTP', + requestHttpMethod: replaceReference(trigger.method, context), + requestPath: replaceReference(trigger.path, context), + requestMode: 'PASSTHROUGH', + }, + serviceConfig: { + serviceProtocol: 'FunctionCompute', + functionComputeConfig: { + fcRegionId: context.region, + functionName: replaceReference(trigger.backend, context), + roleArn: gatewayAccessRole.attrArn, + fcVersion: '3.0', + }, + }, + resultSample: 'ServerlessInsight resultSample', + resultType: 'JSON', + tags: replaceReference(tags, context), + }, + true, + ); + api.addDependsOn(apiGatewayGroup); + }); + }); + } +}; diff --git a/src/stack/rosStack/function.ts b/src/stack/rosStack/function.ts new file mode 100644 index 0000000..d67e286 --- /dev/null +++ b/src/stack/rosStack/function.ts @@ -0,0 +1,80 @@ +import { ActionContext, FunctionDomain } from '../../types'; +import { + CODE_ZIP_SIZE_LIMIT, + getFileSource, + readCodeSize, + replaceReference, + resolveCode, +} from '../../common'; +import { RosFunction } from '@alicloud/ros-cdk-fc3/lib/fc3.generated'; +import * as fc from '@alicloud/ros-cdk-fc3'; +import * as oss from '@alicloud/ros-cdk-oss'; +import { isEmpty } from 'lodash'; +import * as ossDeployment from '@alicloud/ros-cdk-ossdeployment'; +import * as ros from '@alicloud/ros-cdk-core'; + +export const resolveFunctions = ( + scope: ros.Construct, + functions: Array | undefined, + context: ActionContext, + service: string, +) => { + if (isEmpty(functions)) { + return undefined; + } + const fileSources = functions + ?.filter(({ code }) => readCodeSize(code) > CODE_ZIP_SIZE_LIMIT) + .map(({ code, name }) => ({ fcName: name, ...getFileSource(name, code) })); + + let destinationBucket: oss.Bucket; + if (!isEmpty(fileSources)) { + // creat oss to store code + destinationBucket = new oss.Bucket( + scope, + replaceReference(`${service}_artifacts_bucket`, context), + { + bucketName: `${service}-artifacts-bucket`, + serverSideEncryptionConfiguration: { sseAlgorithm: 'KMS' }, + }, + true, + ); + new ossDeployment.BucketDeployment( + scope, + `${service}_artifacts_code_deployment`, + { + sources: fileSources!.map(({ source }) => source), + destinationBucket, + timeout: 300, + logMonitoring: false, // 是否开启日志监控,设为false则不开启 + }, + true, + ); + } + functions?.forEach((fnc) => { + let code: RosFunction.CodeProperty = { + zipFile: resolveCode(fnc.code), + }; + if (readCodeSize(fnc.code) > CODE_ZIP_SIZE_LIMIT) { + code = { + ossBucketName: destinationBucket.attrName, + ossObjectName: fileSources?.find( + ({ fcName }) => fcName === replaceReference(fnc.name, context), + )?.objectKey, + }; + } + new fc.RosFunction( + scope, + fnc.key, + { + functionName: replaceReference(fnc.name, context), + handler: replaceReference(fnc.handler, context), + runtime: replaceReference(fnc.runtime, context), + memorySize: replaceReference(fnc.memory, context), + timeout: replaceReference(fnc.timeout, context), + environmentVariables: replaceReference(fnc.environment, context), + code, + }, + true, + ); + }); +}; diff --git a/src/stack/rosStack/rosStack.ts b/src/stack/rosStack/rosStack.ts new file mode 100644 index 0000000..3d5de16 --- /dev/null +++ b/src/stack/rosStack/rosStack.ts @@ -0,0 +1,34 @@ +import * as ros from '@alicloud/ros-cdk-core'; +import { ActionContext, ServerlessIac } from '../../types'; +import { replaceReference } from '../../common'; +import { resolveTags } from './tag'; +import { resolveFunctions } from './function'; +import { resolveStages } from './stage'; +import { resloveVars } from './vars'; +import { resolveDatabases } from './database'; +import { resolveEvents } from './event'; + +export class RosStack extends ros.Stack { + private readonly service: string; + + constructor(scope: ros.Construct, iac: ServerlessIac, context: ActionContext) { + super(scope, replaceReference(iac.service, context), { + stackName: context.stackName, + tags: resolveTags(iac.tags, context), + }); + + this.service = replaceReference(iac.service, context); + new ros.RosInfo(this, ros.RosInfo.description, `${this.service} stack`); + + // Define Parameters + resloveVars(this, iac.vars); + // Define Mappings + resolveStages(this, iac.stages, context); + // Define functions + resolveFunctions(this, iac.functions, context, this.service); + // Define Events + resolveEvents(this, iac.events, iac.tags, context, this.service); + // Define Databases + resolveDatabases(this, iac.databases, context); + } +} diff --git a/src/stack/rosStack/stage.ts b/src/stack/rosStack/stage.ts new file mode 100644 index 0000000..7556c35 --- /dev/null +++ b/src/stack/rosStack/stage.ts @@ -0,0 +1,15 @@ +import * as ros from '@alicloud/ros-cdk-core'; +import { replaceReference } from '../../common'; +import { ActionContext, Stages } from '../../types'; +import { isEmpty } from 'lodash'; + +export const resolveStages = ( + scope: ros.Construct, + stages: Stages | undefined, + context: ActionContext, +) => { + if (isEmpty(stages)) { + return undefined; + } + new ros.RosMapping(scope, 'stages', { mapping: replaceReference(stages, context) }); +}; diff --git a/src/stack/rosStack/tag.ts b/src/stack/rosStack/tag.ts new file mode 100644 index 0000000..8cd887e --- /dev/null +++ b/src/stack/rosStack/tag.ts @@ -0,0 +1,9 @@ +import { ActionContext, ServerlessIac } from '../../types'; +import { replaceReference } from '../../common'; + +export const resolveTags = (tags: ServerlessIac['tags'], context: ActionContext) => { + return tags?.reduce((acc: { [key: string]: string }, tag) => { + acc[tag.key] = replaceReference(tag.value, context); + return acc; + }, {}); +}; diff --git a/src/stack/rosStack/vars.ts b/src/stack/rosStack/vars.ts new file mode 100644 index 0000000..39be434 --- /dev/null +++ b/src/stack/rosStack/vars.ts @@ -0,0 +1,18 @@ +import * as ros from '@alicloud/ros-cdk-core'; +import { RosParameterType } from '@alicloud/ros-cdk-core'; +import { Vars } from '../../types'; +import { isEmpty } from 'lodash'; + +export const resloveVars = (scope: ros.Construct, vars: Vars | undefined) => { + if (isEmpty(vars)) { + return undefined; + } + + Object.entries(vars).map( + ([id, value]) => + new ros.RosParameter(scope, id, { + type: RosParameterType.STRING, + defaultValue: value, + }), + ); +}; diff --git a/src/types/domains/function.ts b/src/types/domains/function.ts index 6d9ab5e..33b741e 100644 --- a/src/types/domains/function.ts +++ b/src/types/domains/function.ts @@ -5,7 +5,7 @@ export type FunctionRaw = { code: string; memory: number; timeout: number; - environment: { + environment?: { [key: string]: string; }; }; diff --git a/tests/parser/parse.test.ts b/tests/parser/parse.test.ts index d4d7fad..b64651c 100644 --- a/tests/parser/parse.test.ts +++ b/tests/parser/parse.test.ts @@ -3,10 +3,16 @@ import { parseYaml } from '../../src/parser'; describe('unit test for parse', () => { describe('domain - databases', () => { - const yamlPath = path.resolve(__dirname, '../fixtures/serverless-insight-es.yml'); - + const defaultContext = { + iacLocation: path.resolve(__dirname, '../fixtures/serverless-insight-es.yml'), + accessKeyId: 'xxx', + accessKeySecret: 'xxx', + region: 'cn-chengdu', + stackName: 'insight-es-poc-test', + stage: 'test', + }; it('should pass databases from yaml to domain instance when the yaml is valid', () => { - const databaseDomain = parseYaml(yamlPath); + const databaseDomain = parseYaml(defaultContext); expect(databaseDomain).toEqual({ service: 'insight-es-poc', version: '0.0.1',