summaryrefslogtreecommitdiffstats
path: root/lib/puppet/type/exec.rb
blob: daa49e22303f08274f59caff4176c82224357632 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
module Puppet
  newtype(:exec) do
    include Puppet::Util::Execution
    require 'timeout'

    @doc = "Executes external commands.  It is critical that all commands
      executed using this mechanism can be run multiple times without
      harm, i.e., they are *idempotent*.  One useful way to create idempotent
      commands is to use the checks like `creates` to avoid running the
      command unless some condition is met.

      Note that you can restrict an `exec` to only run when it receives
      events by using the `refreshonly` parameter; this is a useful way to
      have your configuration respond to events with arbitrary commands.

      Note also that if an `exec` receives an event from another resource,
      it will get executed again (or execute the command specified in `refresh`, if there is one).

      There is a strong tendency to use `exec` to do whatever work Puppet
      can't already do; while this is obviously acceptable (and unavoidable)
      in the short term, it is highly recommended to migrate work from `exec`
      to native Puppet types as quickly as possible.  If you find that
      you are doing a lot of work with `exec`, please at least notify
      us at Puppet Labs what you are doing, and hopefully we can work with
      you to get a native resource type for the work you are doing."

    require 'open3'

    # Create a new check mechanism.  It's basically just a parameter that
    # provides one extra 'check' method.
    def self.newcheck(name, &block)
      @checks ||= {}

      check = newparam(name, &block)
      @checks[name] = check
    end

    def self.checks
      @checks.keys
    end

    newproperty(:returns, :array_matching => :all, :event => :executed_command) do |property|
      include Puppet::Util::Execution
      munge do |value|
        value.to_s
      end

      def event_name
        :executed_command
      end

      defaultto "0"

      attr_reader :output
      desc "The expected return code(s).  An error will be returned if the
        executed command returns something else.  Defaults to 0. Can be
        specified as an array of acceptable return codes or a single value."

      # Make output a bit prettier
      def change_to_s(currentvalue, newvalue)
        "executed successfully"
      end

      # First verify that all of our checks pass.
      def retrieve
        # Default to somethinng

        if @resource.check
          return :notrun
        else
          return self.should
        end
      end

      # Actually execute the command.
      def sync
        olddir = nil

        # We need a dir to change to, even if it's just the cwd
        dir = self.resource[:cwd] || Dir.pwd

        event = :executed_command
        tries = self.resource[:tries]
        try_sleep = self.resource[:try_sleep]

        begin
          tries.times do |try|
            # Only add debug messages for tries > 1 to reduce log spam.
            debug("Exec try #{try+1}/#{tries}") if tries > 1
            @output, @status = @resource.run(self.resource[:command])
            break if self.should.include?(@status.exitstatus.to_s)
            if try_sleep > 0 and tries > 1
              debug("Sleeping for #{try_sleep} seconds between tries")
              sleep try_sleep
            end
          end
        rescue Timeout::Error
          self.fail "Command exceeded timeout" % value.inspect
        end

        if log = @resource[:logoutput]
          case log
          when :true
            log = @resource[:loglevel]
          when :on_failure
            unless self.should.include?(@status.exitstatus.to_s)
              log = @resource[:loglevel]
            else
              log = :false
            end
          end
          unless log == :false
            @output.split(/\n/).each { |line|
              self.send(log, line)
            }
          end
        end

        unless self.should.include?(@status.exitstatus.to_s)
          self.fail("#{self.resource[:command]} returned #{@status.exitstatus} instead of one of [#{self.should.join(",")}]")
        end

        event
      end
    end

    newparam(:command) do
      isnamevar
      desc "The actual command to execute.  Must either be fully qualified
        or a search path for the command must be provided.  If the command
        succeeds, any output produced will be logged at the instance's
        normal log level (usually `notice`), but if the command fails
        (meaning its return code does not match the specified code) then
        any output is logged at the `err` log level."
    end

    newparam(:path) do
      desc "The search path used for command execution.
        Commands must be fully qualified if no path is specified.  Paths
        can be specified as an array or as a colon-separated list."

      # Support both arrays and colon-separated fields.
      def value=(*values)
        @value = values.flatten.collect { |val|
          if val =~ /;/ # recognize semi-colon separated paths
            val.split(";")
          elsif val =~ /^\w:[^:]*$/ # heuristic to avoid splitting a driveletter away
            val
          else
            val.split(":")
          end
        }.flatten
      end
    end

    newparam(:user) do
      desc "The user to run the command as.  Note that if you
        use this then any error output is not currently captured.  This
        is because of a bug within Ruby.  If you are using Puppet to
        create this user, the exec will automatically require the user,
        as long as it is specified by name."

      # Most validation is handled by the SUIDManager class.
      validate do |user|
        self.fail "Only root can execute commands as other users" unless Puppet.features.root?
      end
    end

    newparam(:group) do
      desc "The group to run the command as.  This seems to work quite
        haphazardly on different platforms -- it is a platform issue
        not a Ruby or Puppet one, since the same variety exists when
        running commnands as different users in the shell."
      # Validation is handled by the SUIDManager class.
    end

    newparam(:cwd) do
      desc "The directory from which to run the command.  If
        this directory does not exist, the command will fail."

      validate do |dir|
        unless dir =~ /^#{File::SEPARATOR}/
          self.fail("CWD must be a fully qualified path")
        end
      end

      munge do |dir|
        dir = dir[0] if dir.is_a?(Array)

        dir
      end
    end

    newparam(:logoutput) do
      desc "Whether to log output.  Defaults to logging output at the
        loglevel for the `exec` resource. Use *on_failure* to only
        log the output when the command reports an error.  Values are
        **true**, *false*, *on_failure*, and any legal log level."

      newvalues(:true, :false, :on_failure)
    end

    newparam(:refresh) do
      desc "How to refresh this command.  By default, the exec is just
        called again when it receives an event from another resource,
        but this parameter allows you to define a different command
        for refreshing."

      validate do |command|
        @resource.validatecmd(command)
      end
    end

    newparam(:env) do
      desc "This parameter is deprecated. Use 'environment' instead."

      munge do |value|
        warning "'env' is deprecated on exec; use 'environment' instead."
        resource[:environment] = value
      end
    end

    newparam(:environment) do
      desc "Any additional environment variables you want to set for a
        command.  Note that if you use this to set PATH, it will override
        the `path` attribute.  Multiple environment variables should be
        specified as an array."

      validate do |values|
        values = [values] unless values.is_a? Array
        values.each do |value|
          unless value =~ /\w+=/
            raise ArgumentError, "Invalid environment setting '#{value}'"
          end
        end
      end
    end

    newparam(:timeout) do
      desc "The maximum time the command should take.  If the command takes
        longer than the timeout, the command is considered to have failed
        and will be stopped.  Use any negative number to disable the timeout.
        The time is specified in seconds."

      munge do |value|
        value = value.shift if value.is_a?(Array)
        if value.is_a?(String)
          unless value =~ /^[-\d.]+$/
            raise ArgumentError, "The timeout must be a number."
          end
          Float(value)
        else
          value
        end
      end

      defaultto 300
    end

    newparam(:tries) do
      desc "The number of times execution of the command should be tried.
        Defaults to '1'. This many attempts will be made to execute
        the command until an acceptable return code is returned.
        Note that the timeout paramater applies to each try rather than
        to the complete set of tries."

      munge do |value|
        if value.is_a?(String)
          unless value =~ /^[\d]+$/
            raise ArgumentError, "Tries must be an integer"
          end
          value = Integer(value)
        end
        raise ArgumentError, "Tries must be an integer >= 1" if value < 1
        value
      end

      defaultto 1
    end

    newparam(:try_sleep) do
      desc "The time to sleep in seconds between 'tries'."

      munge do |value|
        if value.is_a?(String)
          unless value =~ /^[-\d.]+$/
            raise ArgumentError, "try_sleep must be a number"
          end
          value = Float(value)
        end
        raise ArgumentError, "try_sleep cannot be a negative number" if value < 0
        value
      end

      defaultto 0
    end


    newcheck(:refreshonly) do
      desc "The command should only be run as a
        refresh mechanism for when a dependent object is changed.  It only
        makes sense to use this option when this command depends on some
        other object; it is useful for triggering an action:

            # Pull down the main aliases file
            file { \"/etc/aliases\":
              source => \"puppet://server/module/aliases\"
            }

            # Rebuild the database, but only when the file changes
            exec { newaliases:
              path => [\"/usr/bin\", \"/usr/sbin\"],
              subscribe => File[\"/etc/aliases\"],
              refreshonly => true
            }

        Note that only `subscribe` and `notify` can trigger actions, not `require`,
        so it only makes sense to use `refreshonly` with `subscribe` or `notify`."

      newvalues(:true, :false)

      # We always fail this test, because we're only supposed to run
      # on refresh.
      def check(value)
        # We have to invert the values.
        if value == :true
          false
        else
          true
        end
      end
    end

    newcheck(:creates) do
      desc "A file that this command creates.  If this
        parameter is provided, then the command will only be run
        if the specified file does not exist:

            exec { \"tar xf /my/tar/file.tar\":
              cwd => \"/var/tmp\",
              creates => \"/var/tmp/myfile\",
              path => [\"/usr/bin\", \"/usr/sbin\"]
            }

        "

      # FIXME if they try to set this and fail, then we should probably
      # fail the entire exec, right?
      validate do |files|
        files = [files] unless files.is_a? Array

        files.each do |file|
          self.fail("'creates' must be set to a fully qualified path") unless file

          unless file =~ %r{^#{File::SEPARATOR}}
            self.fail "'creates' files must be fully qualified."
          end
        end
      end

      # If the file exists, return false (i.e., don't run the command),
      # else return true
      def check(value)
        ! FileTest.exists?(value)
      end
    end

    newcheck(:unless) do
      desc "If this parameter is set, then this `exec` will run unless
        the command returns 0.  For example:

            exec { \"/bin/echo root >> /usr/lib/cron/cron.allow\":
              path => \"/usr/bin:/usr/sbin:/bin\",
              unless => \"grep root /usr/lib/cron/cron.allow 2>/dev/null\"
            }

        This would add `root` to the cron.allow file (on Solaris) unless
        `grep` determines it's already there.

        Note that this command follows the same rules as the main command,
        which is to say that it must be fully qualified if the path is not set.
        "

      validate do |cmds|
        cmds = [cmds] unless cmds.is_a? Array

        cmds.each do |cmd|
          @resource.validatecmd(cmd)
        end
      end

      # Return true if the command does not return 0.
      def check(value)
        begin
          output, status = @resource.run(value, true)
        rescue Timeout::Error
          err "Check #{value.inspect} exceeded timeout"
          return false
        end

        status.exitstatus != 0
      end
    end

    newcheck(:onlyif) do
      desc "If this parameter is set, then this `exec` will only run if
        the command returns 0.  For example:

            exec { \"logrotate\":
              path => \"/usr/bin:/usr/sbin:/bin\",
              onlyif => \"test `du /var/log/messages | cut -f1` -gt 100000\"
            }

        This would run `logrotate` only if that test returned true.

        Note that this command follows the same rules as the main command,
        which is to say that it must be fully qualified if the path is not set.

        Also note that onlyif can take an array as its value, e.g.:

            onlyif => [\"test -f /tmp/file1\", \"test -f /tmp/file2\"]

        This will only run the exec if /all/ conditions in the array return true.
        "

      validate do |cmds|
        cmds = [cmds] unless cmds.is_a? Array

        cmds.each do |cmd|
          @resource.validatecmd(cmd)
        end
      end

      # Return true if the command returns 0.
      def check(value)
        begin
          output, status = @resource.run(value, true)
        rescue Timeout::Error
          err "Check #{value.inspect} exceeded timeout"
          return false
        end

        status.exitstatus == 0
      end
    end

    # Exec names are not isomorphic with the objects.
    @isomorphic = false

    validate do
      validatecmd(self[:command])
    end

    # FIXME exec should autorequire any exec that 'creates' our cwd
    autorequire(:file) do
      reqs = []

      # Stick the cwd in there if we have it
      reqs << self[:cwd] if self[:cwd]

      self[:command].scan(/^(#{File::SEPARATOR}\S+)/) { |str|
        reqs << str
      }

      self[:command].scan(/^"([^"]+)"/) { |str|
        reqs << str
      }

      [:onlyif, :unless].each { |param|
        next unless tmp = self[param]

        tmp = [tmp] unless tmp.is_a? Array

        tmp.each do |line|
          # And search the command line for files, adding any we
          # find.  This will also catch the command itself if it's
          # fully qualified.  It might not be a bad idea to add
          # unqualified files, but, well, that's a bit more annoying
          # to do.
          reqs += line.scan(%r{(#{File::SEPARATOR}\S+)})
        end
      }

      # For some reason, the += isn't causing a flattening
      reqs.flatten!

      reqs
    end

    autorequire(:user) do
      # Autorequire users if they are specified by name
      if user = self[:user] and user !~ /^\d+$/
        user
      end
    end

    def self.instances
      []
    end

    # Verify that we pass all of the checks.  The argument determines whether
    # we skip the :refreshonly check, which is necessary because we now check
    # within refresh
    def check(refreshing = false)
      self.class.checks.each { |check|
        next if refreshing and check == :refreshonly
        if @parameters.include?(check)
          val = @parameters[check].value
          val = [val] unless val.is_a? Array
          val.each do |value|
            return false unless @parameters[check].check(value)
          end
        end
      }

      true
    end

    # Verify that we have the executable
    def checkexe(cmd)
      exe = extractexe(cmd)

      if self[:path]
        if Puppet.features.posix? and !File.exists?(exe)
          withenv :PATH => self[:path].join(File::PATH_SEPARATOR) do
            exe = which(exe) || raise(ArgumentError,"Could not find command '#{exe}'")
          end
        elsif Puppet.features.microsoft_windows? and !File.exists?(exe)
          self[:path].each do |path|
            [".exe", ".ps1", ".bat", ".com", ""].each do |extension|
              file = File.join(path, exe+extension)
              return if File.exists?(file)
            end
          end
        end
      end

      raise ArgumentError, "Could not find executable '#{exe}'" unless FileTest.exists?(exe)
      unless FileTest.executable?(exe)
        raise ArgumentError,
          "'#{exe}' is not executable"
      end
    end

    def output
      if self.property(:returns).nil?
        return nil
      else
        return self.property(:returns).output
      end
    end

    # Run the command, or optionally run a separately-specified command.
    def refresh
      if self.check(true)
        if cmd = self[:refresh]
          self.run(cmd)
        else
          self.property(:returns).sync
        end
      end
    end

    # Run a command.
    def run(command, check = false)
      output = nil
      status = nil

      dir = nil

      checkexe(command)

      if dir = self[:cwd]
        unless File.directory?(dir)
          if check
            dir = nil
          else
            self.fail "Working directory '#{dir}' does not exist"
          end
        end
      end

      dir ||= Dir.pwd

      if check
        debug "Executing check '#{command}'"
      else
        debug "Executing '#{command}'"
      end
      begin
        # Do our chdir
        Dir.chdir(dir) do
          environment = {}

          environment[:PATH] = self[:path].join(":") if self[:path]

          if envlist = self[:environment]
            envlist = [envlist] unless envlist.is_a? Array
            envlist.each do |setting|
              if setting =~ /^(\w+)=((.|\n)+)$/
                name = $1
                value = $2
                if environment.include? name
                  warning(
                  "Overriding environment setting '#{name}' with '#{value}'"
                  )
                end
                environment[name] = value
              else
                warning "Cannot understand environment setting #{setting.inspect}"
              end
            end
          end

          withenv environment do
            Timeout::timeout(self[:timeout]) do
              output, status = Puppet::Util::SUIDManager.run_and_capture(
                [command], self[:user], self[:group]
              )
            end
            # The shell returns 127 if the command is missing.
            if status.exitstatus == 127
              raise ArgumentError, output
            end
          end
        end
      rescue Errno::ENOENT => detail
        self.fail detail.to_s
      end

      return output, status
    end

    def validatecmd(cmd)
      exe = extractexe(cmd)
      # if we're not fully qualified, require a path
      self.fail "'#{cmd}' is not qualified and no path was specified. Please qualify the command or specify a path." if File.expand_path(exe) != exe and self[:path].nil?
    end

    def extractexe(cmd)
      # easy case: command was quoted
      if cmd =~ /^"([^"]+)"/
        $1
      else
        cmd.split(/ /)[0]
      end
    end
  end
end