lib/raykit/command.rb



require "open3"
require "timeout"
require "json"
require "yaml"
require "logger"
require "securerandom"

BUFFER_SIZE = 1024 unless defined?(BUFFER_SIZE)

module Raykit
  # Functionality to support executing and logging system commands

  class Command
    @@commands = []
    # The timeout in seconds, defaults to 0 if not specified

    attr_accessor :timeout
    # The working directory, defaults the current directory if not specified

    attr_accessor :directory
    # The start time

    attr_accessor :start_time
    # The execution time in seconds

    attr_accessor :elapsed
    attr_accessor :command, :output, :error, :exitstatus, :user, :machine, :logging_enabled, :success_log_level

    def init_defaults
      @timeout = 0
      @directory = Dir.pwd
      @output = +""
      @error = +""
      @exitstatus = 0
      @user = Environment.user
      @machine = Environment.machine
      @logging_enabled = true
    end

    def initialize(command)
      #def initialize(command, timeout = 0, success_log_level = Logger::DEBUG, logging_enabled = true)

      timeout = 0
      success_log_level = nil
      logging_enabled = false
      @@commands = []
      init_defaults
      @success_log_level = success_log_level
      @logging_enabled = logging_enabled
      @command = command
      @timeout = timeout
      #t = Time.now

      @elapsed = 0
      #run if @command.length.positive?

      self
    end

    def set_timeout(timeout)
      @timeout = timeout
      self
    end

    def run
      # puts '---running---'

      @start_time = Time.now
      @elapsed = 0
      timer = Timer.new
      if @timeout.zero?
        @output, @error, process_status = Open3.capture3(@command)
        @exitstatus = process_status.exitstatus
      else
        # =================================================

        #puts "@timeout is #{@timeout}"

        Open3.popen3(@command, chdir: @directory) do |_stdin, stdout, stderr, thread|
          tick = 1
          pid = thread.pid
          start = Time.now
          while ((Time.now - start) < @timeout) && thread.alive?
            Kernel.select([stdout, stderr], nil, nil, tick)
            begin
              @output << stdout.read_nonblock(BUFFER_SIZE)
              #@error << stderr.read_nonblock(BUFFER_SIZE)

            rescue IO::WaitReadable
            rescue EOFError
              #puts "rescue block entered"

              @exitstatus = thread.value.exitstatus
              until stdout.eof?
                @output << stdout.read_nonblock(BUFFER_SIZE)
              end
              until stderr.eof?
                @error << stderr.read_nonblock(BUFFER_SIZE)
              end
              break
            end

            #begin

            #  @output << stdout.read_nonblock(BUFFER_SIZE)

            #  @error << stderr.read_nonblock(BUFFER_SIZE)

            #rescue IO::WaitReadable

            #rescue EOFError

            #  @exitstatus = thread.value.exitstatus

            #  until stdout.eof?

            #    @output << stdout.read_nonblock(BUFFER_SIZE)

            #  end

            #  until stderr.eof?

            #    @error << stderr.read_nonblock(BUFFER_SIZE)

            #  end

            #  break

            #end

          end
          sleep 1
          if thread.alive?
            if Gem.win_platform?
              `taskkill /f /pid #{pid}`
            else
              Process.kill("TERM", pid)
            end
            @exitstatus = 5
            @error = +"timed out"
          else
            @exitstatus = thread.value.exitstatus
          end
        end

        # =================================================

      end
      @elapsed = timer.elapsed
      if @logging_enabled
        log
        if @exitstatus != 0
          to_log_event.to_seq
        else
          # puts '---logging---'

          unless @success_log_level.nil?
            e = to_log_event
            e["Level"] = "Verbose" if @success_log_level == "Verbose"
            e["Level"] = "Debug" if @success_log_level == Logger::DEBUG
            e["Level"] = "Information" if @success_log_level == Logger::INFO
            e["Level"] = "Warning" if @elapsed > 60 * 2
            e.to_seq
          end
        end
      end
      self
    end

    def to_log_event
      secrets = Secrets.new
      msg = secrets.hide(@command)
      level = "Verbose"
      level = "Warning" if @exitstatus != 0
      output = @output
      error = @error
      output = @output[-1000..-1] if @output.length > 1200
      error = @error[-1000..-1] if @error.length > 1200
      Raykit::LogEvent.new(level, msg, {
        "SourceContext" => "Raykit::Command",
        "Category" => "Command",
        "Timeout" => @timeout,
        "Directory" => @directory,
        "Output" => output,
        "Error" => error,
        "ExitStatus" => @exitstatus,
        "Elapsed" => elapsed_str,
        "ElapsedSeconds" => @elapsed,
      })
    end

    def log
      # --- Rolling File JSON -----

      log = Logger.new("#{Raykit::Environment.log_dir}/Raykit.Commands.txt", "daily")
      log.formatter = proc do |_severity, _datetime, _progname, msg|
        "#{msg}\n"
      end
      secrets = Secrets.new
      msg = secrets.hide(@command) # "?"# self.summary(false)

      level = "Verbose"
      level = "Warning" if @exitstatus != 0
      event = Raykit::LogEvent.new(level, msg, {
        "Timeout" => @timeout,
        "Directory" => @directory,
        "Output" => @output,
        "Error" => @error,
        "ExitStatus" => @exitstatus,
        "Elapsed" => elapsed_str,
      })
      log.info event.to_json
      # ---------------------------

      begin
        json = JSON.generate(to_hash)
        log_filename = "#{Environment.get_dev_dir("log")}/Raykit.Command/#{SecureRandom.uuid}.json"
        log_dir = File.dirname(log_filename)
        FileUtils.mkdir_p(log_dir) unless Dir.exist?(log_dir)
        File.open(log_filename, "w") { |f| f.write(json) }
        if !@exitstatus.nil? && @exitstatus.zero?
          LOG.log("Raykit.Command", Logger::Severity::INFO, json)
        else
          LOG.log("Raykit.Command", Logger::Severity::ERROR, json)
        end
      rescue JSON::GeneratorError => e
        puts to_hash.to_s
        puts e.to_s
        json = JSON.generate(to_hash)
      end
    end

    def elapsed_str
      if elapsed < 1.0
      end
      "#{format("%.0f", elapsed)}s"
    end

    def summary(show_directory = false)
      checkmark = "\u2713"
      # warning="\u26A0"

      error = "\u0058"
      symbol = Rainbow(checkmark.encode("utf-8")).green
      symbol = Rainbow(error.encode("utf-8")).red if @exitstatus != 0
      cmd = "#{Rainbow(SECRETS.hide(@command)).yellow}"
      if show_directory
        puts "#{symbol} #{cmd} " + Rainbow("#{elapsed_str}").cyan
        puts Rainbow("  #{@directory}").white + " "
      else
        puts "#{symbol} #{Rainbow(SECRETS.hide(@command)).yellow} " + Rainbow("#{elapsed_str}").cyan
        #puts "#{symbol} #{Rainbow(SECRETS.hide(@command)).yellow} (#{elapsed_str})"

      end
      self
    end

    def details_on_failure
      if @exitstatus != 0
        details
      end
      self
    end

    def details
      puts "  exit_code: " + @exitstatus.to_s
      if @output.length > 0
        @output.lines.each do |line|
          puts "  " + line
        end
      end
      if @error.length > 0
        @error.lines.each do |line|
          puts "  " + line
        end
      end
      self
    end

    def to_markdown
      checkmark = "\u2713"
      error = "\u0058"
      symbol = checkmark.encode("utf-8")
      symbol = error.encode("utf-8") if @exitstatus != 0
      cmd = "#{SECRETS.hide(@command)}"
      md = "#{symbol} #{SECRETS.hide(@command)} (#{elapsed_str})"
      md
    end

    def save
      filename = "#{Environment.get_dev_dir("log")}/Commands/#{SecureRandom.uuid}"
      log_dir = File.dirname(filename)
      FileUtils.mkdir_p(log_dir) unless Dir.exist?(log_dir)

      File.open(filename, "w") do |f|
        f.write(JSON.pretty_generate(to_hash))
      end
      self
    end

    def save_as(filename)
      File.open(filename, "w") do |f|
        f.write(JSON.pretty_generate(to_hash))
      end
      self
    end

    def log_to_file(filename)
      File.delete(filename) if File.exists?(filename)
      File.open(filename, "w") { |f|
        f.puts output
        f.puts error
      }
      self
    end

    def to_hash
      hash = {}
      hash[:command] = @command
      hash[:directory] = @directory
      hash[:timeout] = @timeout
      hash[:start_time] = @start_time
      hash[:elapsed] = @elapsed
      hash[:output] = @output.force_encoding("ISO-8859-1").encode("UTF-8")
      hash[:error] = @error.force_encoding("ISO-8859-1").encode("UTF-8")
      hash[:exitstatus] = @exitstatus
      hash[:user] = @user
      hash[:machine] = @machine
      hash[:context] = "Raykit.Command"
      hash
    end

    def from_hash(hash)
      @command = hash["command"]
      @directory = hash["directory"]
      @timeout = hash["timeout"]
      @start_time = hash["start_time"]
      @elapsed = hash["elapsed"]
      @output = hash["output"]
      @error = hash["error"]
      @exitstatus = hash["exitstatus"]
      @user = hash["user"] if hash.include?("user")
      @machine = hash["machine"] if hash.include?("machine")
    end

    def self.parse(json)
      cmd = Command.new("")
      cmd.from_hash(JSON.parse(json))
      cmd
    end

    def self.parse_yaml_commands(yaml)
      # commands=Array.new()

      data = YAML.safe_load(yaml)
      commands = get_script_commands(data)
    end

    def self.get_script_commands(hash)
      commands = []
      if hash.key?("script")
        hash["script"].each do |cmd|
          commands << cmd
        end
      end
      hash.each do |_k, v|
        next unless v.is_a?(Hash)

        subcommands = get_script_commands(v)
        subcommands.each do |c|
          commands << c
        end
      end
      commands
    end
  end
end