1require File.expand_path(File.join(File.dirname(__FILE__),
2                                   'puppet_server_address_validation.rb'))
3require File.expand_path(File.join(File.dirname(__FILE__),
4                                   'puppet_agent_mgr', 'mgr_v2.rb'))
5require File.expand_path(File.join(File.dirname(__FILE__),
6                                   'puppet_agent_mgr', 'mgr_v3.rb'))
7require File.expand_path(File.join(File.dirname(__FILE__),
8                                   'puppet_agent_mgr', 'mgr_windows.rb'))
9
10
11module MCollective
12  module Util
13
14    #
15    ###   Manager parent class
16    #
17
18    class PuppetAgentMgr
19
20      class NotImplementedError < StandardError
21      end
22
23      # manager cache
24      @@the_manager = nil
25
26      # returns the manager instance
27      def self.manager(configfile   = nil,
28                       service_name = 'puppet',
29                       do_init      = false,
30                       testing      = false)
31        # we want a new instance for each spec
32        if testing || do_init || !@@the_manager
33          # TODO: get rid of the conditional require 'puppet'
34          # we should get puppet if not testing
35          require 'puppet' if not testing
36          Log.debug("Creating a new instance of puppet agent manager: " \
37                    "config file = %s, service name = %s, testing = %s" \
38                    % [configfile, service_name, testing]) unless testing
39          @@the_manager = from_version(configfile, service_name, testing)
40        end
41        @@the_manager
42      end
43
44      ###   manager factory (class method)
45
46      class << self
47        # NB: initilize() must be implemented by subclasses
48        def from_version(configfile, service_name, testing)
49          if Puppet.version =~ /^(\d+)/
50            case $1
51              when "2"
52                raise "Window is not supported yet" if MCollective::Util.windows?
53                return MgrV2.new(configfile, service_name, testing)
54              when "3", "4", "5", "6"
55                if MCollective::Util.windows?
56                  return MgrWindows.new(configfile, service_name, testing)
57                else
58                  return MgrV3.new(configfile, service_name, testing)
59                end
60              else
61                raise "Cannot manage Puppet version %s" % $1
62            end
63          else
64            raise "Cannot determine the Puppet major version"
65          end
66        end
67      end
68
69      ###   utility methods
70
71      # all the managed resources
72      def managed_resources
73        # need to add some caching here based on mtime of the resources file
74        return [] unless File.exist?(Puppet[:resourcefile])
75        File.readlines(Puppet[:resourcefile]).map do |resource|
76          resource.chomp
77        end
78      end
79
80      # loads the summary file and ensures that some keys are always present
81      def load_summary
82        summary = {"changes" => {},
83                   "time" => {},
84                   "resources" => {},
85                   "version" => {},
86                   "events" => {}}
87        if File.exist?(Puppet[:lastrunfile])
88          summary.merge!(YAML.load_file(Puppet[:lastrunfile]))
89        end
90        summary["resources"] = \
91          {"failed" => 0,
92           "changed" => 0,
93           "corrective_change" => 0,
94           "total" => 0,
95           "restarted" => 0,
96           "out_of_sync" => 0}.merge!(summary["resources"])
97        summary
98      end
99
100      # epoch time when the last catalog was applied
101      def lastrun
102        summary = load_summary
103        summary_time = summary["time"].fetch("last_run", 0)
104        begin
105          return Integer(summary_time)
106        rescue => e
107          Log.warn("Couldn't parse %s (time from summary file); " \
108                   "returning 0" % summary_time)
109          0
110        end
111      end
112
113      # how mnay of each type of resource is the node
114      def managed_resource_type_distribution
115        type_distribution = {}
116        if File.exist?(Puppet[:resourcefile])
117          File.readlines(Puppet[:resourcefile]).each do |line|
118            type = line.split("[").first.split("::").map {
119                      |i| i.capitalize}.join("::")
120            type_distribution[type] ||= 0
121            type_distribution[type] += 1
122          end
123        end
124        type_distribution
125      end
126
127      # Reads the last run report and extracts the log lines
128      #
129      # @return [Array<Hash>]
130      def last_run_logs
131        return [] unless File.exists?(Puppet[:lastrunreport])
132
133        report = YAML.load_file(Puppet[:lastrunreport])
134
135        report.logs.map do |line|
136          {
137            "time_utc" => line.time.utc.to_i,
138            "time" => line.time.to_i,
139            "level" => line.level.to_s,
140            "source" => line.source,
141            "msg" => line.message.chomp
142          }
143        end
144      end
145
146      # covert seconds to human readable string
147      def seconds_to_human(seconds)
148        days = seconds / 86400
149        seconds -= 86400 * days
150
151        hours = seconds / 3600
152        seconds -= 3600 * hours
153
154        minutes = seconds / 60
155        seconds -= 60 * minutes
156
157        if days > 1
158          return "%d days %d hours %d minutes %02d seconds" % [
159                    days, hours, minutes, seconds]
160        elsif days == 1
161          return "%d day %d hours %d minutes %02d seconds" % [
162                    days, hours, minutes, seconds]
163        elsif hours > 0
164          return "%d hours %d minutes %02d seconds" % [hours, minutes, seconds]
165        elsif minutes > 0
166          return "%d minutes %02d seconds" % [minutes, seconds]
167        else
168          return "%02d seconds" % seconds
169        end
170      end
171
172      # simple utility to return a hash with lots of useful information
173      # about the state of the agent
174      def status
175        the_last_run = lastrun
176        status = {:applying => applying?,
177                  :enabled => enabled?,
178                  :daemon_present => daemon_present?,
179                  :lastrun => the_last_run,
180                  :idling => idling?,
181                  :disable_message => lock_message,
182                  :since_lastrun => (Time.now.to_i - the_last_run)}
183
184        if !status[:enabled]
185          status[:status] = "disabled"
186        elsif status[:applying]
187          status[:status] = "applying a catalog"
188        elsif status[:idling]
189          status[:status] = "idling"
190        elsif !status[:applying]
191          status[:status] = "stopped"
192        end
193
194        status[:message] = "Currently %s; last completed run %s ago" \
195          % [status[:status], seconds_to_human(status[:since_lastrun])]
196        status
197      end
198
199      # returns true if name is a single letter or an alphanumeric string
200      def valid_name?(name)
201        if name.length == 1
202          return false unless name =~ /\A[a-zA-Z]\Z/
203        else
204          return false unless name =~ /\A[a-zA-Z0-9_]+\Z/
205        end
206        true
207      end
208
209      # validates arguments and returns the CL options to execute puppet
210      def create_common_puppet_cli(noop=nil, tags=[], environment=nil,
211                                   server=nil, splay=nil, splaylimit=nil,
212                                   ignoreschedules=nil, use_cached_catalog=nil)
213        opts = []
214        tags = [tags].flatten.compact
215
216        MCollective::Util::PuppetServerAddressValidation.validate_server(server)
217        hostname, port = \
218          MCollective::Util::PuppetServerAddressValidation.parse_name_and_port_of(server)
219
220        if environment && !valid_name?(environment)
221          raise("Invalid environment '%s' specified" % environment)
222        end
223
224        if splaylimit && !splaylimit.is_a?(Integer)
225          raise("Invalid splaylimit '%s' specified" % splaylimit)
226        end
227
228        unless tags.empty?
229          [tags].flatten.each do |tag|
230            tag.split("::").each do |part|
231              raise("Invalid tag '%s' specified" % tag) unless valid_name?(part)
232            end
233          end
234          opts << "--tags %s" % tags.join(",")
235        end
236
237        opts << "--splay" if splay == true
238        opts << "--no-splay" if splay == false
239        opts << "--splaylimit %s" % splaylimit if splaylimit
240        opts << "--noop" if noop == true
241        opts << "--no-noop" if noop == false
242        opts << "--environment %s" % environment if environment
243        opts << "--server %s" % hostname if hostname
244        opts << "--masterport %s" % port if port
245        opts << "--ignoreschedules" if ignoreschedules
246        opts << "--use_cached_catalog" if use_cached_catalog == true
247        opts << "--no-use_cached_catalog" if use_cached_catalog == false
248        opts
249      end
250
251      def run_in_foreground(clioptions, execute=true)
252        options = ["--onetime", "--no-daemonize", "--color=false",
253                   "--show_diff", "--verbose"].concat(clioptions)
254        return options unless execute
255        %x[puppet agent #{options.join(' ')}]
256      end
257
258      def run_in_background(clioptions, execute=true)
259        options =["--onetime",
260                  "--daemonize", "--color=false"].concat(clioptions)
261        return options unless execute
262        %x[puppet agent #{options.join(' ')}]
263      end
264
265      # do a run based on the following options:
266      #
267      # :foreground_run  - runs in the foreground a --test run
268      # :foreground_run  - runs in the foreground a --onetime --no-daemonize
269      #                      --show_diff --verbose run
270      # :signal_daemon   - if the daemon is running, sends it USR1 to wake it up
271      # :noop            - enables or disabled noop mode based on true/false
272      # :tags            - an array of tags to limit the run to
273      # :environment     - the environment to run
274      # :server          - puppet master to use, can be some.host or some.host:port
275      # :splay           - enables or disables splay based on true/false
276      # :splaylimit      - set the maximum splay time
277      # :ignoreschedules - instructs puppet to ignore any defined schedules
278      #
279      # else a single background run will be attempted but this will fail if
280      # an idling daemon is present and :signal_daemon was false
281      def runonce!(options={})
282        valid_options = [:noop, :signal_daemon, :foreground_run, :tags,
283                         :environment, :server, :splay, :splaylimit,
284                         :options_only, :ignoreschedules, :use_cached_catalog]
285
286        options.keys.each do |opt|
287          unless valid_options.include?(opt)
288            raise("Unknown option %s specified" % opt)
289          end
290        end
291
292        if applying?
293          raise "Puppet is currently applying a catalog, cannot run now"
294        end
295
296        if disabled?
297          raise "Puppet is disabled, cannot run now"
298        end
299
300        splay           = options.fetch(:splay, nil)
301        splaylimit      = options.fetch(:splaylimit, nil)
302        noop            = options.fetch(:noop, nil)
303        signal_daemon   = options.fetch(:signal_daemon,
304                                        Util.str_to_bool(Config.instance.pluginconf.fetch("puppet.signal_daemon", "true")))
305        foreground_run  = options.fetch(:foreground_run, false)
306        environment     = options.fetch(:environment, nil)
307        server          = options.fetch(:server, nil)
308        ignoreschedules = options.fetch(:ignoreschedules, nil)
309        use_cached_catalog = options.fetch(:use_cached_catalog, nil)
310        tags            = [ options[:tags] ].flatten.compact
311
312        clioptions = create_common_puppet_cli(noop, tags, environment,
313                                              server, splay, splaylimit,
314                                              ignoreschedules, use_cached_catalog)
315
316        if idling? && signal_daemon && !clioptions.empty?
317          raise "Cannot specify any custom puppet options " \
318                "when the daemon is running"
319        end
320
321        if foreground_run
322          if options[:options_only]
323            return :foreground_run, run_in_foreground(clioptions, false)
324          end
325          return run_in_foreground(clioptions)
326        elsif idling? && signal_daemon
327          return :signal_running_daemon, clioptions if options[:options_only]
328          return signal_running_daemon
329        else
330          raise "Cannot run when the agent is running" if applying?
331          return :run_in_foreground, run_in_foreground(clioptions, false)
332        end
333      end
334
335      def atomic_file(file)
336        tempfile = Tempfile.new(File.basename(file), File.dirname(file))
337        yield(tempfile)
338        tempfile.close
339        File.rename(tempfile.path, file)
340      end
341
342      ###   Unix methods (Windows manager subclass must override)
343
344      # is the agent daemon currently in the unix process list?
345      def daemon_present?
346        if File.exist?(Puppet[:pidfile])
347          return has_process_for_pid?(File.read(Puppet[:pidfile]))
348        end
349        false
350      end
351
352      # is the agent currently applying a catalog
353      def applying?
354        begin
355          platform_applying?
356        rescue NotImplementedError
357          raise
358        rescue => e
359          Log.warn("Could not determine if Puppet is applying a catalog: " \
360                   "%s: %s: %s" % [e.backtrace.first, e.class, e.to_s])
361          false
362        end
363      end
364
365      def signal_running_daemon
366        pid = File.read(Puppet[:pidfile])
367
368        if has_process_for_pid?(pid)
369          begin
370            ::Process.kill("USR1", Integer(pid))
371          rescue Exception => e
372            raise "Failed to signal the puppet agent at pid " \
373                  "%s: %s" % [pid, e.to_s]
374          end
375        else
376          run_in_background
377        end
378      end
379
380      def has_process_for_pid?(pid)
381        !!::Process.kill(0, Integer(pid)) rescue false
382      end
383
384      ###   state of the world
385
386      # is a catalog being applied rigt now?
387      def stopped?
388        !applying?
389      end
390
391      # is the daemon running but not applying a catalog
392      def idling?
393        daemon_present? && !applying?
394      end
395
396      # is the agent enabled
397      def enabled?
398        !disabled?
399      end
400
401      # is a background run allowed? by default it's only allowed if the
402      # daemon isn't present but can be overridden
403      def background_run_allowed?
404        !daemon_present?
405      end
406
407
408      # seconds since the last catalog was applied
409      def since_lastrun
410        (Time.now - lastrun).to_i
411      end
412
413      # if a resource is being managed, resource in the syntax File[/x] etc
414      def managing_resource?(resource)
415        if resource =~ /^(.+?)\[(.+)$/
416          managed_resources.include?([$1.downcase, $2].join("["))
417        else
418          raise "Invalid resource name %s" % resource
419        end
420      end
421
422      # how many resources are managed
423      def managed_resources_count
424        managed_resources.size
425      end
426
427      ###   methods that must be implemented by subclasses
428
429      def initialize(*args)
430        raise NotImplementedError, "Must use PuppetAgentMgr.manager"
431      end
432
433      # enables the puppet agent, it can now start applying catalogs again
434      def enable!
435        raise NotImplementedError, "Not implemented by subclass"
436      end
437
438      # disable the puppet agent, on version 2.x the message is ignored
439      def disable!(msg=nil)
440        raise NotImplementedError, "Not implemented by subclass"
441      end
442
443      # the current lock message, always "" on 2.0
444      def lock_message
445        raise NotImplementedError, "Not implemented by subclass"
446      end
447
448      # is the agent disabled
449      def disabled?
450        raise NotImplementedError, "Not implemented by subclass"
451      end
452
453      ###   private methods
454
455      private
456
457      def platform_applying?
458        raise NotImplementedError, "Not implemented by subclass"
459      end
460
461    end
462  end
463end
464