# frozen_string_literal: true # # Base class for all jobs that will be run on builds # class CommandJob include StandardModel # # Constants # STATE_NEW = 'new' unless defined? STATE_NEW STATE_WIP = 'working' unless defined? STATE_WIP STATE_RETRYING = 'retrying' unless defined? STATE_RETRYING STATE_SUCCESS = 'success' unless defined? STATE_SUCCESS STATE_FAIL = 'failure' unless defined? STATE_FAIL STATE_CANCELLED = 'cancelled' unless defined? STATE_CANCELLED unless defined? ALL_STATES ALL_STATES = [STATE_NEW, STATE_WIP, STATE_RETRYING, STATE_CANCELLED, STATE_SUCCESS, STATE_FAIL].freeze end # # Fields # field :state, type: String, default: STATE_NEW field :retries, type: Integer, default: 0 field :max_retries, type: Integer, default: 5 field :result, type: String field :error_message, type: String field :started_at, type: Time field :finished_at, type: Time # # Relationships # has_many :logs, class_name: 'CommandJobLog', dependent: :destroy belongs_to :started_by, class_name: 'User', optional: true # # Validations # validates :state, inclusion: { in: ALL_STATES } # # Who started this job # def display_started_by started_by.present? ? started_by.name : 'System' end # # Default time to keep a job before auto archiving it # def ttl 30 end # # Return the name of this job # def name self.class.to_s.underscore.humanize end # # True if in new status # def new_job? job_state?(STATE_NEW) end # # True if in WIP status # def work_in_progress? job_state?([STATE_WIP, STATE_RETRYING]) end # # True if in success status # def succeeded? job_state?(STATE_SUCCESS) end # # True if in fail status # def failure? job_state?(STATE_FAIL) end # # If we is finished, failed or success # def completed? job_state?([STATE_CANCELLED, STATE_FAIL, STATE_SUCCESS]) end # # Job has not finished, failure or success # def running? !completed? end alias incomplete? running? # # If we are cancelled # def cancelled? job_state?(STATE_CANCELLED) end def failure_or_cancelled? job_state?([STATE_FAIL, STATE_CANCELLED], default_state: true) end # # Fetch the latest version of this instance from the database and check the state against the required # state. If there is a match, then return true, otherwise return false. # If there is an error, return the default. # def job_state?(states, default_state: false) states.is_a?(Array) ? states.include?(state) : states.eql?(state) rescue StandardError => error App47Logger.log_warn "Unable to check job failed or cancelled #{inspect}", error default_state end # # Return the job's status and information in a hash that could be used to return to a calling # api # def current_status status = { state: state } status[:message] = error_message if error_message.present? status end # # Perform this job in the background # def perform_later perform end handle_asynchronously :perform_later # # Steps to execute before a run # def before_run case state when STATE_NEW set retries: 0, started_at: Time.now.utc, finished_at: nil, error_message: nil, result: nil, state: STATE_WIP when STATE_RETRYING set retries: 0, started_at: Time.now.utc, finished_at: nil, error_message: nil, result: nil when STATE_FAIL set retries: 0, started_at: Time.now.utc, finished_at: nil, error_message: nil, state: STATE_RETRYING, result: nil else set retries: 0, started_at: Time.now.utc, finished_at: nil, result: nil end end # # Steps to execute after a run # def after_run case state when STATE_RETRYING, STATE_WIP set finished_at: Time.now.utc, error_message: nil, state: STATE_SUCCESS when STATE_SUCCESS set finished_at: Time.now.utc, error_message: nil else set finished_at: Time.now.utc end end # # # Perform the command job # def perform before_run run after_run rescue StandardError => error log_error 'Unable to start job', error set state: STATE_FAIL, error_message: error.message end alias perform_now perform # # Run the job, handling any failures that might happen # def run run! unless cancelled? rescue StandardError => error if (retries + 1) >= max_retries log_error "Unable to run job id: #{id}, done retrying", error set state: STATE_FAIL, error_message: "Failed final attempt: #{error.message}" else log_error "Unable to run job id: #{id}, retrying!!", error add_log "Unable to run job: #{error.message}, retrying!!" set error_message: "Failed attempt # #{retries}: #{error.message}", retries: retries + 1, state: STATE_RETRYING run end end # # Determine the correct action to take and get it started # def run! raise 'Incomplete class, concrete implementation should implement #run!' end # # Write out the contents to the file # def write_file(path, contents) File.open(path, 'w') { |f| f.write(contents) } add_log "Saving:\n #{contents}\nto: #{path}" end # # Download a file to the given path # def download_file(file_url, file_path) download = URI.parse(file_url).open IO.copy_stream(download, file_path) add_log "Downloaded file: #{file_url} to #{file_path}" rescue StandardError => error raise "Unable to download file from #{file_url} to #{file_path}, error: ##{error.message}" end # # Copy a given file to a new location and record the log # def copy_file(from_path, to_path) if File.exist? from_path FileUtils.cp(from_path, to_path) add_log "Copy file from: #{from_path} to: #{to_path}" else add_log "File not found: #{from_path}, copy not performed" end rescue StandardError => error raise "Unable to copy file from #{from_path} to #{to_path}, error: ##{error.message}" end # # Copy a given directory to a new location and record the log # def copy_dir(dir, to_path) FileUtils.cp_r dir, to_path add_log "Copy directory from: #{dir} to: #{to_path}" end # # Remove the given file name # def remove_file(file_path) return unless File.exist?(file_path) FileUtils.remove_file file_path add_log "Removing file: #{file_path}" end # # Remove the given file name # def remove_dir(dir_path) return unless File.exist?(dir_path) FileUtils.remove_dir dir_path add_log "Removing dir: #{dir_path}" end # # Create a directory and record it # def mkdir(dir) return if File.exist?(dir) FileUtils.mkdir dir add_log "Created directory: #{dir}" end alias make_dir mkdir # # Unzip a given file # def unzip_file(file_path, to_dir) run_command "unzip #{file_path}", to_dir, error_texts: 'unzip:' end # # Run the command capturing the command output and any standard error to the log. # def run_command(command, dir = '/tmp', options = {}) command = command.join(' ') if command.is_a?(Array) output = Tempfile.open('run-command-', '/tmp') do |f| Dir.chdir(dir) { `#{command} > #{f.path} 2>&1` } mask_keywords(f.open.read, options[:mask_texts]) end output = 'Success' if output.blank? command = mask_keywords(command, options[:mask_texts]) if block_given? yield output else logs.create!(dir: dir, command: command, message: output) end options[:output_limit] ||= -1 check_for_text(output, options[:error_texts], output_limit: options[:output_limit]) check_for_text(output, options[:required_texts], inclusive_check: false, output_limit: options[:output_limit]) output end # # Mask keywords if given in the command # def mask_keywords(output, keywords = []) return output if keywords.blank? keywords = [keywords] if keywords.is_a?(String) keywords.each do |keyword| output = output.gsub(keyword, '***********') end output end # # Check if any occurrences were found (or not found) # For most command jobs, we want to see the full output. -1 accomplishes this # def check_for_text(output, texts = [], inclusive_check: true, output_limit: -1) return if texts.blank? texts = [texts] if texts.is_a?(String) texts.each do |text| if inclusive_check raise "Error: found text (#{text}) - #{output[0...output_limit]}" if output.match?(/#{text}/) else raise "Error: missing text (#{text}) - #{output[0...output_limit]}" unless output.match?(/#{text}/) end end end # # Add a job log message # def add_log(message) logs.create!(message: message) end # # Which to sort by # def sort_fields %i[created_at] end end