theforeman/foreman_remote_execution

View on GitHub
app/controllers/api/v2/job_invocations_controller.rb

Summary

Maintainability
A
1 hr
Test Coverage
module Api
  module V2
    class JobInvocationsController < ::Api::V2::BaseController
      include ::Api::Version2
      include ::Foreman::Renderer

      before_action :find_optional_nested_object, :only => %w{output raw_output}
      before_action :find_host, :only => %w{output raw_output}
      before_action :find_resource, :only => %w{show update destroy clone cancel rerun outputs}

      wrap_parameters JobInvocation, :include => (JobInvocation.attribute_names + [:ssh])

      api :GET, '/job_invocations/', N_('List job invocations')
      param_group :search_and_pagination, ::Api::V2::BaseController
      def index
        @job_invocations = resource_scope_for_index
      end

      api :GET, '/job_invocations/:id', N_('Show job invocation')
      param :id, :identifier, :required => true
      param :host_status, :bool, required: false, desc: N_('Show Job status for the hosts')
      def show
        @hosts = @job_invocation.targeting.hosts.authorized(:view_hosts, Host)
        @template_invocations = @job_invocation.template_invocations
                                               .where(host: @hosts)
                                               .includes(:input_values)

        if params[:host_status] == 'true'
          template_invocations = @template_invocations.includes(:run_host_job_task).to_a
          @host_statuses = Hash[template_invocations.map { |ti| [ti.host_id, template_invocation_status(ti)] }]
        end
      end

      def_param_group :job_invocation do
        param :job_invocation, Hash, :required => true, :action_aware => true do
          param :job_template_id, String, :required => false, :desc => N_('The job template to use, parameter is required unless feature was specified')
          param :targeting_type, String, :required => true, :desc => N_('Invocation type, one of %s') % Targeting::TYPES
          param :randomized_ordering, :bool, :desc => N_('Execute the jobs on hosts in randomized order')
          param :inputs, Hash, :required => false, :desc => N_('Inputs to use')
          param :ssh, Hash, :desc => N_('SSH provider specific options') do
            param :effective_user, String,
              :required => false,
              :desc => N_('What user should be used to run the script (using sudo-like mechanisms). Defaults to a template parameter or global setting.')
            param :effective_user_password, String,
              :required => false,
              :desc => N_('Set password for effective user (using sudo-like mechanisms)')
          end
          param :ssh_user, String, :required => false, :desc => N_('Set SSH user')
          param :password, String, :required => false, :desc => N_('Set SSH password')
          param :key_passphrase, String, :required => false, :desc => N_('Set SSH key passphrase')

          param :recurrence, Hash, :desc => N_('Create a recurring job') do
            param :cron_line, String, :required => false, :desc => N_('How often the job should occur, in the cron format')
            param :max_iteration, :number, :required => false, :desc => N_('Repeat a maximum of N times')
            param :end_time, DateTime, :required => false, :desc => N_('Perform no more executions after this time')
            param :purpose, String, :required => false, :desc => N_('Designation of a special purpose')
          end

          param :scheduling, Hash, :desc => N_('Schedule the job to start at a later time') do
            param :start_at, DateTime, :required => false, :desc => N_('Schedule the job for a future time')
            param :start_before, DateTime, :required => false, :desc => N_('Indicates that the action should be cancelled if it cannot be started before this time.')
          end

          param :concurrency_control, Hash, :desc => N_('Control concurrency level and distribution over time') do
            param :concurrency_level, Integer, :desc => N_('Run at most N tasks at a time')
          end

          param :bookmark_id, Integer, :required => false
          param :search_query, String, :required => false
          param :description_format, String, :required => false, :desc => N_('Override the description format from the template for this invocation only')
          param :execution_timeout_interval, Integer, :required => false, :desc => N_('Override the timeout interval from the template for this invocation only')
          param :feature, String, :required => false, :desc => N_('Remote execution feature label that should be triggered, job template assigned to this feature will be used')
          param :time_to_pickup, Integer, :required => false, :desc => N_('Override the global time to pickup interval for this invocation only')

          RemoteExecutionProvider.providers.each_value do |provider|
            next if !provider.respond_to?(:provider_inputs_doc) || provider.provider_inputs_doc.empty?
            doc = provider.provider_inputs_doc
            param doc[:namespace], Hash, doc[:opts] do
              doc[:children].map do |input|
                param input[:name], input[:type], input[:opts]
              end
            end
          end
        end
      end

      api :POST, '/job_invocations/', N_('Create a job invocation')
      param_group :job_invocation, :as => :create
      def create
        composer = JobInvocationComposer.from_api_params(
          job_invocation_params
        )
        composer.trigger!
        @job_invocation = composer.job_invocation
        @hosts = @job_invocation.targeting.hosts
        process_response @job_invocation
      rescue JobInvocationComposer::JobTemplateNotFound, JobInvocationComposer::FeatureNotFound => e
        not_found(error: { message: e.message })
      end

      api :GET, '/job_invocations/:id/hosts/:host_id', N_('Get output for a host')
      param :id, :identifier, :required => true
      param :host_id, :identifier, :required => true
      param :since, String, :required => false
      def output
        if @nested_obj.task.scheduled?
          render :json => delayed_task_output(@nested_obj.task, :default => [])
          return
        end

        render :json => host_output(@nested_obj, @host, :default => [], :since => params[:since])
      end

      api :GET, '/job_invocations/:id/hosts/:host_id/raw', N_('Get raw output for a host')
      param :id, :identifier, :required => true
      param :host_id, :identifier, :required => true
      def raw_output
        if @nested_obj.task.scheduled?
          render :json => delayed_task_output(@nested_obj.task)
          return
        end

        render :json => host_output(@nested_obj, @host, :raw => true)
      end

      api :POST, '/job_invocations/:id/cancel', N_('Cancel job invocation')
      param :id, :identifier, :required => true
      param :force, :bool
      def cancel
        if @job_invocation.task.cancellable?
          result = @job_invocation.cancel(params.fetch('force', false))
          render :json => { :cancelled => result, :id => @job_invocation.id }
        else
          render :json => { :message => _('The job could not be cancelled.') },
            :status => :unprocessable_entity
        end
      end

      api :POST, '/job_invocations/:id/rerun', N_('Rerun job on failed hosts')
      param :id, :identifier, :required => true
      param :failed_only, :bool
      def rerun
        composer = JobInvocationComposer.from_job_invocation(@job_invocation, params)
        if composer.rerun_possible?
          composer.trigger!
          @job_invocation = composer.job_invocation
          process_response @job_invocation
        else
          render :json => { :error => _('Could not rerun job %{id} because its template could not be found') % { :id => composer.reruns } },
            :status => :not_found
        end
      end

      api :GET, '/job_invocations/:id/outputs', N_('Get outputs of hosts in a job')
      param :id, :identifier, :required => true
      param :search_query, :identifier, :required => false
      param :since, String, :required => false
      param :raw, String, :required => false
      def outputs
        hosts = @job_invocation.targeting.hosts.authorized(:view_hosts, Host)
        hosts = hosts.search_for(params['search_query']) if params['search_query']
        raw = ActiveRecord::Type::Boolean.new.cast params['raw']
        default_value = raw ? '' : []
        outputs = hosts.map do |host|
          host_output(@job_invocation, host, :default => default_value, :since => params['since'], :raw => raw)
            .merge(host_id: host.id)
        end

        render :json => { :outputs => outputs }
      end

      def resource_name(resource = controller_name)
        case resource
        when 'organization', 'location'
          nil
        else
          'job_invocation'
        end
      end

      private

      def allowed_nested_id
        %w(job_invocation_id)
      end

      def action_permission
        case params[:action]
        when 'output', 'raw_output', 'outputs'
          :view
        when 'cancel'
          :cancel
        when 'rerun'
          :create
        else
          super
        end
      end

      def find_host
        @host = @nested_obj.targeting.hosts.authorized(:view_hosts, Host).find(params['host_id'])
      rescue ActiveRecord::RecordNotFound
        not_found({ :error => { :message => (_("Host with id '%{id}' was not found") % { :id => params['host_id'] }) } })
      end

      def job_invocation_params
        return @job_invocation_params if @job_invocation_params.present?

        job_invocation_params = params.fetch(:job_invocation, {}).dup

        if job_invocation_params[:feature].present? && job_invocation_params[:job_template_id].present?
          raise _("Only one of feature or job_template_id can be specified")
        end

        if job_invocation_params.key?(:ssh)
          job_invocation_params.merge!(job_invocation_params.delete(:ssh).permit(:effective_user, :effective_user_password))
        end

        job_invocation_params[:inputs] ||= {}
        job_invocation_params[:inputs].permit!
        permit_provider_inputs job_invocation_params
        @job_invocation_params = job_invocation_params
      end

      def permit_provider_inputs(invocation_params)
        providers = RemoteExecutionProvider.providers.values.reject { |provider| !provider.respond_to?(:provider_input_namespace) || provider.provider_input_namespace.empty? }
        providers.each { |provider| invocation_params[provider.provider_input_namespace]&.permit! }
      end

      def output_lines_since(task, time)
        since = time.to_f if time.present?
        line_sets = task.main_action.live_output
        line_sets = line_sets.drop_while { |o| o['timestamp'].to_f <= since } if since
        line_sets
      end

      def host_output(job_invocation, host, default: nil, since: nil, raw: false)
        refresh = !job_invocation.finished?

        if (task = job_invocation.sub_task_for_host(host))
          refresh = task.pending?
          output  = output_lines_since(task, since)
          output  = output.map { |set| set['output'] }.join if raw
        end

        { :complete => !refresh, :refresh => refresh, :output => output || default }
      end

      def delayed_task_output(task, default: nil)
        { :complete => false, :refresh => true, :output => default, :delayed => true, :start_at => task.start_at }
      end

      # Do not try to scope JobInvocations by taxonomies
      def parent_scope
        resource_class.where(nil)
      end

      def template_invocation_status(template_invocation)
        task = template_invocation.try(:run_host_job_task)
        parent_task = @job_invocation.task

        return(parent_task.result == 'cancelled' ? 'cancelled' : 'N/A') if task.nil?
        return task.state if task.state == 'running' || task.state == 'planned'
        return 'error' if task.result == 'warning'

        task.result
      end
    end
  end
end