module MCollective # Some basic utility helper methods useful to clients, agents, runner etc. module Util # Finds out if this MCollective has an agent by the name passed # # If the passed name starts with a / it's assumed to be regex # and will use regex to match def self.has_agent?(agent) agent = Regexp.new(agent.gsub("\/", "")) if agent.match("^/") if agent.is_a?(Regexp) if Agents.agentlist.grep(agent).size > 0 return true else return false end else return Agents.agentlist.include?(agent) end false end # On windows ^c can't interrupt the VM if its blocking on # IO, so this sets up a dummy thread that sleeps and this # will have the end result of being interruptable at least # once a second. This is a common pattern found in Rails etc def self.setup_windows_sleeper Thread.new { loop { sleep 1 } } if Util.windows? end # Checks if this node has a configuration management class by parsing the # a text file with just a list of classes, recipes, roles etc. This is # ala the classes.txt from puppet. # # If the passed name starts with a / it's assumed to be regex # and will use regex to match def self.has_cf_class?(klass) klass = Regexp.new(klass.gsub("\/", "")) if klass.match("^/") cfile = Config.instance.classesfile Log.debug("Looking for configuration management classes in #{cfile}") begin File.readlines(cfile).each do |k| if klass.is_a?(Regexp) return true if k.chomp.match(klass) else return true if k.chomp == klass end end rescue Exception => e Log.warn("Parsing classes file '#{cfile}' failed: #{e.class}: #{e}") end false end # Gets the value of a specific fact, mostly just a duplicate of MCollective::Facts.get_fact # but it kind of goes with the other classes here def self.get_fact(fact) Facts.get_fact(fact) end # Compares fact == value, # # If the passed value starts with a / it's assumed to be regex # and will use regex to match def self.has_fact?(fact, value, operator) Log.debug("Comparing #{fact} #{operator} #{value}") Log.debug("where :fact = '#{fact}', :operator = '#{operator}', :value = '#{value}'") fact = Facts[fact] return false if fact.nil? fact = fact.clone if operator == '=~' # to maintain backward compat we send the value # as /.../ which is what 1.0.x needed. this strips # off the /'s wich is what we need here if value =~ /^\/(.+)\/$/ value = $1 end return true if fact.match(Regexp.new(value)) elsif operator == "==" return true if fact == value elsif ['<=', '>=', '<', '>', '!='].include?(operator) # Yuk - need to type cast, but to_i and to_f are overzealous if value =~ /^[0-9]+$/ && fact =~ /^[0-9]+$/ fact = Integer(fact) value = Integer(value) elsif value =~ /^[0-9]+.[0-9]+$/ && fact =~ /^[0-9]+.[0-9]+$/ fact = Float(fact) value = Float(value) end return true if eval("fact #{operator} value") end false end # Checks if the configured identity matches the one supplied # # If the passed name starts with a / it's assumed to be regex # and will use regex to match def self.has_identity?(identity) identity = Regexp.new(identity.gsub("\/", "")) if identity.match("^/") if identity.is_a?(Regexp) return Config.instance.identity.match(identity) else return true if Config.instance.identity == identity end false end # Checks if the passed in filter is an empty one def self.empty_filter?(filter) filter == empty_filter || filter == {} end # Creates an empty filter def self.empty_filter {"fact" => [], "cf_class" => [], "agent" => [], "identity" => [], "compound" => []} end # Picks a config file defaults to ~/.mcollective # else /etc/mcollective/client.cfg def self.config_file_for_user # expand_path is pretty lame, it relies on HOME environment # which isnt't always there so just handling all exceptions # here as cant find reverting to default begin config = File.expand_path("~/.mcollective") unless File.readable?(config) && File.file?(config) config = "/etc/mcollective/client.cfg" end rescue Exception => e config = "/etc/mcollective/client.cfg" end return config end # Creates a standard options hash def self.default_options {:verbose => false, :disctimeout => nil, :timeout => 5, :config => config_file_for_user, :collective => nil, :discovery_method => nil, :discovery_options => Config.instance.default_discovery_options, :filter => empty_filter} end def self.make_subscriptions(agent, type, collective=nil) config = Config.instance raise("Unknown target type #{type}") unless [:broadcast, :directed, :reply].include?(type) if collective.nil? config.collectives.map do |c| {:agent => agent, :type => type, :collective => c} end else raise("Unknown collective '#{collective}' known collectives are '#{config.collectives.join ', '}'") unless config.collectives.include?(collective) [{:agent => agent, :type => type, :collective => collective}] end end # Helper to subscribe to a topic on multiple collectives or just one def self.subscribe(targets) connection = PluginManager["connector_plugin"] targets = [targets].flatten targets.each do |target| connection.subscribe(target[:agent], target[:type], target[:collective]) end end # Helper to unsubscribe to a topic on multiple collectives or just one def self.unsubscribe(targets) connection = PluginManager["connector_plugin"] targets = [targets].flatten targets.each do |target| connection.unsubscribe(target[:agent], target[:type], target[:collective]) end end # Wrapper around PluginManager.loadclass def self.loadclass(klass) PluginManager.loadclass(klass) end # Parse a fact filter string like foo=bar into the tuple hash thats needed def self.parse_fact_string(fact) if fact =~ /^([^ ]+?)[ ]*=>[ ]*(.+)/ return {:fact => $1, :value => $2, :operator => '>=' } elsif fact =~ /^([^ ]+?)[ ]*=<[ ]*(.+)/ return {:fact => $1, :value => $2, :operator => '<=' } elsif fact =~ /^([^ ]+?)[ ]*(<=|>=|<|>|!=|==|=~)[ ]*(.+)/ return {:fact => $1, :value => $3, :operator => $2 } elsif fact =~ /^(.+?)[ ]*=[ ]*\/(.+)\/$/ return {:fact => $1, :value => "/#{$2}/", :operator => '=~' } elsif fact =~ /^([^= ]+?)[ ]*=[ ]*(.+)/ return {:fact => $1, :value => $2, :operator => '==' } else raise "Could not parse fact #{fact} it does not appear to be in a valid format" end end # Escapes a string so it's safe to use in system() or backticks # # Taken from Shellwords#shellescape since it's only in a few ruby versions def self.shellescape(str) return "''" if str.empty? str = str.dup # Process as a single byte sequence because not all shell # implementations are multibyte aware. str.gsub!(/([^A-Za-z0-9_\-.,:\/@\n])/n, "\\\\\\1") # A LF cannot be escaped with a backslash because a backslash + LF # combo is regarded as line continuation and simply ignored. str.gsub!(/\n/, "'\n'") return str end def self.windows? !!(RbConfig::CONFIG['host_os'] =~ /mswin|win32|dos|mingw|cygwin/i) end # Return color codes, if the config color= option is false # just return a empty string def self.color(code) colorize = Config.instance.color colors = {:red => "", :green => "", :yellow => "", :cyan => "", :bold => "", :reset => ""} if colorize return colors[code] || "" else return "" end end # Helper to return a string in specific color def self.colorize(code, msg) "%s%s%s" % [ color(code), msg, color(:reset) ] end # Returns the current ruby version as per RUBY_VERSION, mostly # doing this here to aid testing def self.ruby_version RUBY_VERSION end def self.mcollective_version MCollective::VERSION end # Returns an aligned_string of text relative to the size of the terminal # window. If a line in the string exceeds the width of the terminal window # the line will be chopped off at the whitespace chacter closest to the # end of the line and prepended to the next line, keeping all indentation. # # The terminal size is detected by default, but custom line widths can # passed. All strings will also be left aligned with 5 whitespace characters # by default. def self.align_text(text, console_cols = nil, preamble = 5) unless console_cols console_cols = terminal_dimensions[0] # if unknown size we default to the typical unix default console_cols = 80 if console_cols == 0 end console_cols -= preamble # Return unaligned text if console window is too small return text if console_cols <= 0 # If console is 0 this implies unknown so we assume the common # minimal unix configuration of 80 characters console_cols = 80 if console_cols <= 0 text = text.split("\n") piece = '' whitespace = 0 text.each_with_index do |line, i| whitespace = 0 while whitespace < line.length && line[whitespace].chr == ' ' whitespace += 1 end # If the current line is empty, indent it so that a snippet # from the previous line is aligned correctly. if line == "" line = (" " * whitespace) end # If text was snipped from the previous line, prepend it to the # current line after any current indentation. if piece != '' # Reset whitespaces to 0 if there are more whitespaces than there are # console columns whitespace = 0 if whitespace >= console_cols # If the current line is empty and being prepended to, create a new # empty line in the text so that formatting is preserved. if text[i + 1] && line == (" " * whitespace) text.insert(i + 1, "") end # Add the snipped text to the current line line.insert(whitespace, "#{piece} ") end piece = '' # Compare the line length to the allowed line length. # If it exceeds it, snip the offending text from the line # and store it so that it can be prepended to the next line. if line.length > (console_cols + preamble) reverse = console_cols while line[reverse].chr != ' ' reverse -= 1 end piece = line.slice!(reverse, (line.length - 1)).lstrip end # If a snippet exists when all the columns in the text have been # updated, create a new line and append the snippet to it, using # the same left alignment as the last line in the text. if piece != '' && text[i+1].nil? text[i+1] = "#{' ' * (whitespace)}#{piece}" piece = '' end # Add the preamble to the line and add it to the text line = ((' ' * preamble) + line) text[i] = line end text.join("\n") end # Figures out the columns and lines of the current tty # # Returns [0, 0] if it can't figure it out or if you're # not running on a tty def self.terminal_dimensions(stdout = STDOUT, environment = ENV) return [0, 0] unless stdout.tty? return [80, 40] if Util.windows? if environment["COLUMNS"] && environment["LINES"] return [environment["COLUMNS"].to_i, environment["LINES"].to_i] elsif environment["TERM"] && command_in_path?("tput") return [`tput cols`.to_i, `tput lines`.to_i] elsif command_in_path?('stty') return `stty size`.scan(/\d+/).map {|s| s.to_i } else return [0, 0] end rescue [0, 0] end # Checks in PATH returns true if the command is found def self.command_in_path?(command) found = ENV["PATH"].split(File::PATH_SEPARATOR).map do |p| File.exist?(File.join(p, command)) end found.include?(true) end # compare two software versions as commonly found in # package versions. # # returns 0 if a == b # returns -1 if a < b # returns 1 if a > b # # Code originally from Puppet but refactored to a more # ruby style that fits in better with this code base def self.versioncmp(version_a, version_b) vre = /[-.]|\d+|[^-.\d]+/ ax = version_a.scan(vre) bx = version_b.scan(vre) until ax.empty? || bx.empty? a = ax.shift b = bx.shift next if a == b next if a == '-' && b == '-' return -1 if a == '-' return 1 if b == '-' next if a == '.' && b == '.' return -1 if a == '.' return 1 if b == '.' if a =~ /^[^0]\d+$/ && b =~ /^[^0]\d+$/ return Integer(a) <=> Integer(b) else return a.upcase <=> b.upcase end end version_a <=> version_b end # we should really use Pathname#absolute? but it's not in all the # ruby versions we support and it comes down to roughly this def self.absolute_path?(path, separator=File::SEPARATOR, alt_separator=File::ALT_SEPARATOR) if alt_separator path_matcher = /^[#{Regexp.quote alt_separator}#{Regexp.quote separator}]/ else path_matcher = /^#{Regexp.quote separator}/ end !!path.match(path_matcher) end # Looks up and interprolate the hash values into a i18n string def self.t(msgid, args={}) if msgid.is_a?(Symbol) I18n.t("%s.pattern" % msgid, args) else I18n.t(msgid, args) end end end end