theforeman/foreman_remote_execution

View on GitHub
app/lib/actions/remote_execution/run_host_job.rb

Summary

Maintainability
C
1 day
Test Coverage
module Actions
  module RemoteExecution
    class RunHostJob < Actions::EntryAction
      include ::Actions::Helpers::WithContinuousOutput
      include ::Actions::Helpers::WithDelegatedAction
      include ::Actions::ObservableAction
      include ::Actions::RemoteExecution::TemplateInvocationProgressLogging
      include ::Actions::RemoteExecution::EventHelpers

      middleware.do_not_use Dynflow::Middleware::Common::Transaction
      middleware.use Actions::Middleware::HideSecrets

      def queue
        ForemanRemoteExecution::DYNFLOW_QUEUE
      end

      def resource_locks
        :link
      end

      def plan(job_invocation, host, template_invocation, proxy_selector = ::RemoteExecutionProxySelector.new, options = {})
        with_template_invocation_error_logging do
          inner_plan(job_invocation, host, template_invocation, proxy_selector, options)
        end
      end

      def inner_plan(job_invocation, host, template_invocation, proxy_selector, options)
        raise _('Could not use any template used in the job invocation') if template_invocation.blank?
        features = template_invocation.template.remote_execution_features.pluck(:label).uniq
        action_subject(host,
          :host_display_name => host.to_label,
          :job_category => job_invocation.job_category,
          :description => job_invocation.description,
          :job_invocation_id => job_invocation.id,
          :job_features => features)

        template_invocation.host_id = host.id
        template_invocation.run_host_job_task_id = task.id
        template_invocation.save!

        link!(job_invocation)
        link!(template_invocation)

        verify_permissions(host, template_invocation)

        provider = template_invocation.template.provider
        proxy_selector = provider.required_proxy_selector_for(template_invocation.template) || proxy_selector

        provider_type = provider.proxy_feature
        proxy = determine_proxy!(proxy_selector, provider_type, host)
        link!(proxy)
        input[:proxy_id] = proxy.id

        renderer = InputTemplateRenderer.new(template_invocation.template, host, template_invocation)
        script = renderer.render
        raise _('Failed rendering template: %s') % renderer.error_message unless script

        first_execution = host.executed_through_proxies.where(:id => proxy.id).none?
        host.executed_through_proxies << proxy if first_execution

        additional_options = { :hostname => provider.find_ip_or_hostname(host),
                               :script => script,
                               :execution_timeout_interval => job_invocation.execution_timeout_interval,
                               :secrets => secrets(host, job_invocation, provider),
                               :use_batch_triggering => true,
                               :first_execution => first_execution,
                               :alternative_names => provider.alternative_names(host) }
        action_options = provider.proxy_command_options(template_invocation, host)
                                 .merge(additional_options)

        plan_delegated_action(proxy, provider.proxy_action_class, action_options, proxy_action_class: ::Actions::RemoteExecution::ProxyAction)
        plan_self :with_event_logging => true
      end

      def finalize(*args)
        with_template_invocation_error_logging do
          update_host_status
          check_exit_status
        end
      end

      def self.event_states
        [:success, :failure]
      end

      def secrets(host, job_invocation, provider)
        job_secrets = { :ssh_password => job_invocation.password,
                        :key_passphrase => job_invocation.key_passphrase,
                        :effective_user_password => job_invocation.effective_user_password }

        job_secrets.merge(provider.secrets(host)) { |_key, job_secret, provider_secret| job_secret || provider_secret }
      end

      def check_exit_status
        error! ForemanTasks::Task::TaskCancelledException.new(_('Task cancelled')) if delegated_action && delegated_action.output[:cancel_sent]
        error! _('Job execution failed') if exit_status.to_s != '0'
      end

      def live_output
        continuous_output.sort!
      end

      def humanized_input
        return unless input.present?

        N_('%{description} on %{host}') % {
          host: input[:host_display_name],
          description: input[:description].try(:capitalize) || input[:job_category],
        }
      end

      def humanized_name
        N_('Remote action:')
      end

      def rescue_strategy
        ::Dynflow::Action::Rescue::Fail
      end

      def humanized_output
        continuous_output.humanize
      end

      def continuous_output_providers
        super << self
      end

      def fill_continuous_output(continuous_output)
        if input[:with_event_logging]
          continuous_output_from_template_invocation_events(continuous_output)
          return
        end

        delegated_output.fetch('result', []).each do |raw_output|
          continuous_output.add_raw_output(raw_output)
        end

        final_timestamp = (continuous_output.last_timestamp || task.ended_at).to_f + 1

        if task.state == 'stopped' && task.result == 'cancelled'
          continuous_output.add_output(_('Job cancelled by user'), 'debug', final_timestamp)
        else
          fill_planning_errors_to_continuous_output(continuous_output) unless exit_status
        end
        if exit_status
          continuous_output.add_output(_('Exit status: %s') % exit_status, 'stdout', final_timestamp)
        elsif run_step&.error
          continuous_output.add_output(_('Job finished with error') + ": #{run_step.error.exception_class} - #{run_step.error.message}", 'debug', final_timestamp)
        end
      rescue => e
        continuous_output.add_exception(_('Error loading data from proxy'), e)
      end

      def continuous_output_from_template_invocation_events(continuous_output)
        begin
          # Trigger reload
          delegated_output unless task.state == 'stopped'
        rescue => e
          # This is enough, the error will get shown using add_exception at the end of the method
        end

        task.template_invocation.template_invocation_events.order(:sequence_id).find_each do |output|
          if output.event_type == 'exit'
            continuous_output.add_output(_('Exit status: %s') % output.event, 'stdout', output.timestamp)
          else
            continuous_output.add_raw_output(output.as_raw_continuous_output)
          end
        end
        continuous_output.add_exception(_('Error loading data from proxy'), e) if e
      end

      def exit_status
        input[:with_event_logging] ? task.template_invocation.template_invocation_events.find_by(event_type: 'exit')&.event : delegated_output[:exit_status]
      end

      def host_id
        input['host']['id']
      end

      def host_name
        input['host']['name']
      end

      def job_invocation_id
        input['job_invocation_id']
      end

      def job_invocation
        @job_invocation ||= ::JobInvocation.authorized.find(job_invocation_id)
      end

      def host
        @host ||= ::Host.authorized.find(host_id)
      end

      private

      def update_host_status
        host = Host.find(input[:host][:id])
        status = host.execution_status_object || host.build_execution_status_object
        status.status = exit_status.to_s == "0" ? HostStatus::ExecutionStatus::OK : HostStatus::ExecutionStatus::ERROR
        status.refresh unless status.new_record?
        status.save!
        host.refresh_global_status!
      end

      def verify_permissions(host, template_invocation)
        raise _('User can not execute job on host %s') % host.name unless User.current.can?(:view_hosts, host)
        raise _('User can not execute this job template') unless User.current.can?(:view_job_templates, template_invocation.template)
        infra_facet = host.infrastructure_facet
        if (infra_facet&.foreman_instance || infra_facet&.smart_proxy_id) && !User.current.can?(:execute_jobs_on_infrastructure_hosts)
          raise _('User can not execute job on infrastructure host %s') % host.name
        end

        # we don't want to load all template_invocations to verify so we construct Authorizer object manually and set
        # the base collection to current template
        authorizer = Authorizer.new(User.current, :collection => [ template_invocation.id ])
        raise _('User can not execute this job template on %s') % host.name unless authorizer.can?(:create_template_invocations, template_invocation)

        true
      end

      def determine_proxy!(proxy_selector, provider, host)
        proxy = proxy_selector.determine_proxy(host, provider)
        if proxy == :not_available
          offline_proxies = proxy_selector.offline
          settings = { :count => offline_proxies.count, :proxy_names => offline_proxies.map(&:name).join(', ') }
          raise n_('The only applicable proxy %{proxy_names} is down',
            'All %{count} applicable proxies are down. Tried %{proxy_names}',
            offline_proxies.count) % settings
        elsif proxy == :not_defined
          settings = {
            global_proxy: 'remote_execution_global_proxy',
            fallback_proxy: 'remote_execution_fallback_proxy',
            provider: provider,
          }

          raise _('Could not use any proxy for the %{provider} job. Consider configuring %{global_proxy}, ' +
                  '%{fallback_proxy} in settings') % settings
        end
        proxy
      end

      extend ApipieDSL::Class
      apipie :class, "An action representing execution of a job against a host" do
        name 'Actions::RemoteExecution::RunHostJob'
        refs 'Actions::RemoteExecution::RunHostJob'
        sections only: %w[all webhooks]
        property :task, object_of: 'Task', desc: 'Returns the task to which this action belongs'
        property :host_name, String, desc: "Returns the name of the host"
        property :host_id, Integer, desc: "Returns the id of the host"
        property :host, object_of: 'Host', desc: "Returns the host"
        property :job_invocation_id, Integer, desc: "Returns the id of the job invocation"
        property :job_invocation, object_of: 'JobInvocation', desc: "Returns the job invocation"
      end
      class Jail < ::Actions::ObservableAction::Jail
        allow :host_name, :host_id, :host, :job_invocation_id, :job_invocation
      end
    end
  end
end