X-Git-Url: https://review.fuel-infra.org/gitweb?a=blobdiff_plain;f=lib%2Fmcollective%2Futil.rb;fp=lib%2Fmcollective%2Futil.rb;h=9095cc330bf0369d9bb769c2a6ec7c8d028fd6af;hb=b87d2f4e68281062df1913440ca5753ae63314a9;hp=0000000000000000000000000000000000000000;hpb=ab0ea530b8ac956091f17b104ab2311336cfc250;p=packages%2Fprecise%2Fmcollective.git diff --git a/lib/mcollective/util.rb b/lib/mcollective/util.rb new file mode 100644 index 0000000..9095cc3 --- /dev/null +++ b/lib/mcollective/util.rb @@ -0,0 +1,466 @@ +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