mirror of
https://github.com/serverless/serverless.git
synced 2025-12-08 19:46:03 +00:00
632 lines
20 KiB
JavaScript
632 lines
20 KiB
JavaScript
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
|