aws/aws-codedeploy-agent

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

Summary

Maintainability
C
1 day
Test Coverage
require 'timeout'
require 'open3'
require 'json'
require 'fileutils'

require 'instance_agent/plugins/codedeploy/application_specification/application_specification'
require 'instance_agent/platform/thread_joiner'
module InstanceAgent
  module Plugins
    module CodeDeployPlugin
      class ScriptLog
        SCRIPT_LOG_FILE_RELATIVE_LOCATION = 'logs/scripts.log'

        attr_reader :log
        def append_to_log(log_entry)
          log_entry ||= ""
          @log ||= []
          @log.push(log_entry)

          index = @log.size
          remaining_buffer = 2048

          while (index > 0 && (remaining_buffer - @log[index-1].length) > 0)
            index = index - 1
            remaining_buffer = remaining_buffer - @log[index-1].length
          end

          if index > 0
            @log = @log.drop(index)
          end
        end

        def concat_log(log_entries)
          log_entries ||= []
          log_entries.each do |log_entry|
            append_to_log(log_entry)
          end
        end
      end

      class ScriptError < StandardError
        attr_reader :error_code, :script_name, :log

        SUCCEEDED_CODE = 0
        SCRIPT_MISSING_CODE = 1
        SCRIPT_EXECUTABILITY_CODE = 2
        SCRIPT_TIMED_OUT_CODE = 3
        SCRIPT_FAILED_CODE = 4
        UNKNOWN_ERROR_CODE = 5
        OUTPUTS_LEFT_OPEN_CODE = 6
        FAILED_AFTER_RESTART_CODE = 7

        def initialize(error_code, script_name, log)
          @error_code = error_code
          @script_name = script_name
          @log = log
        end

        def to_json
          log = @log.log || []
          log = log.join("")
          log.force_encoding("utf-8")
          {'error_code' => @error_code, 'script_name' => @script_name, 'message' => message, 'log' => log}.to_json
        end
      end

      class HookExecutor
        LAST_SUCCESSFUL_DEPLOYMENT = "LastSuccessfulOrIgnore"
        MOST_RECENT_DEPLOYMENT = "MostRecentOrIgnore"
        CURRENT = "New"
        MAPPING_BETWEEN_HOOKS_AND_DEPLOYMENTS = { "BeforeBlockTraffic"=>LAST_SUCCESSFUL_DEPLOYMENT,
            "AfterBlockTraffic"=>LAST_SUCCESSFUL_DEPLOYMENT,
            "ApplicationStop"=>LAST_SUCCESSFUL_DEPLOYMENT,
            "BeforeInstall"=>CURRENT,
            "AfterInstall"=>CURRENT,
            "ApplicationStart"=>CURRENT,
            "BeforeAllowTraffic"=>CURRENT,
            "AfterAllowTraffic"=>CURRENT,
            "ValidateService"=>CURRENT}

        def initialize(arguments = {})
          #check arguments
          raise "Lifecycle Event Required " if arguments[:lifecycle_event].nil?
          raise "Deployment ID required " if arguments[:deployment_id].nil?
          raise "Deployment Root Directory Required " if arguments[:deployment_root_dir].nil?
          raise "App Spec Path Required " if arguments[:app_spec_path].nil?
          raise "Application name required" if arguments[:application_name].nil?
          raise "Deployment Group name required" if arguments[:deployment_group_name].nil?
          raise "Deployment creator required" if arguments[:deployment_creator].nil?
          raise "Deployment type required" if arguments[:deployment_type].nil?
          @lifecycle_event = arguments[:lifecycle_event]
          @deployment_id = arguments[:deployment_id]
          @application_name = arguments[:application_name]
          @deployment_group_name = arguments[:deployment_group_name]
          @deployment_group_id = arguments[:deployment_group_id]
          @deployment_creator = arguments[:deployment_creator]
          @deployment_type = arguments[:deployment_type]
          @current_deployment_root_dir = arguments[:deployment_root_dir]
          select_correct_deployment_root_dir(arguments[:deployment_root_dir], arguments[:last_successful_deployment_dir], arguments[:most_recent_deployment_dir])
          return if @deployment_root_dir.nil?
          @deployment_archive_dir = File.join(@deployment_root_dir, 'deployment-archive')
          @app_spec_path = arguments[:app_spec_path]
          parse_app_spec
          @hook_logging_mutex = Mutex.new
          @script_log = ScriptLog.new
          @child_envs={'LIFECYCLE_EVENT' => @lifecycle_event.to_s,
                      'DEPLOYMENT_ID'   => @deployment_id.to_s,
                      'APPLICATION_NAME' => @application_name,
                      'DEPLOYMENT_GROUP_NAME' => @deployment_group_name,
                      'DEPLOYMENT_GROUP_ID' => @deployment_group_id}
          @child_envs.merge!(arguments[:revision_envs]) if arguments[:revision_envs]
        end

        def is_noop?
          return @app_spec.nil? || @app_spec.hooks[@lifecycle_event].nil? || @app_spec.hooks[@lifecycle_event].empty?
        end

        def total_timeout_for_all_scripts
          return nil if is_noop?
          timeouts = @app_spec.hooks[@lifecycle_event].map {|script| script.timeout}
          timeouts.reduce(0) {|running_sum, item| running_sum + item}
        end

        def execute
          return if @app_spec.nil?
          if (hooks = @app_spec.hooks[@lifecycle_event]) &&
          !hooks.empty?
            create_script_log_file_if_needed do |script_log_file|
              log_script("LifecycleEvent - " + @lifecycle_event + "\n", script_log_file)
              hooks.each do |script|
                if(!File.exist?(script_absolute_path(script)))
                  raise ScriptError.new(ScriptError::SCRIPT_MISSING_CODE, script.location, @script_log), 'Script does not exist at specified location: ' + File.expand_path(script_absolute_path(script))
                elsif(!InstanceAgent::Platform.util.script_executable?(script_absolute_path(script)))
                  log :warn, 'Script at specified location: ' + script.location + ' is not executable.  Trying to make it executable.'
                  begin
                    FileUtils.chmod("+x", script_absolute_path(script))
                  rescue
                    raise ScriptError.new(ScriptError::SCRIPT_EXECUTABILITY_CODE, script.location, @script_log), 'Unable to set script at specified location: ' + script.location + ' as executable'
                  end
                end
                begin
                  execute_script(script, script_log_file)
                rescue Timeout::Error
                  raise ScriptError.new(ScriptError::SCRIPT_TIMED_OUT_CODE, script.location, @script_log), 'Script at specified location: ' +script.location + ' failed to complete in '+script.timeout.to_s+' seconds'
                rescue ScriptError
                  raise
                rescue StandardError => e
                  script_error = "#{script_error_prefix(script.location, script.runas)} failed with error #{e.class} with message #{e}"
                  raise ScriptError.new(ScriptError::SCRIPT_FAILED_CODE, script.location, @script_log), script_error
                end
              end
            end
          end
          @script_log.log
        end

        private
        def execute_script(script, script_log_file)
          script_command = InstanceAgent::Platform.util.prepare_script_command(script, script_absolute_path(script))
          log_script("Script - " + script.location + "\n", script_log_file)
          exit_status = 1
          signal = nil

          if !InstanceAgent::Platform.util.supports_process_groups?
            # The Windows port doesn't emulate process groups so don't try to use them here
            open3_options = {}
            signal = 'KILL' #It is up to the script to handle killing child processes it spawns.
          else
            open3_options = {:pgroup => true}
            signal = '-TERM' #kill the process group instead of pid
          end

          Open3.popen3(@child_envs, script_command, open3_options) do |stdin, stdout, stderr, wait_thr|
            stdin.close
            stdout_thread = Thread.new{stdout.each_line { |line| log_script("[stdout]" + line.to_s, script_log_file)}}
            stderr_thread = Thread.new{stderr.each_line { |line| log_script("[stderr]" + line.to_s, script_log_file)}}
            thread_joiner = InstanceAgent::ThreadJoiner.new(script.timeout)
            thread_joiner.joinOrFail(wait_thr) do
              Process.kill(signal, wait_thr.pid)
              raise Timeout::Error
            end
            thread_joiner.joinOrFail(stdout_thread) do
              script_error = "Script at specified location: #{script.location} failed to close STDOUT"
              log :error, script_error
              raise ScriptError.new(ScriptError::OUTPUTS_LEFT_OPEN_CODE, script.location, @script_log), script_error
            end
            thread_joiner.joinOrFail(stderr_thread) do
              script_error = "Script at specified location: #{script.location} failed to close STDERR"
              log :error, script_error
              raise ScriptError.new(ScriptError::OUTPUTS_LEFT_OPEN_CODE, script.location, @script_log), script_error
            end
            exit_status = wait_thr.value.exitstatus
          end
          if(exit_status != 0)
            script_error = "#{script_error_prefix(script.location, script.runas)} failed with exit code #{exit_status.to_s}"
            raise ScriptError.new(ScriptError::SCRIPT_FAILED_CODE, script.location, @script_log), script_error
          end
        end

        private
        def script_error_prefix(script_location, script_run_as_user)
          script_error_prefix = 'Script at specified location: ' + script_location
          if(!script_run_as_user.nil?)
            script_error_prefix = 'Script at specified location: ' + script_location + ' run as user ' + script_run_as_user
          end

          script_error_prefix
        end

        private
        def create_script_log_file_if_needed
          script_log_file_location = File.join(@current_deployment_root_dir, ScriptLog::SCRIPT_LOG_FILE_RELATIVE_LOCATION)
          if(!File.exists?(script_log_file_location))
            unless File.directory?(File.dirname(script_log_file_location))
              FileUtils.mkdir_p(File.dirname(script_log_file_location))
            end
            script_log_file = File.open(script_log_file_location, 'w')
          else
            script_log_file = File.open(script_log_file_location, 'a')
          end
          yield(script_log_file)
        ensure
          script_log_file.close unless script_log_file.nil?
        end

        private
        def script_absolute_path(script)
          File.join(@deployment_archive_dir, script.location)
        end

        private
        def parse_app_spec
          app_spec_location = File.join(@deployment_archive_dir, @app_spec_path)
          log(:debug, "Checking for app spec in #{app_spec_location}")
          unless File.exists?(app_spec_location)
            raise <<-MESSAGE.gsub(/^[\s\t]*/, '').gsub(/\s*\n/, ' ').strip
                The CodeDeploy agent did not find an AppSpec file within the unpacked revision directory at revision-relative path "#{@app_spec_path}".
                The revision was unpacked to directory "#{@deployment_archive_dir}", and the AppSpec file was expected but not found at path
                "#{app_spec_location}". Consult the AWS CodeDeploy Appspec documentation for more information at
                http://docs.aws.amazon.com/codedeploy/latest/userguide/reference-appspec-file.html
            MESSAGE
          end
          @app_spec =  InstanceAgent::Plugins::CodeDeployPlugin::ApplicationSpecification::ApplicationSpecification.parse(File.read(app_spec_location))
        end

        private
        def select_correct_deployment_root_dir(current_deployment_root_dir, last_successful_deployment_root_dir, most_recent_deployment_dir)
          @deployment_root_dir = current_deployment_root_dir
          hook_deployment_mapping = mapping_between_hooks_and_deployments
          if(select_correct_mapping_for_hooks == LAST_SUCCESSFUL_DEPLOYMENT && !File.exist?(File.join(@deployment_root_dir, 'deployment-archive')))
            @deployment_root_dir = last_successful_deployment_root_dir
          elsif(select_correct_mapping_for_hooks == MOST_RECENT_DEPLOYMENT && !File.exists?(File.join(@deployment_root_dir, 'deployment-archive')))
            @deployment_root_dir = most_recent_deployment_dir
          end
        end

        private
        def select_correct_mapping_for_hooks
          hook_deployment_mapping = mapping_between_hooks_and_deployments
          if((@deployment_creator.eql? "codeDeployRollback") && (@deployment_type.eql? "BLUE_GREEN"))
            hook_deployment_mapping = rollback_deployment_mapping_between_hooks_and_deployments
          end
          hook_deployment_mapping[@lifecycle_event]
        end

        private
        def mapping_between_hooks_and_deployments
          MAPPING_BETWEEN_HOOKS_AND_DEPLOYMENTS
        end

        private 
        def rollback_deployment_mapping_between_hooks_and_deployments
          { "BeforeBlockTraffic"=>MOST_RECENT_DEPLOYMENT,
            "AfterBlockTraffic"=>MOST_RECENT_DEPLOYMENT,
            "ApplicationStop"=>LAST_SUCCESSFUL_DEPLOYMENT,
            "BeforeInstall"=>CURRENT,
            "AfterInstall"=>CURRENT,
            "ApplicationStart"=>CURRENT,
            "BeforeAllowTraffic"=>LAST_SUCCESSFUL_DEPLOYMENT,
            "AfterAllowTraffic"=>LAST_SUCCESSFUL_DEPLOYMENT,
            "ValidateService"=>CURRENT}
        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

        private
        def log_script(message, script_log_file)
          @hook_logging_mutex.synchronize do
            @script_log.append_to_log(message)
            script_log_file.write(Time.now.to_s[0..-7] + ' ' + message)
            InstanceAgent::DeploymentLog.instance.log("[#{@deployment_id}]#{message.strip}") if InstanceAgent::Config.config[:enable_deployments_log]
            script_log_file.flush
          end
        end
      end
    end
  end
end