import _ from 'lodash' import crypto from 'crypto' import path from 'path' import fs from 'fs' import wait from 'timers-ext/promise/sleep.js' import validate from './lib/validate.js' import filesize from '../../utils/filesize.js' import ServerlessError from '../../serverless-error.js' class AwsDeployFunction { constructor(serverless, options, pluginUtils) { this.serverless = serverless this.logger = pluginUtils.log this.loggerStyle = pluginUtils.style this.progress = pluginUtils.progress this.options = options || {} this.packagePath = this.options.package || this.serverless.service.package.path || path.join(this.serverless.serviceDir || '.', '.serverless') this.provider = this.serverless.getProvider('aws') this.shouldEnsureFunctionState = false Object.assign(this, validate) this.hooks = { initialize: () => { const commandName = this.serverless.processedInput.commands.join(' ') if (commandName !== 'deploy function') return this.logger.notice( `Deploying function "${ this.options.function }" to stage "${this.serverless .getProvider('aws') .getStage()}" ${this.loggerStyle.aside( `(${this.serverless.getProvider('aws').getRegion()})`, )}`, ) }, 'before:deploy:function:initialize': () => this.progress.notice('Validating', { isMainEvent: true }), 'deploy:function:initialize': async () => { this.logger.debug('validating') await this.validate() this.logger.debug('checking if function exists') await this.checkIfFunctionExists() this.logger.debug('checking if function changed') this.checkIfFunctionChangesBetweenImageAndHandler() }, 'before:deploy:function:packageFunction': () => this.progress.notice('Retrieving function info'), 'deploy:function:packageFunction': async () => this.serverless.pluginManager.spawn('package:function'), 'before:deploy:function:deploy': () => this.progress.notice('Packaging', { isMainEvent: true }), 'deploy:function:deploy': async () => { if (!this.options['update-config']) { this.logger.debug('deploying function code') await this.deployFunction() } await this.updateFunctionConfiguration() if (this.shouldEnsureFunctionState) { await this.ensureFunctionState() } await this.serverless.pluginManager.spawn('aws:common:cleanupTempDir') }, } } async checkIfFunctionExists() { this.progress.notice('Checking for changes') // check if the function exists in the service this.options.functionObj = this.serverless.service.getFunction( this.options.function, ) // check if function exists on AWS const params = { FunctionName: this.options.functionObj.name, } const result = await (async () => { try { return await this.provider.request('Lambda', 'getFunction', params) } catch (error) { if ( _.get(error, 'providerError.code') === 'ResourceNotFoundException' ) { const errorMessage = [ `The function "${this.options.function}" you want to update is not yet deployed.`, ' Please run "serverless deploy" to deploy your service.', ' After that you can redeploy your services functions with the', ' "serverless deploy function" command.', ].join('') throw new ServerlessError(errorMessage, 'FUNCTION_NOT_YET_DEPLOYED') } throw error } })() if (result) this.serverless.service.provider.remoteFunctionData = result } checkIfFunctionChangesBetweenImageAndHandler() { const functionObject = this.serverless.service.getFunction( this.options.function, ) const remoteFunctionPackageType = this.serverless.service.provider.remoteFunctionData.Configuration .PackageType if (functionObject.handler && remoteFunctionPackageType === 'Image') { throw new ServerlessError( `The function "${this.options.function}" you want to update with handler was previously packaged as an image. Please run "serverless deploy" to ensure consistent deploy.`, 'DEPLOY_FUNCTION_CHANGE_BETWEEN_HANDLER_AND_IMAGE_ERROR', ) } if (functionObject.image && remoteFunctionPackageType === 'Zip') { throw new ServerlessError( `The function "${this.options.function}" you want to update with image was previously packaged as zip file. Please run "serverless deploy" to ensure consistent deploy.`, 'DEPLOY_FUNCTION_CHANGE_BETWEEN_HANDLER_AND_IMAGE_ERROR', ) } } async normalizeArnRole(role) { if (typeof role === 'string') { if (role.indexOf(':') !== -1) { return role } const roleResource = this.serverless.service.resources.Resources[role] if (roleResource.Type !== 'AWS::IAM::Role') { throw new ServerlessError( 'Provided resource is not IAM Role', 'ROLE_REFERENCES_NON_AWS_IAM_ROLE', ) } const roleProperties = roleResource.Properties if (!roleProperties.RoleName) { throw new ServerlessError( 'Role resource missing RoleName property', 'MISSING_ROLENAME_FOR_ROLE', ) } const compiledFullRoleName = `${roleProperties.Path || '/'}${ roleProperties.RoleName }` const result = await this.provider.getAccountInfo() return `arn:${result.partition}:iam::${result.accountId}:role${compiledFullRoleName}` } const data = await this.provider.request('IAM', 'getRole', { RoleName: role['Fn::GetAtt'][0], }) return data.Arn } async ensureFunctionState() { this.options.functionObj = this.serverless.service.getFunction( this.options.function, ) const params = { FunctionName: this.options.functionObj.name, } const startTime = Date.now() const callWithRetry = async () => { const result = await this.provider.request( 'Lambda', 'getFunction', params, ) if ( result && result.Configuration.State === 'Active' && result.Configuration.LastUpdateStatus === 'Successful' ) { return } const didOneMinutePass = Date.now() - startTime > 60 * 1000 if (didOneMinutePass) { throw new ServerlessError( 'Ensuring function state timed out. Please try to deploy your function once again.', 'DEPLOY_FUNCTION_ENSURE_STATE_TIMED_OUT', ) } this.logger.info( `Retrying ensure function state for function: ${this.options.function}.`, ) await wait(500) await callWithRetry() } await callWithRetry() } async callUpdateFunctionConfiguration(params) { this.logger.debug('deploying function configuration changes') const startTime = Date.now() const callWithRetry = async () => { try { await this.provider.request( 'Lambda', 'updateFunctionConfiguration', params, ) } catch (err) { const didOneMinutePass = Date.now() - startTime > 60 * 1000 if ( err.providerError && err.providerError.code === 'ResourceConflictException' ) { if (didOneMinutePass) { throw new ServerlessError( 'Retry timed out. Please try to deploy your function once again.', 'DEPLOY_FUNCTION_CONFIGURATION_UPDATE_TIMED_OUT', ) } this.logger.info( `Retrying configuration update for function: ${this.options.function}. Reason: ${err.message}`, ) await wait(1000) await callWithRetry() } else { throw err } } } await callWithRetry() } async updateFunctionConfiguration() { const functionObj = this.options.functionObj const providerObj = this.serverless.service.provider const remoteFunctionConfiguration = this.serverless.service.provider.remoteFunctionData.Configuration const params = { FunctionName: functionObj.name, } const kmsKeyArn = functionObj.kmsKeyArn || providerObj.kmsKeyArn if (kmsKeyArn) { params.KMSKeyArn = kmsKeyArn } if ( params.KMSKeyArn && params.KMSKeyArn === remoteFunctionConfiguration.KMSKeyArn ) { delete params.KMSKeyArn } if (functionObj.snapStart) { params.SnapStart = { ApplyOn: 'PublishedVersions', } } if ( functionObj.description && functionObj.description !== remoteFunctionConfiguration.Description ) { params.Description = functionObj.description } if ( functionObj.handler && functionObj.handler !== remoteFunctionConfiguration.Handler ) { params.Handler = functionObj.handler } if (functionObj.memorySize) { params.MemorySize = functionObj.memorySize } else if (providerObj.memorySize) { params.MemorySize = providerObj.memorySize } if ( params.MemorySize && params.MemorySize === remoteFunctionConfiguration.MemorySize ) { delete params.MemorySize } if (functionObj.timeout) { params.Timeout = functionObj.timeout } else if (providerObj.timeout) { params.Timeout = providerObj.timeout } if ( params.Timeout && params.Timeout === remoteFunctionConfiguration.Timeout ) { delete params.Timeout } const runtime = this.provider.getRuntime(functionObj.runtime) if (runtime !== remoteFunctionConfiguration.Runtime) { params.Runtime = runtime } // Check if we have remotely managed layers and add them to the update call // if they exist in the remote function configuration. const isConsoleSdkLayerArn = RegExp.prototype.test.bind( /(?:177335420605|321667558080):layer:sls-/u, ) const serverlessConsoleLayerArns = ( remoteFunctionConfiguration.Layers || [] ) .filter(({ Arn: arn }) => isConsoleSdkLayerArn(arn)) .map(({ Arn }) => Arn) const hasServerlessConsoleLayers = serverlessConsoleLayerArns.length > 0 if (!functionObj.layers || !functionObj.layers.some(_.isObject)) { // We need to initialize to an empty array so if a layer is removed // we will send an empty Layers array in the update call to remove any layers. // If there are no layers in the remove config this property will be set to undefined anyway. params.Layers = functionObj.layers || providerObj.layers || [] if (!remoteFunctionConfiguration.Layers) { remoteFunctionConfiguration.Layers = [] } if (hasServerlessConsoleLayers) { for (const layer of serverlessConsoleLayerArns) { if (!params.Layers.includes(layer)) { params.Layers.push(layer) } } } // Do not attach layers to the update call if the layers did not change. if ( params.Layers && remoteFunctionConfiguration.Layers && _.isEqual( new Set(params.Layers), new Set(remoteFunctionConfiguration.Layers.map((layer) => layer.Arn)), ) ) { delete params.Layers } } if ( functionObj.onError && !_.isObject(functionObj.onError) && _.get(remoteFunctionConfiguration, 'DeadLetterConfig.TargetArn', null) !== functionObj.onError ) { params.DeadLetterConfig = { TargetArn: functionObj.onError, } } // Add empty environment object if it does not exist // so when we do the comparison below it will be equal to an empty object params.Environment = { Variables: {}, } if (!remoteFunctionConfiguration.Environment) { remoteFunctionConfiguration.Environment = { Variables: {}, } } if (functionObj.environment || providerObj.environment) { params.Environment.Variables = Object.assign( {}, providerObj.environment, functionObj.environment, ) } if ( Object.values(params.Environment.Variables).some((value) => _.isObject(value), ) ) { delete params.Environment } else { Object.keys(params.Environment.Variables).forEach((key) => { // taken from the bash man pages if (!key.match(/^[A-Za-z_][a-zA-Z0-9_]*$/)) { const errorMessage = 'Invalid characters in environment variable' throw new ServerlessError( errorMessage, 'DEPLOY_FUNCTION_INVALID_ENV_VARIABLE', ) } if (params.Environment.Variables[key] != null) { params.Environment.Variables[key] = String( params.Environment.Variables[key], ) } }) } // If we detected remotely managed layers, we need to add the environment variables // that are managed by the Serverless Console to the update call so they do not get removed. if (params.Environment && hasServerlessConsoleLayers) { const consoleEnvironmentVariableNames = [ 'AWS_LAMBDA_EXEC_WRAPPER', 'SLS_ORG_ID', 'SLS_DEV_MODE_ORG_ID', 'SLS_DEV_TOKEN', 'SERVERLESS_PLATFORM_STAGE', ] const remoteVariables = remoteFunctionConfiguration.Environment.Variables const localVariables = params.Environment.Variables for (const variableName of consoleEnvironmentVariableNames) { if (remoteVariables[variableName] && !localVariables[variableName]) { localVariables[variableName] = remoteVariables[variableName] } } } if ( params.Environment && remoteFunctionConfiguration.Environment && _.isEqual( params.Environment.Variables, remoteFunctionConfiguration.Environment.Variables, ) ) { delete params.Environment } if (functionObj.vpc || providerObj.vpc) { const vpc = functionObj.vpc || providerObj.vpc params.VpcConfig = {} if ( Array.isArray(vpc.securityGroupIds) && !vpc.securityGroupIds.some(_.isObject) ) { params.VpcConfig.SecurityGroupIds = vpc.securityGroupIds } if (Array.isArray(vpc.subnetIds) && !vpc.subnetIds.some(_.isObject)) { params.VpcConfig.SubnetIds = vpc.subnetIds } const didVpcChange = () => { const remoteConfigToCompare = { SecurityGroupIds: [], SubnetIds: [] } if (remoteFunctionConfiguration.VpcConfig) { remoteConfigToCompare.SecurityGroupIds = new Set( remoteFunctionConfiguration.VpcConfig.SecurityGroupIds || [], ) remoteConfigToCompare.SubnetIds = new Set( remoteFunctionConfiguration.VpcConfig.SubnetIds || [], ) } const localConfigToCompare = { SecurityGroupIds: new Set(params.VpcConfig.SecurityGroupIds || []), SubnetIds: new Set(params.VpcConfig.SubnetIds || []), } return _.isEqual(remoteConfigToCompare, localConfigToCompare) } if (!Object.keys(params.VpcConfig).length || didVpcChange()) { delete params.VpcConfig } } const executionRole = this.provider.getCustomExecutionRole(functionObj) if (executionRole) { params.Role = await this.normalizeArnRole(executionRole) } if (params.Role === remoteFunctionConfiguration.Role) { delete params.Role } if (functionObj.image) { const imageConfig = {} if (_.isObject(functionObj.image)) { if (functionObj.image.command) { imageConfig.Command = functionObj.image.command } if (functionObj.image.entryPoint) { imageConfig.EntryPoint = functionObj.image.entryPoint } if (functionObj.image.workingDirectory) { imageConfig.WorkingDirectory = functionObj.image.workingDirectory } } if ( !_.isEqual( imageConfig, _.get( remoteFunctionConfiguration, 'ImageConfigResponse.ImageConfig', {}, ), ) ) { params.ImageConfig = imageConfig } } if (!Object.keys(_.omit(params, 'FunctionName')).length) { const noticeMessage = [ 'Function configuration did not change, so the update was skipped.', ' If you made changes to the service configuration and expected them to be deployed,', ' this most likely means that they can only be applied with a full service deployment: "serverless deploy".', ].join('') this.logger.aside( `${noticeMessage} ${this.loggerStyle.aside( `(${Math.floor( (Date.now() - this.serverless.pluginManager.commandRunStartTime) / 1000, )}s)`, )}`, ) return } this.progress.notice('Updating function configuration', { isMainEvent: true, }) await this.callUpdateFunctionConfiguration(params) this.shouldEnsureFunctionState = true if (this.options['update-config']) this.logger.notice() this.logger.success( `Function configuration updated ${this.loggerStyle.aside( `(${Math.floor( (Date.now() - this.serverless.pluginManager.commandRunStartTime) / 1000, )}s)`, )}\n`, ) } async deployFunction() { const functionObject = this.serverless.service.getFunction( this.options.function, ) const params = { FunctionName: this.options.functionObj.name, } if (functionObject.image) { const { functionImageUri, functionImageSha } = await this.provider.resolveImageUriAndSha(this.options.function) const remoteImageSha = this.serverless.service.provider.remoteFunctionData.Configuration .CodeSha256 if (remoteImageSha === functionImageSha && !this.options.force) { this.logger.notice( `Image did not change. Function deployment skipped. ${this.loggerStyle.aside( `(${Math.floor( (Date.now() - this.serverless.pluginManager.commandRunStartTime) / 1000, )}s)`, )}`, ) return } params.ImageUri = functionImageUri } else { const artifactFileName = this.provider.naming.getFunctionArtifactName( this.options.function, ) let artifactFilePath = this.serverless.service.package.artifact || path.join(this.packagePath, artifactFileName) // check if an artifact is used in function package level if (_.get(functionObject, 'package.artifact')) { artifactFilePath = functionObject.package.artifact } const data = fs.readFileSync(artifactFilePath) const remoteHash = this.serverless.service.provider.remoteFunctionData.Configuration .CodeSha256 const localHash = crypto .createHash('sha256') .update(data) .digest('base64') if (remoteHash === localHash && !this.options.force) { this.logger.aside( `Code did not change. Function deployment skipped. ${this.loggerStyle.aside( `(${Math.floor( (Date.now() - this.serverless.pluginManager.commandRunStartTime) / 1000, )}s)`, )}`, ) return } params.ZipFile = data const stats = fs.statSync(artifactFilePath) this.progress.notice(`Uploading (${filesize(stats.size)})`, { isMainEvent: true, }) } await this.provider.request('Lambda', 'updateFunctionCode', params) this.shouldEnsureFunctionState = true this.logger.success( `Function code deployed ${this.loggerStyle.aside( `(${Math.floor( (Date.now() - this.serverless.pluginManager.commandRunStartTime) / 1000, )}s)`, )}`, ) } } export default AwsDeployFunction