require "English" require "open3" require "socket" require "etc" require "logger" # This module provides classes for the Makit gem. module Makit # This class provide methods running commands. # class CommandRunner attr_accessor :show_output_on_success, :log_to_artifacts, :commands def initialize @show_output_on_success = false @log_to_artifacts = true @commands = [] end def get_cache_filename(command) # test if the command_request is a Makit::V1::CommandRequest #if command_request.is_a? Makit::V1::CommandRequest || command_request.is_a? Makit::V1::Command # also replacing any path delimiters with an underscore int_hash = Digest::SHA256.hexdigest("{command.name}.#{command.arguments.join(" ")}") # int_hash #int_hash = command_request.to_hash hash_string = "#{int_hash}"[0, 8] cache_filename = Makit::Directories::PROJECT_ARTIFACTS + "/commands/cache/#{hash_string}.pb" # create the directory if it does not already exist FileUtils.mkdir_p(File.dirname(cache_filename)) cache_filename end # if there is a matching cached command result, that then the specified timestamp, # then return the cached result # otherwise run the command and save the result to a cache file # then return the result def cache_run(command_request, timestamp) # combine the command name and arguments into a single string # and use it to create a cache filename, making sure it is a valid filename, # by replacing all characters that are not valid in a filename with an underscore # also replacing any path delimiters with an underscore int_hash = command_request.to_hash hash_string = "#{int_hash}"[0, 8] cache_filename = Makit::Directories::PROJECT_ARTIFACTS + "/commands/#{hash_string}.pb" #puts "cache_filename: #{cache_filename}" #cache_filename = Makit::Directories::PROJECT_ARTIFACTS + "/commands/#{command_request.name}.#{command_request.arguments.join("_")}.#{timestamp.seconds}.pb" if File.exist?(cache_filename) #puts "cache file date: #{File.mtime(cache_filename)}" if (File.mtime(cache_filename) > timestamp) #puts "cache_filename exists and is newer than #{timestamp}" command = Makit::Serializer.open(cache_filename, Makit::V1::Command) show_command(command) return command #Makit::Serializer.open(cache_filename, Makit::V1::Command) else #puts "cache_filename exists, but is older than #{timestamp}" end end command = run(command_request) # make sure the cache directory exists FileUtils.mkdir_p(File.dirname(cache_filename)) #puts "saving command to cache_filename" Makit::Serializer.save_as(cache_filename, command) commands.push(command) command end def show_command(command) if command.exit_code != 0 puts Makit::CommandRunner.get_command_summary(command) + " (exit code #{command.exit_code})".colorize(:default) puts " directory: #{command.directory}\n" puts " duration: #{command.duration.seconds} seconds\n" puts Makit::Humanize::indent_string(command.output, 2) if command.output.length > 0 puts Makit::Humanize::indent_string(command.error, 2) if command.error.length > 0 #exit 1 if command.exit_on_error else puts Makit::CommandRunner.get_command_summary(command) + " (#{command.duration.seconds} seconds)".colorize(:cyan) puts Makit::Humanize::indent_string(command.output, 2).colorize(:default) if show_output_on_success end end # Run a command and return a Makit::V1::Command. def run(command_request) raise "Invalid command_request" unless command_request.is_a? Makit::V1::CommandRequest command = execute(command_request) show_output = true exit_on_error = true log_to_artifacts(command) if @log_to_artifacts show_command(command) #exit 1 if exit_on_error & command.exit_code != 1 #if command.exit_code != 0 # puts Makit::CommandRunner.get_command_summary(command) + " (exit code #{command.exit_code})".colorize(:default) # puts " directory: #{command.directory}\n" # puts " duration: #{command.duration.seconds} seconds\n" # puts Makit::Humanize::indent_string(command.output, 2) if command.output.length > 0 # puts Makit::Humanize::indent_string(command.error, 2) if command.error.length > 0 # exit 1 if command_request.exit_on_error #else # puts Makit::CommandRunner.get_command_summary(command) + " (#{command.duration.seconds} seconds)".colorize(:cyan) # puts Makit::Humanize::indent_string(command.output, 2).colorize(:default) if show_output_on_success #end commands.push(command) command end def log_to_artifacts(command) #dir = File.join(Makit::Directories::PROJECT_ARTIFACTS, "commands") #FileUtils.mkdir_p(dir) unless Dir.exist?(dir) #filename_friendly_timestamp = Time.now.strftime("%Y.%m.%d_%H%M%S") #log_filename = File.join(dir, "#{filename_friendly_timestamp}.json") log_filename = get_cache_filename(command) # serialize to protobuf json json = command.to_json pretty_json = JSON.pretty_generate(JSON.parse(json)) File.write(log_filename, pretty_json) end # Run a command and return a Makit::V1::Command. def try(args) request = parse_command_request(args) request.exit_on_error = false run(request) #run2(args, false) end # Show the output of a command and return a Makit::V1::Command. def show(args) request = parse_args(args) command = execute(request) show_output = true exit_on_error = true Makit::LOGGER.info(Makit::CommandRunner.get_command_summary(command)) show_output = true if command.exit_code != 0 Makit::LOGGER.info(indent_string("\n#{command.output}\n#{command.error}\n".strip, 2)) if show_output exit(command.exit_code) if exit_on_error && command.exit_code != 0 # unless process_status.success? command end # Parse and return a Makit::V1::CommandRequest. def parse_command_request(source) return Makit::V1::CommandRequest.new(source) if source.is_a? Hash return source if source.is_a? Makit::V1::CommandRequest if source.is_a? String return parse_args(source) end raise "Invalid source" unless source.is_a? Makit::V1::CommandRequest end def parse_command_request_from_hash(hash) raise "Invalid hash" unless hash.is_a? Hash Makit::V1::CommandRequest.new(hash) end def parse_command_request_from_string(source) raise "Invalid source" unless source.is_a? String words = source.split(" ") hash = { name: words.shift, arguments: words, exit_on_error: true, } end # Parse the command line arguments into a Makit::V1::CommandRequest. def parse_args(args) #raise "No command specified" if args.empty? if args.is_a? Makit::V1::CommandRequest args else if args.is_a? String args = args.split(" ") if (args.length == 1) hash = { name: args[0], arguments: [], exit_on_error: true, } Makit::V1::CommandRequest.new(hash) else hash = { name: args.shift, arguments: args, exit_on_error: true, } Makit::V1::CommandRequest.new(hash) end else Makit::V1::CommandRequest.new(args) end end end def get_path_name(name) # replace all characters that a not valid in a filename with an underscore name.gsub(/[^0-9a-z]/i, "_") end # Given a Makit::V1::CommandRequest, execute the command and return a Makit::V1::Command. def execute(args) command_request = parse_args(args) command_request.directory = Dir.pwd if command_request.directory.nil? command_request.directory = Dir.pwd if command_request.directory.length == 0 result = Makit::V1::Command.new(name: command_request.name) command_request.arguments.each do |arg| result.arguments.push(arg) end command = "#{command_request.name} #{command_request.arguments.join(" ")}" result.directory = command_request.directory start = Time.now filename_friendly_timestamp = Time.now.strftime("%Y.%m.%d_%H%M%S") log_filename = File.join(Makit::Directories::LOG, "#{filename_friendly_timestamp}.log") # assign a directory variable to the current working directory, if not specified, # otherwise assign the specified directory command_request.directory = Dir.pwd if command_request.directory.nil? command_request.directory = Dir.pwd if command_request.directory.length == 0 raise "Invalid directory" unless Dir.exist?(command_request.directory) result.started_at = Google::Protobuf::Timestamp.new(seconds: start.to_i, nanos: start.nsec.to_i) ############# execute the command (output, error, exit_code) = execute_command(command, command_request.directory, command_request.timeout) result.output = output.force_encoding("ASCII-8BIT") result.error = error.force_encoding("ASCII-8BIT") result.exit_code = exit_code.nil? ? 0 : exit_code elapsed_time = Time.now - start seconds = elapsed_time.to_i nanos = ((elapsed_time - seconds) * 1_000_000_000).to_i result.duration = Google::Protobuf::Duration.new(seconds: seconds, nanos: nanos) result end # pure function to execute a command # returns (stdout, stderr, exit_code) or raise an exception def execute_command(command, directory, timeout) original_directory = Dir.pwd begin output = nil error = nil process_status = nil Dir.chdir(directory) do output, error, process_status = Open3.capture3(command) end return [output, error, process_status.exitstatus] rescue => e # restore the original working directory Dir.chdir(original_directory) message_parts = [] message_parts << "failed to execute #{command}" message_parts << "directory: #{directory}" message_parts << "timeout: #{timeout}" unless timeout.nil? message_parts << "error: #{e.message}" message = message_parts.join("\n") + "\n" return ["", message, 1] #raise Makit::Error, message end end def execute_command_request(command_request) # if the command_request is not a Makit::V1::CommandRequest, raise an error raise "Invalid command_request" unless command_request.is_a? Makit::V1::CommandRequest args = Array.new command_request.arguments.each do |arg| args.push(arg) end result = Makit::V1::Command.new({ name: command_request.name, arguments: args, started_at: Google::Protobuf::Timestamp.new({ seconds: Time.now.to_i, nanos: Time.now.nsec }), }) begin rescue => e end end def indent_string(input_string, indent_spaces) indentation = " " * indent_spaces input_string.lines.map { |line| indentation + line }.join end def self.get_command_summary(command) symbol = Makit::Symbols.warning symbol = Makit::Symbols.checkmark if !command.exit_code.nil? && command.exit_code.zero? symbol = Makit::Symbols.error if command.exit_code != 0 summary = "#{symbol} #{command.name.colorize(:yellow)} #{command.arguments.join(" ")}" if summary.length > 80 summary = summary.to_lines(80, command.name.length + 3) end summary end def log_rake_commands dir = File.join(Makit::Directories::PROJECT_ARTIFACTS, "commands") FileUtils.mkdir_p(dir) unless Dir.exist?(dir) # open a text file to write to File.open(File.join(dir, "rake.commands.txt"), "w") do |file| #rake_commands = commands.select { |command| command.name == "rake" } commands.each do |command| file.puts " " + Makit::CommandRunner.get_command_summary(command).strip_color_codes file.puts " start time: #{command.started_at}" file.puts command.output.strip_color_codes unless command.output.strip_color_codes.strip.length == 0 file.puts command.error.strip_color_codes unless command.error.strip_color_codes.strip.length == 0 file.puts " " end end end def log_slowest_commands dir = File.join(Makit::Directories::PROJECT_ARTIFACTS, "commands") FileUtils.mkdir_p(dir) unless Dir.exist?(dir) # open a text file to write to File.open(File.join(dir, "slow.commands.txt"), "w") do |file| Makit::RUNNER.commands.sort_by { |command| (command.duration.seconds + (command.duration.nanos / 1_000_000_000.0)) }.reverse.first(5).each do |command| # Convert to float representation duration_in_float = command.duration.seconds + (command.duration.nanos / 1_000_000_000.0) #puts " #{command.name} took #{duration_in_float} seconds" file.puts " " + Makit::CommandRunner.get_command_summary(command).strip_color_codes + " (#{command.duration.seconds} seconds)" end end end end # class CommandRunner end # module Makit