Cimpress-MCP/LambdaWrap

View on GitHub
lib/lambda_wrap/lambda_manager.rb

Summary

Maintainability
B
5 hrs
Test Coverage
require 'set'
require 'pathname'
require 'active_support/core_ext/object/blank'

module LambdaWrap
  # Lambda Manager class.
  # Front loads the configuration to the constructor so that the developer can be more declarative with configuration
  # and deployments.
  # @since 1.0
  class Lambda < AwsService
    # Initializes a Lambda Manager. Frontloaded configuration.
    #
    # @param [Hash] options The Configuration for the Lambda
    # @option options [String] :lambda_name The name you want to assign to the function you are uploading. The function
    #  names appear in the console and are returned in the ListFunctions API. Function names are used to specify
    #  functions to other AWS Lambda API operations, such as Invoke. Note that the length constraint applies only to
    #  the ARN. If you specify only the function name, it is limited to 64 characters in length.
    # @option options [String] :handler The function within your code that Lambda calls to begin execution.
    # @option options [String] :role_arn The Amazon Resource Name (ARN) of the IAM role that Lambda assumes when it
    #  executes your function to access any other Amazon Web Services (AWS) resources.
    # @option options [String] :path_to_zip_file The absolute path to the Deployment Package zip file
    # @option options [String] :runtime The runtime environment for the Lambda function you are uploading.
    # @option options [String] :description ('Deployed with LambdaWrap') A short, user-defined function description.
    #  Lambda does not use this value. Assign a meaningful description as you see fit.
    # @option options [Integer] :timeout (30) The function execution time at which Lambda should terminate the function.
    # @option options [Integer] :memory_size (128) The amount of memory, in MB, your Lambda function is given. Lambda
    #  uses this memory size to infer the amount of CPU and memory allocated to your function. The value must be a
    #  multiple of 64MB. Minimum: 128, Maximum: 3008.
    # @option options [Array<String>] :subnet_ids ([]) If your Lambda function accesses resources in a VPC, you provide
    #  this parameter identifying the list of subnet IDs. These must belong to the same VPC. You must provide at least
    #  one security group and one subnet ID to configure VPC access.
    # @option options [Array<String>] :security_group_ids ([]) If your Lambda function accesses resources in a VPC, you
    #  provide this parameter identifying the list of security group IDs. These must belong to the same VPC. You must
    #  provide at least one security group and one subnet ID.
    # @option options [Boolean] :delete_unreferenced_versions (true) Option to delete any Lambda Function Versions upon
    #  deployment that do not have an alias pointing to them.
    # @option options [String] :dead_letter_queue_arn ('') The ARN of the SQS Queue for failed async invocations.
    def initialize(options)
      defaults = {
        description: 'Deployed with LambdaWrap', subnet_ids: [], security_group_ids: [], timeout: 30, memory_size: 128,
        delete_unreferenced_versions: true, dead_letter_queue_arn: ''
      }
      options_with_defaults = options.reverse_merge(defaults)
      unless (options_with_defaults[:lambda_name]) && (options_with_defaults[:lambda_name].is_a? String)
        raise ArgumentError, 'lambda_name must be provided (String)!'
      end
      @lambda_name = options_with_defaults[:lambda_name]

      unless (options_with_defaults[:handler]) && (options_with_defaults[:handler].is_a? String)
        raise ArgumentError, 'handler must be provided (String)!'
      end
      @handler = options_with_defaults[:handler]

      unless (options_with_defaults[:role_arn]) && (options_with_defaults[:role_arn].is_a? String)
        raise ArgumentError, 'role_arn must be provided (String)!'
      end
      @role_arn = options_with_defaults[:role_arn]

      unless (options_with_defaults[:path_to_zip_file]) && (options_with_defaults[:path_to_zip_file].is_a? String)
        raise ArgumentError, 'path_to_zip_file must be provided (String)!'
      end
      @path_to_zip_file = options_with_defaults[:path_to_zip_file]

      unless (options_with_defaults[:runtime]) && (options_with_defaults[:runtime].is_a? String)
        raise ArgumentError, 'runtime must be provided (String)!'
      end

      unless SUPPORTED_RUNTIMES.include?(options_with_defaults[:runtime])
        raise ArgumentError, "Invalid Runtime specified: #{options_with_defaults[:runtime]}." \
          "Only accepts: #{SUPPORTED_RUNTIMES}"
      end

      @runtime = options_with_defaults[:runtime]

      unless (options_with_defaults[:memory_size] % 64).zero? && (options_with_defaults[:memory_size] >= 128) &&
             (options_with_defaults[:memory_size] <= 3008)
        raise ArgumentError, 'Invalid Memory Size.'
      end
      @memory_size = options_with_defaults[:memory_size]
      # VPC
      if options_with_defaults[:subnet_ids].empty? ^ options_with_defaults[:security_group_ids].empty?
        raise ArgumentError, 'Must supply values for BOTH Subnet Ids and Security Group ID if VPC is desired.'
      end
      unless options_with_defaults[:subnet_ids].empty?
        @vpc_configuration = {
          subnet_ids: options_with_defaults[:subnet_ids],
          security_group_ids: options_with_defaults[:security_group_ids]
        }
      end
      @description = options_with_defaults[:description]
      @timeout = options_with_defaults[:timeout]
      @delete_unreferenced_versions = options_with_defaults[:delete_unreferenced_versions]
      @dead_letter_queue_arn = options_with_defaults[:dead_letter_queue_arn]
    end

    # Deploys the Lambda to the specified Environment. Creates a Lambda Function if one didn't exist.
    # Updates the Lambda's configuration, Updates the Lambda's Code, publishes a new version, and creates
    # an alias that points to the newly published version. If the @delete_unreferenced_versions option
    # is enabled, all Lambda Function versions that don't have an alias pointing to them will be deleted.
    #
    # @param environment_options [LambdaWrap::Environment] The target Environment to deploy
    # @param client [Aws::Lambda::Client] Client to use with SDK. Should be passed in by the API class.
    # @param region [String] AWS Region string. Should be passed in by the API class.
    def deploy(environment_options, client, region = 'AWS_REGION')
      super

      puts "Deploying Lambda: #{@lambda_name} to Environment: #{environment_options.name}"

      unless File.exist?(@path_to_zip_file)
        raise ArgumentError, "Deployment Package Zip File does not exist: #{@path_to_zip_file}!"
      end

      lambda_details = retrieve_lambda_details

      if lambda_details.nil?
        function_version = create_lambda
      else
        update_lambda_config
        function_version = update_lambda_code
      end

      create_alias(function_version, environment_options.name, environment_options.description)

      cleanup_unused_versions if @delete_unreferenced_versions

      puts "Lambda: #{@lambda_name} successfully deployed!"
      true
    end

    # Tearsdown an Environment. Deletes an alias with the same name as the environment. Deletes
    # Unreferenced Lambda Function Versions if the option was specified.
    #
    # @param environment_options [LambdaWrap::Environment] The target Environment to teardown.
    # @param client [Aws::Lambda::Client] Client to use with SDK. Should be passed in by the API class.
    # @param region [String] AWS Region string. Should be passed in by the API class.
    def teardown(environment_options, client, region = 'AWS_REGION')
      super
      remove_alias(environment_options.name)
      cleanup_unused_versions if @delete_unreferenced_versions
      true
    end

    # Deletes the Lambda Object with associated versions, code, configuration, and aliases.
    #
    # @param client [Aws::Lambda::Client] Client to use with SDK. Should be passed in by the API class.
    # @param region [String] AWS Region string. Should be passed in by the API class.
    def delete(client, region = 'AWS_REGION')
      super
      puts "Deleting all versions and aliases for Lambda: #{@lambda_name}"
      lambda_details = retrieve_lambda_details
      if lambda_details.nil?
        puts 'No Lambda to delete.'
      else
        options = { function_name: @lambda_name }
        @client.delete_function(options)
        puts "Lambda #{@lambda_name} and all Versions & Aliases have been deleted."
      end
      true
    end

    def to_s
      return @lambda_name if @lambda_name && @lambda_name.is_a?(String)
      super
    end

    private

    SUPPORTED_RUNTIMES = [
      'nodejs4.3',
      'nodejs6.10',
      'java8',
      'python2.7',
      'python3.6',
      'dotnetcore1.0',
      'dotnetcore2.0',
      'nodejs4.3-edge',
      'go1.x'
    ].freeze

    def retrieve_lambda_details
      lambda_details = nil
      begin
        options = { function_name: @lambda_name }
        lambda_details = @client.get_function(options).configuration
      rescue Aws::Lambda::Errors::ResourceNotFoundException, Aws::Lambda::Errors::NotFound
        puts "Lambda #{@lambda_name} does not exist."
      end
      lambda_details
    end

    def create_lambda
      puts "Creating New Lambda Function: #{@lambda_name}...."
      puts "Runtime Engine: #{@runtime}, Timeout: #{@timeout}, Memory Size: #{@memory_size}."
      options = {
        function_name: @lambda_name, runtime: @runtime, role: @role_arn, handler: @handler,
        code: { zip_file: File.binread(@path_to_zip_file) }, description: @description, timeout: @timeout,
        memory_size: @memory_size, vpc_config: @vpc_configuration, publish: true
      }
      options[:dead_letter_config] = { target_arn: @dead_letter_queue_arn } unless @dead_letter_queue_arn.blank?

      lambda_version = @client.create_function(options).version
      puts "Successfully created Lambda: #{@lambda_name}!"
      lambda_version
    end

    def update_lambda_config
      puts "Updating Lambda Config for #{@lambda_name}..."
      puts "Runtime Engine: #{@runtime}, Timeout: #{@timeout}, Memory Size: #{@memory_size}."
      if @vpc_configuration
        puts "With VPC Configuration: Subnets: #{@vpc_configuration[:subnet_ids]}, Security Groups: \
#{@vpc_configuration[:security_group_ids]}"
      end

      options = {
        function_name: @lambda_name, role: @role_arn, handler: @handler, description: @description, timeout: @timeout,
        memory_size: @memory_size, vpc_config: @vpc_configuration, runtime: @runtime
      }
      options[:dead_letter_config] = { target_arn: @dead_letter_queue_arn } unless @dead_letter_queue_arn.blank?

      @client.update_function_configuration(options)

      puts "Successfully updated Lambda configuration for #{@lambda_name}"
    end

    def update_lambda_code
      puts "Updating Lambda Code for #{@lambda_name}...."
      options = {
        function_name: @lambda_name,
        zip_file: File.binread(@path_to_zip_file),
        publish: true
      }

      response = @client.update_function_code(options)

      puts "Successully updated Lambda #{@lambda_name} code to version: #{response.version}"
      response.version
    end

    def create_alias(func_version, alias_name, alias_description)
      options = {
        function_name: @lambda_name, name: alias_name, function_version: func_version,
        description: alias_description || 'Alias managed by LambdaWrap'
      }
      if alias_exist?(alias_name)
        @client.update_alias(options)
      else
        @client.create_alias(options)
      end
      puts "Created Alias: #{alias_name} for Lambda: #{@lambda_name} v#{func_version}."
    end

    def remove_alias(alias_name)
      puts "Deleting Alias: #{alias_name} for #{@lambda_name}"
      options = { function_name: @lambda_name, name: alias_name }
      @client.delete_alias(options)
    end

    def cleanup_unused_versions
      puts "Cleaning up unused function versions for #{@lambda_name}."
      function_versions_to_be_deleted = retrieve_all_function_versions -
                                        retrieve_function_versions_used_in_aliases

      return if function_versions_to_be_deleted.empty?

      function_versions_to_be_deleted.each do |version|
        puts "Deleting function version: #{version}."
        options = { function_name: @lambda_name, qualifier: version }
        @client.delete_function(options)
      end

      puts "Cleaned up #{function_versions_to_be_deleted.length} unused versions."
    end

    def retrieve_all_function_versions
      function_versions = []
      response = nil
      loop do
        options = {
          function_name: @lambda_name
        }
        unless !response || response.next_marker.nil? || response.next_marker.empty?
          options[:marker] = response.next_marker
        end
        response = @client.list_versions_by_function(options)
        function_versions.concat(response.versions.map(&:version))
        if response.next_marker.nil? || response.next_marker.empty?
          return function_versions.reject { |v| v == '$LATEST' }
        end
      end
    end

    def retrieve_all_aliases
      aliases = []
      response = nil
      loop do
        options = {
          function_name: @lambda_name
        }
        unless !response || response.next_marker.nil? || response.next_marker.empty?
          options[:marker] = response.next_marker
        end
        response = @client.list_aliases(options)
        aliases.concat(response.aliases)
        return aliases if response.aliases.empty? || response.next_marker.nil? || response.next_marker.empty?
      end
    end

    def retrieve_function_versions_used_in_aliases
      function_versions_with_aliases = Set.new []
      function_versions_with_aliases.merge(retrieve_all_aliases.map(&:function_version)).to_a
    end

    def alias_exist?(alias_name)
      retrieve_all_aliases.detect { |a| a.name == alias_name }
    end
  end
end