9095cc330bf0369d9bb769c2a6ec7c8d028fd6af
[packages/precise/mcollective.git] / lib / mcollective / util.rb
1 module MCollective
2   # Some basic utility helper methods useful to clients, agents, runner etc.
3   module Util
4     # Finds out if this MCollective has an agent by the name passed
5     #
6     # If the passed name starts with a / it's assumed to be regex
7     # and will use regex to match
8     def self.has_agent?(agent)
9       agent = Regexp.new(agent.gsub("\/", "")) if agent.match("^/")
10
11       if agent.is_a?(Regexp)
12         if Agents.agentlist.grep(agent).size > 0
13           return true
14         else
15           return false
16         end
17       else
18         return Agents.agentlist.include?(agent)
19       end
20
21       false
22     end
23
24     # On windows ^c can't interrupt the VM if its blocking on
25     # IO, so this sets up a dummy thread that sleeps and this
26     # will have the end result of being interruptable at least
27     # once a second.  This is a common pattern found in Rails etc
28     def self.setup_windows_sleeper
29       Thread.new { loop { sleep 1 } } if Util.windows?
30     end
31
32     # Checks if this node has a configuration management class by parsing the
33     # a text file with just a list of classes, recipes, roles etc.  This is
34     # ala the classes.txt from puppet.
35     #
36     # If the passed name starts with a / it's assumed to be regex
37     # and will use regex to match
38     def self.has_cf_class?(klass)
39       klass = Regexp.new(klass.gsub("\/", "")) if klass.match("^/")
40       cfile = Config.instance.classesfile
41
42       Log.debug("Looking for configuration management classes in #{cfile}")
43
44       begin
45         File.readlines(cfile).each do |k|
46           if klass.is_a?(Regexp)
47             return true if k.chomp.match(klass)
48           else
49             return true if k.chomp == klass
50           end
51         end
52       rescue Exception => e
53         Log.warn("Parsing classes file '#{cfile}' failed: #{e.class}: #{e}")
54       end
55
56       false
57     end
58
59     # Gets the value of a specific fact, mostly just a duplicate of MCollective::Facts.get_fact
60     # but it kind of goes with the other classes here
61     def self.get_fact(fact)
62       Facts.get_fact(fact)
63     end
64
65     # Compares fact == value,
66     #
67     # If the passed value starts with a / it's assumed to be regex
68     # and will use regex to match
69     def self.has_fact?(fact, value, operator)
70
71       Log.debug("Comparing #{fact} #{operator} #{value}")
72       Log.debug("where :fact = '#{fact}', :operator = '#{operator}', :value = '#{value}'")
73
74       fact = Facts[fact]
75       return false if fact.nil?
76
77       fact = fact.clone
78
79       if operator == '=~'
80         # to maintain backward compat we send the value
81         # as /.../ which is what 1.0.x needed.  this strips
82         # off the /'s wich is what we need here
83         if value =~ /^\/(.+)\/$/
84           value = $1
85         end
86
87         return true if fact.match(Regexp.new(value))
88
89       elsif operator == "=="
90         return true if fact == value
91
92       elsif ['<=', '>=', '<', '>', '!='].include?(operator)
93         # Yuk - need to type cast, but to_i and to_f are overzealous
94         if value =~ /^[0-9]+$/ && fact =~ /^[0-9]+$/
95           fact = Integer(fact)
96           value = Integer(value)
97         elsif value =~ /^[0-9]+.[0-9]+$/ && fact =~ /^[0-9]+.[0-9]+$/
98           fact = Float(fact)
99           value = Float(value)
100         end
101
102         return true if eval("fact #{operator} value")
103       end
104
105       false
106     end
107
108     # Checks if the configured identity matches the one supplied
109     #
110     # If the passed name starts with a / it's assumed to be regex
111     # and will use regex to match
112     def self.has_identity?(identity)
113       identity = Regexp.new(identity.gsub("\/", "")) if identity.match("^/")
114
115       if identity.is_a?(Regexp)
116         return Config.instance.identity.match(identity)
117       else
118         return true if Config.instance.identity == identity
119       end
120
121       false
122     end
123
124     # Checks if the passed in filter is an empty one
125     def self.empty_filter?(filter)
126       filter == empty_filter || filter == {}
127     end
128
129     # Creates an empty filter
130     def self.empty_filter
131       {"fact"     => [],
132        "cf_class" => [],
133        "agent"    => [],
134        "identity" => [],
135        "compound" => []}
136     end
137
138     # Picks a config file defaults to ~/.mcollective
139     # else /etc/mcollective/client.cfg
140     def self.config_file_for_user
141       # expand_path is pretty lame, it relies on HOME environment
142       # which isnt't always there so just handling all exceptions
143       # here as cant find reverting to default
144       begin
145         config = File.expand_path("~/.mcollective")
146
147         unless File.readable?(config) && File.file?(config)
148           config = "/etc/mcollective/client.cfg"
149         end
150       rescue Exception => e
151         config = "/etc/mcollective/client.cfg"
152       end
153
154       return config
155     end
156
157     # Creates a standard options hash
158     def self.default_options
159       {:verbose           => false,
160        :disctimeout       => nil,
161        :timeout           => 5,
162        :config            => config_file_for_user,
163        :collective        => nil,
164        :discovery_method  => nil,
165        :discovery_options => Config.instance.default_discovery_options,
166        :filter            => empty_filter}
167     end
168
169     def self.make_subscriptions(agent, type, collective=nil)
170       config = Config.instance
171
172       raise("Unknown target type #{type}") unless [:broadcast, :directed, :reply].include?(type)
173
174       if collective.nil?
175         config.collectives.map do |c|
176           {:agent => agent, :type => type, :collective => c}
177         end
178       else
179         raise("Unknown collective '#{collective}' known collectives are '#{config.collectives.join ', '}'") unless config.collectives.include?(collective)
180
181         [{:agent => agent, :type => type, :collective => collective}]
182       end
183     end
184
185     # Helper to subscribe to a topic on multiple collectives or just one
186     def self.subscribe(targets)
187       connection = PluginManager["connector_plugin"]
188
189       targets = [targets].flatten
190
191       targets.each do |target|
192         connection.subscribe(target[:agent], target[:type], target[:collective])
193       end
194     end
195
196     # Helper to unsubscribe to a topic on multiple collectives or just one
197     def self.unsubscribe(targets)
198       connection = PluginManager["connector_plugin"]
199
200       targets = [targets].flatten
201
202       targets.each do |target|
203         connection.unsubscribe(target[:agent], target[:type], target[:collective])
204       end
205     end
206
207     # Wrapper around PluginManager.loadclass
208     def self.loadclass(klass)
209       PluginManager.loadclass(klass)
210     end
211
212     # Parse a fact filter string like foo=bar into the tuple hash thats needed
213     def self.parse_fact_string(fact)
214       if fact =~ /^([^ ]+?)[ ]*=>[ ]*(.+)/
215         return {:fact => $1, :value => $2, :operator => '>=' }
216       elsif fact =~ /^([^ ]+?)[ ]*=<[ ]*(.+)/
217         return {:fact => $1, :value => $2, :operator => '<=' }
218       elsif fact =~ /^([^ ]+?)[ ]*(<=|>=|<|>|!=|==|=~)[ ]*(.+)/
219         return {:fact => $1, :value => $3, :operator => $2 }
220       elsif fact =~ /^(.+?)[ ]*=[ ]*\/(.+)\/$/
221         return {:fact => $1, :value => "/#{$2}/", :operator => '=~' }
222       elsif fact =~ /^([^= ]+?)[ ]*=[ ]*(.+)/
223         return {:fact => $1, :value => $2, :operator => '==' }
224       else
225         raise "Could not parse fact #{fact} it does not appear to be in a valid format"
226       end
227     end
228
229     # Escapes a string so it's safe to use in system() or backticks
230     #
231     # Taken from Shellwords#shellescape since it's only in a few ruby versions
232     def self.shellescape(str)
233       return "''" if str.empty?
234
235       str = str.dup
236
237       # Process as a single byte sequence because not all shell
238       # implementations are multibyte aware.
239       str.gsub!(/([^A-Za-z0-9_\-.,:\/@\n])/n, "\\\\\\1")
240
241       # A LF cannot be escaped with a backslash because a backslash + LF
242       # combo is regarded as line continuation and simply ignored.
243       str.gsub!(/\n/, "'\n'")
244
245       return str
246     end
247
248     def self.windows?
249       !!(RbConfig::CONFIG['host_os'] =~ /mswin|win32|dos|mingw|cygwin/i)
250     end
251
252     # Return color codes, if the config color= option is false
253     # just return a empty string
254     def self.color(code)
255       colorize = Config.instance.color
256
257       colors = {:red => "\e[31m",
258                 :green => "\e[32m",
259                 :yellow => "\e[33m",
260                 :cyan => "\e[36m",
261                 :bold => "\e[1m",
262                 :reset => "\e[0m"}
263
264       if colorize
265         return colors[code] || ""
266       else
267         return ""
268       end
269     end
270
271     # Helper to return a string in specific color
272     def self.colorize(code, msg)
273       "%s%s%s" % [ color(code), msg, color(:reset) ]
274     end
275
276     # Returns the current ruby version as per RUBY_VERSION, mostly
277     # doing this here to aid testing
278     def self.ruby_version
279       RUBY_VERSION
280     end
281
282     def self.mcollective_version
283       MCollective::VERSION
284     end
285
286     # Returns an aligned_string of text relative to the size of the terminal
287     # window. If a line in the string exceeds the width of the terminal window
288     # the line will be chopped off at the whitespace chacter closest to the
289     # end of the line and prepended to the next line, keeping all indentation.
290     #
291     # The terminal size is detected by default, but custom line widths can
292     # passed. All strings will also be left aligned with 5 whitespace characters
293     # by default.
294     def self.align_text(text, console_cols = nil, preamble = 5)
295       unless console_cols
296         console_cols = terminal_dimensions[0]
297
298         # if unknown size we default to the typical unix default
299         console_cols = 80 if console_cols == 0
300       end
301
302       console_cols -= preamble
303
304       # Return unaligned text if console window is too small
305       return text if console_cols <= 0
306
307       # If console is 0 this implies unknown so we assume the common
308       # minimal unix configuration of 80 characters
309       console_cols = 80 if console_cols <= 0
310
311       text = text.split("\n")
312       piece = ''
313       whitespace = 0
314
315       text.each_with_index do |line, i|
316         whitespace = 0
317
318         while whitespace < line.length && line[whitespace].chr == ' '
319           whitespace += 1
320         end
321
322         # If the current line is empty, indent it so that a snippet
323         # from the previous line is aligned correctly.
324         if line == ""
325           line = (" " * whitespace)
326         end
327
328         # If text was snipped from the previous line, prepend it to the
329         # current line after any current indentation.
330         if piece != ''
331           # Reset whitespaces to 0 if there are more whitespaces than there are
332           # console columns
333           whitespace = 0 if whitespace >= console_cols
334
335           # If the current line is empty and being prepended to, create a new
336           # empty line in the text so that formatting is preserved.
337           if text[i + 1] && line == (" " * whitespace)
338             text.insert(i + 1, "")
339           end
340
341           # Add the snipped text to the current line
342           line.insert(whitespace, "#{piece} ")
343         end
344
345         piece = ''
346
347         # Compare the line length to the allowed line length.
348         # If it exceeds it, snip the offending text from the line
349         # and store it so that it can be prepended to the next line.
350         if line.length > (console_cols + preamble)
351           reverse = console_cols
352
353           while line[reverse].chr != ' '
354             reverse -= 1
355           end
356
357           piece = line.slice!(reverse, (line.length - 1)).lstrip
358         end
359
360         # If a snippet exists when all the columns in the text have been
361         # updated, create a new line and append the snippet to it, using
362         # the same left alignment as the last line in the text.
363         if piece != '' && text[i+1].nil?
364           text[i+1] = "#{' ' * (whitespace)}#{piece}"
365           piece = ''
366         end
367
368         # Add the preamble to the line and add it to the text
369         line = ((' ' * preamble) + line)
370         text[i] = line
371       end
372
373       text.join("\n")
374     end
375
376     # Figures out the columns and lines of the current tty
377     #
378     # Returns [0, 0] if it can't figure it out or if you're
379     # not running on a tty
380     def self.terminal_dimensions(stdout = STDOUT, environment = ENV)
381       return [0, 0] unless stdout.tty?
382
383       return [80, 40] if Util.windows?
384
385       if environment["COLUMNS"] && environment["LINES"]
386         return [environment["COLUMNS"].to_i, environment["LINES"].to_i]
387
388       elsif environment["TERM"] && command_in_path?("tput")
389         return [`tput cols`.to_i, `tput lines`.to_i]
390
391       elsif command_in_path?('stty')
392         return `stty size`.scan(/\d+/).map {|s| s.to_i }
393       else
394         return [0, 0]
395       end
396     rescue
397       [0, 0]
398     end
399
400     # Checks in PATH returns true if the command is found
401     def self.command_in_path?(command)
402       found = ENV["PATH"].split(File::PATH_SEPARATOR).map do |p|
403         File.exist?(File.join(p, command))
404       end
405
406       found.include?(true)
407     end
408
409     # compare two software versions as commonly found in
410     # package versions.
411     #
412     # returns 0 if a == b
413     # returns -1 if a < b
414     # returns 1 if a > b
415     #
416     # Code originally from Puppet but refactored to a more
417     # ruby style that fits in better with this code base
418     def self.versioncmp(version_a, version_b)
419       vre = /[-.]|\d+|[^-.\d]+/
420       ax = version_a.scan(vre)
421       bx = version_b.scan(vre)
422
423       until ax.empty? || bx.empty?
424         a = ax.shift
425         b = bx.shift
426
427         next      if a == b
428         next      if a == '-' && b == '-'
429         return -1 if a == '-'
430         return 1  if b == '-'
431         next      if a == '.' && b == '.'
432         return -1 if a == '.'
433         return 1  if b == '.'
434
435         if a =~ /^[^0]\d+$/ && b =~ /^[^0]\d+$/
436           return Integer(a) <=> Integer(b)
437         else
438           return a.upcase <=> b.upcase
439         end
440       end
441
442       version_a <=> version_b
443     end
444
445     # we should really use Pathname#absolute? but it's not in all the
446     # ruby versions we support and it comes down to roughly this
447     def self.absolute_path?(path, separator=File::SEPARATOR, alt_separator=File::ALT_SEPARATOR)
448       if alt_separator
449         path_matcher = /^[#{Regexp.quote alt_separator}#{Regexp.quote separator}]/
450       else
451         path_matcher = /^#{Regexp.quote separator}/
452       end
453
454       !!path.match(path_matcher)
455     end
456
457     # Looks up and interprolate the hash values into a i18n string
458     def self.t(msgid, args={})
459       if msgid.is_a?(Symbol)
460         I18n.t("%s.pattern" % msgid, args)
461       else
462         I18n.t(msgid, args)
463       end
464     end
465   end
466 end