aws/aws-codedeploy-agent

View on GitHub
lib/instance_agent/plugins/codedeploy/command_executor.rb

Summary

Maintainability
F
3 days
Test Coverage
require 'openssl'
require 'fileutils'
require 'aws-sdk-core'
require 'aws-sdk-s3'
require 'zlib'
require 'zip'
require 'instance_metadata'
require 'open-uri'
require 'uri'
require 'set'

require 'instance_agent/plugins/codedeploy/command_poller'
require 'instance_agent/plugins/codedeploy/deployment_specification'
require 'instance_agent/plugins/codedeploy/hook_executor'
require 'instance_agent/plugins/codedeploy/installer'
require 'instance_agent/string_utils'

module InstanceAgent
  module Plugins
    module CodeDeployPlugin
      ARCHIVES_TO_RETAIN = 5
      class CommandExecutor
        class << self
          attr_reader :command_methods
        end

        attr_reader :deployment_system

        InvalidCommandNameFailure = Class.new(Exception)

        def initialize(options = {})
          @deployment_system = "CodeDeploy"
          @hook_mapping = options[:hook_mapping]
          if(!@hook_mapping.nil?)
            map
          end
          begin
            max_revisions = ProcessManager::Config.config[:max_revisions]
            @archives_to_retain = max_revisions.nil?? ARCHIVES_TO_RETAIN : Integer(max_revisions)
            if @archives_to_retain < 1
              raise ArgumentError
            end
          rescue ArgumentError
            log(:error, "Invalid configuration :max_revision=#{max_revisions}")
            Platform.util.quit()
          end
          log(:info, "Archives to retain is: #{@archives_to_retain}}")
        end

        def self.command(name, &blk)
          @command_methods ||= Hash.new
          raise "Received command is not in PascalCase form: #{name.to_s}" unless StringUtils.is_pascal_case(name.to_s)
          method = StringUtils.underscore(name.to_s)
          @command_methods[name] = method

          define_method(method, &blk)
        end

        def is_command_noop?(command_name, deployment_spec)
          deployment_spec = InstanceAgent::Plugins::CodeDeployPlugin::DeploymentSpecification.parse(deployment_spec)

          # DownloadBundle and Install are never noops.
          return false if command_name == "Install" || command_name == "DownloadBundle"
          return true if @hook_mapping[command_name].nil?

          @hook_mapping[command_name].each do |lifecycle_event|
            # Although we're not executing any commands here, the HookExecutor handles
            # selecting the correct version of the appspec (last successful or current deployment) for us.
            hook_executor = create_hook_executor(lifecycle_event, deployment_spec)

            is_noop = hook_executor.is_noop?
            if is_noop
              log(:info, "Lifecycle event #{lifecycle_event} is a noop")
            end
            return false unless is_noop
          end

          log(:info, "Noop check completed for command #{command_name}, all lifecycle events are noops.")
          return true
        end

        def total_timeout_for_all_lifecycle_events(command_name, deployment_spec)
          parsed_spec = InstanceAgent::Plugins::CodeDeployPlugin::DeploymentSpecification.parse(deployment_spec)
          timeout_sums = ((@hook_mapping || {command_name => []})[command_name] || []).map do |lifecycle_event|
            create_hook_executor(lifecycle_event, parsed_spec).total_timeout_for_all_scripts
          end

          total_timeout = nil
          if timeout_sums.empty?
            log(:info, "Command #{command_name} has no script timeouts specified in appspec.")
          # If any lifecycle events' scripts don't specify a timeout, don't set a value.
          # The default will be the maximum at the server.
          elsif timeout_sums.include?(nil)
            log(:info, "Command #{command_name} has at least one script that does not specify a timeout. " +
              "No timeout override will be sent.")
          else
            total_timeout = timeout_sums.reduce(0) {|running_sum, item| running_sum + item}
            log(:info, "Command #{command_name} has total script timeout #{total_timeout} in appspec.")
          end

          total_timeout
        end

        def execute_command(command, deployment_specification)
          method_name = command_method(command.command_name)
          log(:debug, "Command #{command.command_name} maps to method #{method_name}")

          deployment_specification = InstanceAgent::Plugins::CodeDeployPlugin::DeploymentSpecification.parse(deployment_specification)
          log(:debug, "Successfully parsed the deployment spec")

          log(:debug, "Creating deployment root directory #{deployment_root_dir(deployment_specification)}")
          FileUtils.mkdir_p(deployment_root_dir(deployment_specification))
          raise "Error creating deployment root directory #{deployment_root_dir(deployment_specification)}" if !File.directory?(deployment_root_dir(deployment_specification))

          send(method_name, command, deployment_specification)
        end

        def command_method(command_name)
          raise InvalidCommandNameFailure.new("Unsupported command type: #{command_name}.") unless self.class.command_methods.has_key?(command_name)
          self.class.command_methods[command_name]
        end

        command "DownloadBundle" do |cmd, deployment_spec|
          cleanup_old_archives(deployment_spec)
          log(:debug, "Executing DownloadBundle command for execution #{cmd.deployment_execution_id}")

          case deployment_spec.revision_source
          when 'S3'
            download_from_s3(
            deployment_spec,
            deployment_spec.bucket,
            deployment_spec.key,
            deployment_spec.version,
            deployment_spec.etag)
          when 'GitHub'
            download_from_github(
            deployment_spec,
            deployment_spec.external_account,
            deployment_spec.repository,
            deployment_spec.commit_id,
            deployment_spec.anonymous,
            deployment_spec.external_auth_token)
          when 'Local File'
            handle_local_file(
              deployment_spec,
              deployment_spec.local_location)
          when 'Local Directory'
            handle_local_directory(
              deployment_spec,
              deployment_spec.local_location)
          else
            # This should never happen since this is checked during creation of the deployment_spec object.
            raise "Unknown revision type '#{deployment_spec.revision_source}'"
          end

          if deployment_spec.bundle_type != 'directory'
            FileUtils.rm_rf(File.join(deployment_root_dir(deployment_spec), 'deployment-archive'))
            bundle_file = artifact_bundle(deployment_spec)

            unpack_bundle(cmd, bundle_file, deployment_spec)
          end

          FileUtils.mkdir_p(deployment_instructions_dir)
          log(:debug, "Instructions directory created at #{deployment_instructions_dir}")
          update_most_recent_install(deployment_spec)
          nil
        end

        command "Install" do |cmd, deployment_spec|
          log(:debug, "Executing Install command for execution #{cmd.deployment_execution_id}")

          if !File.directory?(deployment_instructions_dir)
            FileUtils.mkdir_p(deployment_instructions_dir)
            log(:debug, "Instructions directory created at #{deployment_instructions_dir}")
          end

          installer = InstanceAgent::Plugins::CodeDeployPlugin::Installer.new(:deployment_instructions_dir => deployment_instructions_dir,
          :deployment_archive_dir => archive_root_dir(deployment_spec),
          :file_exists_behavior => deployment_spec.file_exists_behavior)

          log(:debug, "Installing revision #{deployment_spec.revision} in "+
          "instance group #{deployment_spec.deployment_group_id}")
          installer.install(deployment_spec.deployment_group_id, default_app_spec(deployment_spec))
          update_last_successful_install(deployment_spec)
          nil
        end

        def map
          @hook_mapping.each_pair do |command, lifecycle_events|
            InstanceAgent::Plugins::CodeDeployPlugin::CommandExecutor.command command do |cmd, deployment_spec|
              #run the scripts
              script_log = InstanceAgent::Plugins::CodeDeployPlugin::ScriptLog.new
              lifecycle_events.each do |lifecycle_event|
                hook_command = create_hook_executor(lifecycle_event, deployment_spec)
                script_log.concat_log(hook_command.execute)
              end
              script_log.log
            end
          end
        end

        private
        def deployment_root_dir(deployment_spec)
          File.join(ProcessManager::Config.config[:root_dir], deployment_spec.deployment_group_id, deployment_spec.deployment_id)
        end

        private
        def deployment_instructions_dir()
          File.join(ProcessManager::Config.config[:root_dir], 'deployment-instructions')
        end

        private
        def archive_root_dir(deployment_spec)
          File.join(deployment_root_dir(deployment_spec), 'deployment-archive')
        end

        private
        def last_successful_deployment_dir(deployment_group)
          last_successful_install_file_location = last_successful_install_file_path(deployment_group)
          return unless File.exist? last_successful_install_file_location
          File.open last_successful_install_file_location do |f|
            return f.read.chomp
          end
        end

        private
        def most_recent_deployment_dir(deployment_group)
          most_recent_install_file_location = most_recent_install_file_path(deployment_group)
          return unless File.exist? most_recent_install_file_location
          File.open most_recent_install_file_location do |f|
            return f.read.chomp
          end
        end

        private
        def create_hook_executor(lifecycle_event, deployment_spec)
          HookExecutor.new(:lifecycle_event => lifecycle_event,
            :application_name => deployment_spec.application_name,
            :deployment_id => deployment_spec.deployment_id,
            :deployment_group_name => deployment_spec.deployment_group_name,
            :deployment_group_id => deployment_spec.deployment_group_id,
            :deployment_creator => deployment_spec.deployment_creator,
            :deployment_type => deployment_spec.deployment_type,
            :deployment_root_dir => deployment_root_dir(deployment_spec),
            :last_successful_deployment_dir => last_successful_deployment_dir(deployment_spec.deployment_group_id),
            :most_recent_deployment_dir => most_recent_deployment_dir(deployment_spec.deployment_group_id),
            :app_spec_path => deployment_spec.app_spec_path,
            :revision_envs => get_revision_envs(deployment_spec))
        end

        private
        def get_revision_envs(deployment_spec)
          case deployment_spec.revision_source
          when 'S3'
            return get_s3_envs(deployment_spec)
          when 'GitHub'
            return get_github_envs(deployment_spec)
          when 'Local File', 'Local Directory'
            return {}
          else
            raise "Unknown revision type '#{deployment_spec.revision_source}'"
          end
        end

        private
        def get_github_envs(deployment_spec)
          # TODO(CDAGENT-387): expose the repository name and account, but we'll likely need to go through AppSec before doing so.
          return {
            "BUNDLE_COMMIT" => deployment_spec.commit_id
          }
        end

        private
        def get_s3_envs(deployment_spec)
          return {
            "BUNDLE_BUCKET" => deployment_spec.bucket,
            "BUNDLE_KEY" => deployment_spec.key,
            "BUNDLE_VERSION" => deployment_spec.version,
            "BUNDLE_ETAG" => deployment_spec.etag
          }
        end

        private
        def default_app_spec(deployment_spec)
          app_spec_location = app_spec_real_path(deployment_spec)
          validate_app_spec_hooks(app_spec_location, deployment_spec.all_possible_lifecycle_events)
        end

        private
        def validate_app_spec_hooks(app_spec_location, all_possible_lifecycle_events)
          app_spec = ApplicationSpecification::ApplicationSpecification.parse(File.read(app_spec_location))
          app_spec_filename = File.basename(app_spec_location)
          unless all_possible_lifecycle_events.nil?
            app_spec_hooks_plus_hooks_from_mapping = app_spec.hooks.keys.to_set.merge(@hook_mapping.keys).to_a
            unless app_spec_hooks_plus_hooks_from_mapping.to_set.subset?(all_possible_lifecycle_events.to_set)
              unknown_lifecycle_events = app_spec_hooks_plus_hooks_from_mapping - all_possible_lifecycle_events
              raise ArgumentError.new("#{app_spec_filename} file contains unknown lifecycle events: #{unknown_lifecycle_events}")
            end

            app_spec_hooks_plus_hooks_from_default_mapping = app_spec.hooks.keys.to_set.merge(InstanceAgent::Plugins::CodeDeployPlugin::CommandPoller::DEFAULT_HOOK_MAPPING.keys).to_a
            custom_hooks_not_found_in_appspec = custom_lifecycle_events(all_possible_lifecycle_events) - app_spec_hooks_plus_hooks_from_default_mapping
            unless (custom_hooks_not_found_in_appspec).empty?
              raise ArgumentError.new("You specified a lifecycle event which is not a default one and doesn't exist in your #{app_spec_filename} file: #{custom_hooks_not_found_in_appspec.join(',')}")
            end
          end

          app_spec
        end

        def custom_lifecycle_events(all_possible_lifecycle_events)
          all_possible_lifecycle_events - AWS::CodeDeploy::Local::Deployer::DEFAULT_ORDERED_LIFECYCLE_EVENTS
        end

        private
        def last_successful_install_file_path(deployment_group)
          File.join(deployment_instructions_dir, "#{deployment_group}_last_successful_install")
        end

        private
        def most_recent_install_file_path(deployment_group)
          File.join(deployment_instructions_dir, "#{deployment_group}_most_recent_install")
        end

        private
        def download_from_s3(deployment_spec, bucket, key, version, etag)
          log(:info, "Downloading artifact bundle from bucket '#{bucket}' and key '#{key}', version '#{version}', etag '#{etag}'")
          options = s3_options()
          s3 = Aws::S3::Client.new(options)

          File.open(artifact_bundle(deployment_spec), 'wb') do |file|

          begin
            if !version.nil?
              object = s3.get_object({:bucket => bucket, :key => key, :version_id => version}, :target => file)
            else
              object = s3.get_object({:bucket => bucket, :key => key}, :target => file)
            end
          rescue Seahorse::Client::NetworkingError => e
            if e.message.include? "unable to connect to"
              if InstanceAgent::Config.config[:use_fips_mode]
                raise $!, "#{$!}. Check that Fips exists in #{options[:region]}. Or, try using s3 endpoint override.", $!.backtrace
              else
                raise $!, "#{$!}. Try using s3 endpoint override.", $!.backtrace
              end
            else
              raise
            end
          end

            if(!etag.nil? && !(etag.gsub(/"/,'').eql? object.etag.gsub(/"/,'')))
              msg = "Expected deployment artifact bundle etag #{etag} but was actually #{object.etag}"
              log(:error, msg)
              raise RuntimeError, msg
            end
          end
          log(:info, "Download complete from bucket #{bucket} and key #{key}")
        end

        public
        def s3_options
          options = {}
          options[:ssl_ca_directory] = ENV['AWS_SSL_CA_DIRECTORY']
          options[:signature_version] = 'v4'

          region = ENV['AWS_REGION'] || InstanceMetadata.region
          options[:region] = region

          if !InstanceAgent::Config.config[:s3_endpoint_override].to_s.empty?
            ProcessManager::Log.debug("using s3 override endpoint #{InstanceAgent::Config.config[:s3_endpoint_override]}")
            options[:endpoint] = URI(InstanceAgent::Config.config[:s3_endpoint_override])
          elsif InstanceAgent::Config.config[:use_fips_mode]
            ProcessManager::Log.debug("using fips endpoint")
            # There was a recent change to S3 client to decompose the region and use a FIPS endpoint is "fips-" is appended
            # to the region. However, this is such a recent change that we cannot rely on the latest version of the SDK to be loaded.
            # For now, the endpoint will be set directly if FIPS is active but can switch to the S3 method once we have broader support.
            # options[:region] = "fips-#{region}"
            options[:endpoint] = "https://s3-fips.#{region}.amazonaws.com"
          end
          proxy_uri = nil
          if InstanceAgent::Config.config[:proxy_uri]
            proxy_uri = URI(InstanceAgent::Config.config[:proxy_uri])
          end
          options[:http_proxy] = proxy_uri

          if InstanceAgent::Config.config[:log_aws_wire]
            # wire logs might be huge; customers should be careful about turning them on
            # allow 1GB of old wire logs in 64MB chunks
            options[:logger] = Logger.new(
                File.join(InstanceAgent::Config.config[:log_dir], "#{InstanceAgent::Config.config[:program_name]}.aws_wire.log"),
                16,
                64 * 1024 * 1024)
            options[:http_wire_trace] = true
          end

          options
        end

        private
        def download_from_github(deployment_spec, account, repo, commit, anonymous, token)

          retries = 0
          errors = []

          unless (deployment_spec.bundle_type)
            if InstanceAgent::Platform.util.supported_oses.include? 'windows'
              deployment_spec.bundle_type = 'zip'
            else
              deployment_spec.bundle_type = 'tar'
            end
          end

          if deployment_spec.bundle_type == 'zip'
            format = 'zipball'
          elsif deployment_spec.bundle_type == 'tar'
            format = 'tarball'
          else
            raise ArgumentError.new("GitHub revision specified with bundle_type other than zip or tar [bundle_type=#{deployment_spec.bundle_type}")
          end

          uri = URI.parse("https://api.github.com/repos/#{account}/#{repo}/#{format}/#{commit}")
          options = {:ssl_verify_mode => OpenSSL::SSL::VERIFY_PEER, :redirect => true, :ssl_ca_cert => ENV['AWS_SSL_CA_DIRECTORY']}

          if anonymous
            log(:debug, "Anonymous GitHub repository download requested.")
          else
            log(:debug, "Authenticated GitHub repository download requested.")
            options.update({'Authorization' => "token #{token}"})
          end

          begin
            # stream bundle file to disk
            log(:info, "Requesting URL: '#{uri.to_s}'")
            File.open(artifact_bundle(deployment_spec), 'w+b') do |file|
              uri.open(options) do |github|
                log(:debug, "GitHub response: '#{github.meta.to_s}'")

                while (buffer = github.read(8 * 1024 * 1024))
                  file.write buffer
                end
              end
            end
          rescue OpenURI::HTTPError => e
            log(:error, "Could not download bundle at '#{uri.to_s}'. Server returned code #{e.io.status[0]} '#{e.io.status[1]}'")
            log(:debug, "Server returned error response body #{e.io.string}")
            errors << "#{e.io.status[0]} '#{e.io.status[1]}'"

            if retries < 3
              time_to_sleep = (10 * (3 ** retries)) # 10 sec, 30 sec, 90 sec
              log(:info, "Retrying download in #{time_to_sleep} seconds.")
              sleep(time_to_sleep)
              retries += 1
              retry
            else
              raise "Could not download bundle at '#{uri.to_s}' after #{retries} retries. Server returned codes: #{errors.join("; ")}."
            end
          end
        end

        private
        def handle_local_file(deployment_spec, local_location)
          # Symlink local file to the location where download is expected to go
          bundle_file = artifact_bundle(deployment_spec)
          log(:info, "Handle local file #{bundle_file}")
          begin
            File.symlink local_location, bundle_file
          rescue
            #Symlinking fails on windows, copying recursively instead
            FileUtils.cp_r local_location, bundle_file
          end
        end

        private
        def handle_local_directory(deployment_spec, local_location)
          # Copy local directory to the location where a file would have been extracted
          # We copy instead of symlinking in order to preserve revision history
          log(:info, "Handle local directory #{local_location}")
          FileUtils.cp_r local_location, archive_root_dir(deployment_spec)
        end

        private
        def unpack_bundle(cmd, bundle_file, deployment_spec)
          dst = File.join(deployment_root_dir(deployment_spec), 'deployment-archive')

          if "tar".eql? deployment_spec.bundle_type
            InstanceAgent::Platform.util.extract_tar(bundle_file, dst)
          elsif "tgz".eql? deployment_spec.bundle_type
            InstanceAgent::Platform.util.extract_tgz(bundle_file, dst)
          elsif "zip".eql? deployment_spec.bundle_type
            begin
              InstanceAgent::Platform.util.extract_zip(bundle_file, dst)
            rescue Exception => e
              if e.message == "Error extracting zip archive: 50"
                FileUtils.remove_dir(dst)
                # http://infozip.sourceforge.net/FAQ.html#error-codes
                msg = "The disk is (or was) full during extraction."
                log(:warn, msg)
                raise msg
              end
              log(:warn, "#{e.message}, with default system unzip util. Hence falling back to ruby unzip to mitigate any partially unzipped or skipped zip files.")
              Zip::File.open(bundle_file) do |zipfile|
                zipfile.each do |f|
                  file_dst = File.join(dst, f.name)
                  FileUtils.mkdir_p(File.dirname(file_dst))
                  zipfile.extract(f, file_dst) { true }
                end
              end
            end
          else
            InstanceAgent::Platform.util.extract_tar(bundle_file, dst)
          end

          archive_root_files = Dir.entries(dst)
          archive_root_files.delete_if { |name| name == '.' || name == '..' }

          # If the top level of the archive is a directory that contains an appspec,
          # strip that before giving up
          if ((archive_root_files.size == 1) &&
              File.directory?(File.join(dst, archive_root_files[0])) &&
              Dir.entries(File.join(dst, archive_root_files[0])).grep(/appspec/i).any?)
            log(:info, "Stripping leading directory from archive bundle contents.")
            # Move the unpacked files to a temporary location
            tmp_dst = File.join(deployment_root_dir(deployment_spec), 'deployment-archive-temp')
            FileUtils.rm_rf(tmp_dst)
            FileUtils.mv(dst, tmp_dst)

            # Move the top level directory to the intended location
            nested_archive_root = File.join(tmp_dst, archive_root_files[0])
            log(:debug, "Actual archive root at #{nested_archive_root}. Moving to #{dst}")
            FileUtils.mv(nested_archive_root, dst)
            FileUtils.rmdir(tmp_dst)

            log(:debug, Dir.entries(dst).join("; "))
          end
        end

        private
        def update_last_successful_install(deployment_spec)
          File.open(last_successful_install_file_path(deployment_spec.deployment_group_id), 'w+') do |f|
            f.write deployment_root_dir(deployment_spec)
          end
        end

        private
        def update_most_recent_install(deployment_spec)
          File.open(most_recent_install_file_path(deployment_spec.deployment_group_id), 'w+') do |f|
            f.write deployment_root_dir(deployment_spec)
          end
        end

        private
        def cleanup_old_archives(deployment_spec)
          deployment_group = deployment_spec.deployment_group_id
          deployment_archives = Dir.entries(File.join(ProcessManager::Config.config[:root_dir], deployment_group))
          # remove . and ..
          deployment_archives.delete(".")
          deployment_archives.delete("..")

          full_path_deployment_archives = deployment_archives.map{ |f| File.join(ProcessManager::Config.config[:root_dir], deployment_group, f)}
          full_path_deployment_archives.delete(deployment_root_dir(deployment_spec))

          extra = full_path_deployment_archives.size - @archives_to_retain + 1
          return unless extra > 0

          # Never remove the last successful deployment
          last_success = last_successful_deployment_dir(deployment_group)
          full_path_deployment_archives.delete(last_success)

          # Sort oldest -> newest, take first `extra` elements
          oldest_extra = full_path_deployment_archives.sort_by{ |f| File.mtime(f) }.take(extra)

          # Absolute path takes care of relative root directories
          directories = oldest_extra.map{ |f| File.absolute_path(f) }
          log(:debug, "Delete Files #{directories}")
          InstanceAgent::Platform.util.delete_dirs_command(directories)

        end

        private
        def artifact_bundle(deployment_spec)
          File.join(deployment_root_dir(deployment_spec), 'bundle.tar')
        end

        private
        def app_spec_path
          'appspec.yml'
        end

        # Checks for existence the possible extensions of the app_spec_path (.yml and .yaml)
        private
        def app_spec_real_path(deployment_spec)
          app_spec_param_location = File.join(archive_root_dir(deployment_spec), deployment_spec.app_spec_path)
          app_spec_yaml_location = File.join(archive_root_dir(deployment_spec), "appspec.yaml")
          app_spec_yml_location = File.join(archive_root_dir(deployment_spec), "appspec.yml")
          if File.exist? app_spec_param_location
            log(:debug, "Using appspec file #{app_spec_param_location}")
            app_spec_param_location
          elsif File.exist? app_spec_yaml_location
            log(:debug, "Using appspec file #{app_spec_yaml_location}")
            app_spec_yaml_location
          else
            log(:debug, "Using appspec file #{app_spec_yml_location}")
            app_spec_yml_location
          end
        end

        private
        def description
          self.class.to_s
        end

        private
        def log(severity, message)
          raise ArgumentError, "Unknown severity #{severity.inspect}" unless InstanceAgent::Log::SEVERITIES.include?(severity.to_s)
          InstanceAgent::Log.send(severity.to_sym, "#{description}: #{message}")
        end
      end
    end
  end
end