require 'test_helper'
require 'json'
require 'instance_agent/log'


class CommandPollerTest < InstanceAgentTestCase
  include InstanceAgent::Plugins::CodeDeployPlugin
  def gather_diagnostics_from_error(error)
    {'error_code' => InstanceAgent::Plugins::CodeDeployPlugin::ScriptError::UNKNOWN_ERROR_CODE, 'script_name' => "", 'message' => error.message, 'log' => ""}.to_json
  end

  def gather_diagnostics(script_output, msg = "")
    script_output ||= ""
    {'error_code' => InstanceAgent::Plugins::CodeDeployPlugin::ScriptError::SUCCEEDED_CODE, 'script_name' => "", 'message' => "Succeeded: #{msg}", 'log' => script_output}.to_json
  end

  def gather_diagnostics_from_failure_after_restart(script_output, msg = "")
    script_output ||= ""
    {'error_code' => InstanceAgent::Plugins::CodeDeployPlugin::ScriptError::FAILED_AFTER_RESTART_CODE, 'script_name' => "", 'message' => "Failed: #{msg}", 'log' => script_output}.to_json
  end

  def get_ack_diagnostics(is_command_noop)
    return {:format => "JSON", :payload => {'IsCommandNoop' => is_command_noop}.to_json()}
  end

  context 'The command poller' do

    setup do
      @host_identifier = "i-123"
      @aws_region = 'us-east-1'
      @deploy_control_endpoint = "my-deploy-control.amazon.com"
      @deploy_control_client = mock('deploy-control-client')
      @deploy_control_api = mock('deploy-control-api')
      @executor = stub(:execute_command => "test this is not returned",
                       :deployment_system => "CodeDeploy")

      ENV['AWS_REGION'] = nil
      InstanceMetadata.stubs(:region).returns(@aws_region)
      InstanceMetadata.stubs(:host_identifier).returns(@host_identifier)

      InstanceAgent::Plugins::CodeDeployPlugin::OnPremisesConfig.stubs(:configure)
      InstanceAgent::Plugins::CodeDeployPlugin::CodeDeployControl.stubs(:new).
        returns(@deploy_control_api)
      @deploy_control_api.stubs(:get_client).
        returns(@deploy_control_client)

      InstanceAgent::Plugins::CodeDeployPlugin::CommandExecutor.stubs(:new).
        returns(@executor)

      @poller = InstanceAgent::Plugins::CodeDeployPlugin::CommandPoller.new
    end

    context 'on initializing' do

      should 'construct a client using the configured region' do
        InstanceAgent::Plugins::CodeDeployPlugin::CodeDeployControl.expects(:new).
          with(has_entries(:region => @aws_region)).
          returns(@deploy_control_api)

        InstanceAgent::Plugins::CodeDeployPlugin::CommandPoller.new
      end

      should 'construct an CodeDeploy command executor' do
        test_hook_mapping = { "BeforeBlockTraffic"=>["BeforeBlockTraffic"],
          "AfterBlockTraffic"=>["AfterBlockTraffic"],
          "ApplicationStop"=>["ApplicationStop"],
          "BeforeInstall"=>["BeforeInstall"],
          "AfterInstall"=>["AfterInstall"],
          "ApplicationStart"=>["ApplicationStart"],
          "BeforeAllowTraffic"=>["BeforeAllowTraffic"],
          "AfterAllowTraffic"=>["AfterAllowTraffic"],
          "ValidateService"=>["ValidateService"]}
        InstanceAgent::Plugins::CodeDeployPlugin::CommandExecutor.expects(:new).
          with(:hook_mapping => test_hook_mapping).
          returns(@executor)

        InstanceAgent::Plugins::CodeDeployPlugin::CommandPoller.new
      end

    end

    context 'on perform' do

      setup do
        @command = stub(
          :host_command_identifier => "my-host-command-identifier",
          :command_name => "DownloadBundle",
          :host_identifier => @host_identifier,
          :deployment_execution_id => "command-deployment-execution-id")
        @poll_host_command_output = stub(:host_command => @command)
        @poll_host_command_acknowledgement_output = stub(:command_status => "InProgress")
        @deployment_specification = stub(:generic_envelope => '{"some":"json"}')
        @get_deploy_specification_output = stub(
          :deployment_system => "CodeDeploy",
          :deployment_specification => @deployment_specification)

        @poll_host_command_state = states('poll_host_command_state').
          starts_as('setup')
        @deploy_control_client.stubs(:poll_host_command).
          returns(@poll_host_command_output).
          when(@poll_host_command_state.is('setup'))

        @put_host_command_acknowledgement_state = states('put_host_command_acknowledgement_state').
        starts_as('setup')
        @deploy_control_client.stubs(:put_host_command_acknowledgement).
          returns(@poll_host_command_acknowledgement_output).
          when(@put_host_command_acknowledgement_state.is('setup'))

        @get_deployment_specification_state = states('get_deployment_specification_state').
          starts_as('setup')
        @deploy_control_client.stubs(:get_deployment_specification).
          returns(@get_deploy_specification_output).
          when(@get_deployment_specification_state.is('setup'))

        @execute_command_state = states('execute_command_state').
          starts_as('setup')
        @executor.stubs(:execute_command).
          when(@execute_command_state.is('setup'))
        @executor.stubs(:is_command_noop?).returns(false)

        @put_host_command_complete_state = states('put_host_command_complete_state').
          starts_as('setup')
        @deploy_control_client.stubs(:put_host_command_complete).
          when(@put_host_command_complete_state.is('setup'))
        @deployment_id  = stub(:deployment_id => "D-1234")
        InstanceAgent::Config.config[:root_dir] = File.join(Dir.tmpdir(), "CodeDeploy")
        InstanceAgent::Config.config[:ongoing_deployment_tracking] = "ongoing-deployment"
        InstanceAgent::Plugins::CodeDeployPlugin::DeploymentSpecification.stubs(:parse).returns(@deployment_id)
        InstanceAgent::Plugins::CodeDeployPlugin::DeploymentCommandTracker.stubs(:delete_deployment_command_tracking_file).returns(true)
        InstanceAgent::Plugins::CodeDeployPlugin::DeploymentCommandTracker.stubs(:create_ongoing_deployment_tracking_file).returns(true)
      end

      should 'call PollHostCommand with the current host name' do
        @deploy_control_client.expects(:poll_host_command).
          with(:host_identifier => @host_identifier).
          returns(@poll_host_command_output)

        @poller.perform
      end

      should 'return when no command is given by PollHostCommand' do
        @deploy_control_client.expects(:poll_host_command).
          with(:host_identifier => @host_identifier).
          returns(stub(:host_command => nil))

        @put_host_command_acknowledgement_state.become('never')
        @deploy_control_client.expects(:put_host_command_acknowledgement).never.
          when(@put_host_command_acknowledgement_state.is('never'))
        @get_deployment_specification_state.become('never')
        @deploy_control_client.expects(:get_deployment_specification).never.
          when(@get_deployment_specification_state.is('never'))
        @put_host_command_complete_state.become('never')
        @deploy_control_client.expects(:put_host_command_complete).never.
          when(@put_host_command_complete_state.is('never'))

        @poller.perform
      end

      should 'raise expection when a different host identifier given by PollHostCommand' do
        command = stub(
          :host_command_identifier => "my-host-command-identifier",
          :command_name => "DownloadBundle",
          :host_identifier => "different-host-identifier",
          :deployment_execution_id => "command-deployment-execution-id")

        poll_host_command_output = stub(:host_command => command)

        @deploy_control_client.expects(:poll_host_command).
          with(:host_identifier => @host_identifier).
          returns(poll_host_command_output)

        @put_host_command_acknowledgement_state.become('never')
        @deploy_control_client.expects(:put_host_command_acknowledgement).never.
          when(@put_host_command_acknowledgement_state.is('never'))
        @get_deployment_specification_state.become('never')
        @deploy_control_client.expects(:get_deployment_specification).never.
          when(@get_deployment_specification_state.is('never'))
        @put_host_command_complete_state.become('never')
        @deploy_control_client.expects(:put_host_command_complete).never.
          when(@put_host_command_complete_state.is('never'))

        assert_raise do
          @poller.perform
        end
      end

      should 'Accept a host name that is a substring of the actual host name' do
        command = stub(
          :host_command_identifier => "my-host-command-identifier",
          :command_name => "DownloadBundle",
          :host_identifier => @host_identifier[0],
          :deployment_execution_id => "command-deployment-execution-id")

        poll_host_command_output = stub(:host_command => command)

        @deploy_control_client.expects(:poll_host_command).
          with(:host_identifier => @host_identifier).
          returns(poll_host_command_output)

        @poller.perform
      end

      should 'raise exception when no command name is given by PollHostCommand' do
        command = stub(
          :host_command_identifier => "my-host-command-identifier",
          :command_name => nil,
          :host_identifier => @host_identifier,
          :deployment_execution_id => "command-deployment-execution-id")

        poll_host_command_output = stub(:host_command => command)

        @deploy_control_client.expects(:poll_host_command).
          with(:host_identifier => @host_identifier).
          returns(poll_host_command_output)

        @put_host_command_acknowledgement_state.become('never')
        @deploy_control_client.expects(:put_host_command_acknowledgement).never.
          when(@put_host_command_acknowledgement_state.is('never'))
        @get_deployment_specification_state.become('never')
        @deploy_control_client.expects(:get_deployment_specification).never.
          when(@get_deployment_specification_state.is('never'))
        @put_host_command_complete_state.become('never')
        @deploy_control_client.expects(:put_host_command_complete).never.
          when(@put_host_command_complete_state.is('never'))

        assert_raise do
          @poller.perform
        end
      end

      should 'raise exception when empty command name is given by PollHostCommand' do
        command = stub(
          :host_command_identifier => "my-host-command-identifier",
          :command_name => "",
          :host_identifier => @host_identifier,
          :deployment_execution_id => "command-deployment-execution-id")

        poll_host_command_output = stub(:host_command => command)

        @deploy_control_client.expects(:poll_host_command).
          with(:host_identifier => @host_identifier).
          returns(poll_host_command_output)

        @put_host_command_acknowledgement_state.become('never')
        @deploy_control_client.expects(:put_host_command_acknowledgement).never.
          when(@put_host_command_acknowledgement_state.is('never'))
        @get_deployment_specification_state.become('never')
        @deploy_control_client.expects(:get_deployment_specification).never.
          when(@get_deployment_specification_state.is('never'))
        @put_host_command_complete_state.become('never')
        @deploy_control_client.expects(:put_host_command_complete).never.
          when(@put_host_command_complete_state.is('never'))

        assert_raise do
          @poller.perform
        end
      end

      should 'allow exceptions from PollHostCommand to propagate to caller' do
        @deploy_control_client.stubs(:poll_host_command).
          raises("some error")

        assert_raise "some error" do
          @poller.perform
        end
      end

      should 'call PutHostCommandAcknowledgement with host_command_identifier returned by PollHostCommand' do
        @deploy_control_client.expects(:put_host_command_acknowledgement).
          with(:diagnostics => get_ack_diagnostics(false),
               :host_command_identifier => @command.host_command_identifier).
          returns(@poll_host_command_acknowledgement_output)

        @poller.acknowledge_and_process_command(@command)
      end

      should 'return when Succeeded command status is given by PutHostCommandAcknowledgement' do
        @deploy_control_client.expects(:put_host_command_acknowledgement).
          with(:diagnostics => get_ack_diagnostics(false),
               :host_command_identifier => @command.host_command_identifier).
          returns(stub(:command_status => "Succeeded"))

        @put_host_command_complete_state.become('never')
        @deploy_control_client.expects(:put_host_command_complete).never.
          when(@put_host_command_complete_state.is('never'))

        @poller.acknowledge_and_process_command(@command)
      end

      context 'when Failed command status is given by PutHostCommandAcknowledgement' do
        context 'when the command is not a noop' do
          setup do
            @deploy_control_client.expects(:put_host_command_acknowledgement).
              with(:diagnostics => get_ack_diagnostics(false),
                  :host_command_identifier => @command.host_command_identifier).
              returns(stub(:command_status => "Failed"))

            @executor.expects(:is_command_noop?).
              with(@command.command_name, @deployment_specification.generic_envelope).returns(false)
          end

          should 'do nothing' do
            @put_host_command_complete_state.become('never')
            @deploy_control_client.expects(:put_host_command_complete).never.
              when(@put_host_command_complete_state.is('never'))

            @poller.acknowledge_and_process_command(@command)
          end
        end

        context 'when the command is a noop' do
          setup do
            @deploy_control_client.expects(:put_host_command_acknowledgement).
              with(:diagnostics => get_ack_diagnostics(true),
                  :host_command_identifier => @command.host_command_identifier).
              returns(stub(:command_status => "Failed"))

            @deploy_control_client.expects(:get_deployment_specification).
              with(:deployment_execution_id => @command.deployment_execution_id,
                  :host_identifier => @host_identifier).
              returns(@get_deploy_specification_output)

            @executor.expects(:is_command_noop?).
              with(@command.command_name, @deployment_specification.generic_envelope).returns(true).twice
          end

          should 'call PutHostCommandComplete with Succeeded' do
            @deploy_control_client.expects(:put_host_command_complete).
              with(:command_status => "Succeeded",
                  :diagnostics => {:format => "JSON", :payload => gather_diagnostics("", "CompletedNoopCommand")},
                  :host_command_identifier => @command.host_command_identifier)

            @poller.acknowledge_and_process_command(@command)
          end
        end
      end

      should 'call GetDeploymentSpecification with the host ID and execution ID of the command' do
        @deploy_control_client.expects(:get_deployment_specification).
          with(:deployment_execution_id => @command.deployment_execution_id,
               :host_identifier => @host_identifier).
          returns(@get_deploy_specification_output)

        @poller.acknowledge_and_process_command(@command)
      end

      should 'allow exceptions from GetDeploymentSpecification to propagate to caller' do
        @deploy_control_client.expects(:get_deployment_specification).
          raises("some error")

        assert_raise "some error" do
          @poller.acknowledge_and_process_command(@command)
        end
      end

      context 'when an empty deployment system is given by GetDeploymentSpecification' do

        setup do
          @get_deploy_specification_output.stubs(:deployment_system).
            returns("")
        end

        should 'not dispatch the command to the command executor' do
          @execute_command_state.become('never')

          assert_raise do
            @poller.acknowledge_and_process_command(@command)
          end
        end

        should 'call put_host_command_complete with a status of Failed' do

          @deploy_control_client.expects(:put_host_command_complete).
            with(:command_status => "Failed",
                 :diagnostics => {:format => "JSON", :payload => gather_diagnostics_from_error(RuntimeError.new("Deployment System mismatch: CodeDeploy != "))},
                 :host_command_identifier => @command.host_command_identifier)

          assert_raise do
            @poller.acknowledge_and_process_command(@command)
          end
        end

      end

      context 'when the wrong deployment system is given by GetDeploymentSpecification' do

        setup do
          @get_deploy_specification_output.stubs(:deployment_system).
            returns("WackyDeployer")
        end

        should 'not dispatch the command to the command executor' do
          @execute_command_state.become('never')

          assert_raise do
            @poller.acknowledge_and_process_command(@command)
          end
        end

        should 'call put_host_command_complete with a status of Failed' do
          @deploy_control_client.expects(:put_host_command_complete).
            with(:command_status => "Failed",
                 :diagnostics => {:format => "JSON", :payload => gather_diagnostics_from_error(RuntimeError.new("Deployment System mismatch: CodeDeploy != WackyDeployer"))},
                 :host_command_identifier => @command.host_command_identifier)

          assert_raise do
            @poller.acknowledge_and_process_command(@command)
          end
        end

      end

      context 'calling recover_from_crash when a lifecycle event is in-progress' do

        setup do
          InstanceAgent::Plugins::CodeDeployPlugin::DeploymentCommandTracker.stubs(:check_deployment_event_inprogress?).returns(true)
          InstanceAgent::Plugins::CodeDeployPlugin::DeploymentCommandTracker.stubs(:deployment_dir_path).returns("deployment-path")
          InstanceAgent::Plugins::CodeDeployPlugin::DeploymentCommandTracker.stubs(:most_recent_host_command_identifier).returns("i-123")
          InstanceAgent::Plugins::CodeDeployPlugin::DeploymentCommandTracker.stubs(:clean_ongoing_deployment_dir)
        end

        should 'call PutHostCommandComplete' do
          @deploy_control_client.expects(:put_host_command_complete).
            with(:command_status => "Failed",
                 :diagnostics => {:format => "JSON", :payload => gather_diagnostics_from_failure_after_restart("", "Failing in-progress lifecycle event after an agent restart.")},
                 :host_command_identifier => "i-123")

          @poller.recover_from_crash?
        end
      end

      context 'calling recover_from_crash when a lifecycle event is not in-progress' do

        setup do
          InstanceAgent::Plugins::CodeDeployPlugin::DeploymentCommandTracker.stubs(:check_deployment_event_inprogress?).returns(false)
          InstanceAgent::Plugins::CodeDeployPlugin::DeploymentCommandTracker.stubs(:deployment_dir_path).returns("deployment-path")
          InstanceAgent::Plugins::CodeDeployPlugin::DeploymentCommandTracker.stubs(:most_recent_host_command_identifier).returns("i-123")
          InstanceAgent::Plugins::CodeDeployPlugin::DeploymentCommandTracker.stubs(:clean_ongoing_deployment_dir)
        end

        should 'not call PutHostCommandComplete' do
          @put_host_command_complete_state.become('never')
          @deploy_control_client.expects(:put_host_command_complete).never.
            when(@put_host_command_complete_state.is('never'))

          @poller.recover_from_crash?
        end
      end

      context 'when no deployment specification is given by GetDeploymentSpecification' do

        setup do
          @get_deploy_specification_output.stubs(:deployment_specification).
            returns(nil)
        end

        should 'not dispatch the command to the command executor' do
          @execute_command_state.become('never')

          assert_raise do
            @poller.acknowledge_and_process_command(@command)
          end
        end

        should 'call PutHostCommandComplete with a status of Failed' do
          @deploy_control_client.expects(:put_host_command_complete).
            with(:command_status => "Failed",
                 :diagnostics => {:format => "JSON", :payload => gather_diagnostics_from_error(RuntimeError.new("Deployment Specification missing"))},
                 :host_command_identifier => @command.host_command_identifier)

          assert_raise do
            @poller.acknowledge_and_process_command(@command)
          end
        end

      end

      should 'dispatch the command to the command executor' do
        @executor.expects(:execute_command).
          with(@command, @deployment_specification.generic_envelope)

        @poller.process_command(@command, @deployment_specification.generic_envelope)
      end

      should 'allow exceptions from execute_command to propagate to caller' do
        @executor.expects(:execute_command).
          raises("some error")
        @deploy_control_client.expects(:put_host_command_complete).
          with(:command_status => "Failed",
               :diagnostics => {:format => "JSON", :payload => gather_diagnostics_from_error(RuntimeError.new("some error"))},
               :host_command_identifier => @command.host_command_identifier)

        assert_raise "some error" do
          @poller.process_command(@command, @deployment_specification.generic_envelope)
        end
      end

      should 'allow script errors from execute_command to propagate diagnostic information to caller' do
        begin
          script_log = InstanceAgent::Plugins::CodeDeployPlugin::ScriptLog.new
          script_log.append_to_log("log entries")
          raise InstanceAgent::Plugins::CodeDeployPlugin::ScriptError.new(InstanceAgent::Plugins::CodeDeployPlugin::ScriptError::SCRIPT_FAILED_CODE, "file_location", script_log), 'message'
        rescue InstanceAgent::Plugins::CodeDeployPlugin::ScriptError => e
          script_error = e
        end

        @executor.expects(:execute_command).
          raises(script_error)

        @deploy_control_client.expects(:put_host_command_complete).
          with(:command_status => "Failed",
               :diagnostics => {:format => "JSON", :payload => script_error.to_json},
               :host_command_identifier => @command.host_command_identifier)

        assert_raise script_error do
          @poller.process_command(@command, @deployment_specification.generic_envelope)
        end
      end

      should 'complete the command when the command executor successfully processes the command' do
        @executor.expects(:execute_command).
          with(@command, @deployment_specification.generic_envelope)

        @deploy_control_client.expects(:put_host_command_complete).
          with(:command_status => "Succeeded",
                :diagnostics => {:format => "JSON", :payload => gather_diagnostics("")},
                :host_command_identifier => @command.host_command_identifier)

        @poller.process_command(@command, @deployment_specification.generic_envelope)
      end

      should 'call Thread.new to spin off a new thread for executing commands' do
        Thread.expects(:new).returns(nil)

        @poller.perform
      end

      should 'not try to enter thread if rejected execution error is raised (happens after shutdown initiated)' do
        Thread.expects(:new).never
        @mock_thread_pool_executor = mock()
        Concurrent::ThreadPoolExecutor.expects(:new).returns(@mock_thread_pool_executor)
        @mock_thread_pool_executor.stubs(:post).raises(Concurrent::RejectedExecutionError.new('RejectedExecutionError'))

        poller = InstanceAgent::Plugins::CodeDeployPlugin::CommandPoller.new
      end
    end

  end

end
