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