--- /dev/null
+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 => "\e[31m",
+ :green => "\e[32m",
+ :yellow => "\e[33m",
+ :cyan => "\e[36m",
+ :bold => "\e[1m",
+ :reset => "\e[0m"}
+
+ 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