aboutsummaryrefslogtreecommitdiff
path: root/lib/puppet/provider/cron/crontab.rb
blob: 12ad7f16a64096ebed279086429ca34eb7f54c93 (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
require_relative 'filetype'
require 'puppet/provider/parsedfile'

Puppet::Type.type(:cron).provide(:crontab, parent: Puppet::Provider::ParsedFile, default_target: ENV['USER'] || 'root', raise_prefetch_errors: true) do
  commands crontab: 'crontab'

  text_line :comment, match: %r{^\s*#}, post_parse: proc { |record|
    record[:name] = Regexp.last_match(1) if record[:line] =~ %r{Puppet Name: (.+)\s*$}
  }

  text_line :blank, match: %r{^\s*$}

  text_line :environment, match: %r{^\s*\w+\s*=}

  def self.filetype
    tabname = case Facter.value(:osfamily)
              when 'Solaris'
                :suntab
              when 'AIX'
                :aixtab
              else
                :crontab
              end

    Puppet::Provider::Cron::FileType.filetype(tabname)
  end

  self::TIME_FIELDS = [:minute, :hour, :monthday, :month, :weekday].freeze

  record_line :crontab,
              fields: ['time', 'command'],
              match: %r{^\s*(@\w+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$},
              absent: '*',
              block_eval: :instance do

    def post_parse(record)
      time = record.delete(:time)
      match = %r{@(\S+)}.match(time)
      if match
        # is there another way to access the constant?
        Puppet::Type::Cron::ProviderCrontab::TIME_FIELDS.each { |f| record[f] = :absent }
        record[:special] = match.captures[0]
        return record
      end

      match = %r{(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)}.match(time)
      if match
        record[:special] = :absent
        Puppet::Type::Cron::ProviderCrontab::TIME_FIELDS.zip(match.captures).each do |field, value|
          record[field] = if value == absent
                            :absent
                          else
                            value.split(',')
                          end
        end

        return record
      end

      raise Puppet::Error, _('Line got parsed as a crontab entry but cannot be handled. Please file a bug with the contents of your crontab')
    end

    def pre_gen(record)
      if record[:special] && record[:special] != :absent
        record[:special] = "@#{record[:special]}"
      end

      Puppet::Type::Cron::ProviderCrontab::TIME_FIELDS.each do |field|
        if (vals = record[field]) && vals.is_a?(Array)
          record[field] = vals.join(',')
        end
      end
      record
    end

    def to_line(record)
      str = ''
      record[:name] = nil if record[:unmanaged]
      str = "# Puppet Name: #{record[:name]}\n" if record[:name]
      if record[:environment] && record[:environment] != :absent
        str += record[:environment].map { |line| "#{line}\n" }.join('')
      end
      fields = if record[:special] && record[:special] != :absent
                 [:special, :command]
               else
                 Puppet::Type::Cron::ProviderCrontab::TIME_FIELDS + [:command]
               end
      str += record.values_at(*fields).map { |field|
        if field.nil? || field == :absent
          absent
        else
          field
        end
      }.join(joiner)
      str
    end
  end

  def create
    if resource.should(:command)
      super
    else
      resource.err _('no command specified, cannot create')
    end
  end

  # Look up a resource with a given name whose user matches a record target
  #
  # @api private
  #
  # @note This overrides the ParsedFile method for finding resources by name,
  #   so that only records for a given user are matched to resources of the
  #   same user so that orphaned records in other crontabs don't get falsely
  #   matched (#2251)
  #
  # @param [Hash<Symbol, Object>] record
  # @param [Array<Puppet::Resource>] resources
  #
  # @return [Puppet::Resource, nil] The resource if found, else nil
  def self.resource_for_record(record, resources)
    resource = super

    target = resource[:target] || resource[:user] if resource
    return resource if record[:target] == target
  end

  # Return the header placed at the top of each generated file, warning
  # users that modifying this file manually is probably a bad idea.
  def self.header
    %(# HEADER: This file was autogenerated at #{Time.now} by puppet.
# HEADER: While it can still be managed manually, it is definitely not recommended.
# HEADER: Note particularly that the comments starting with 'Puppet Name' should
# HEADER: not be deleted, as doing so could cause duplicate cron jobs.\n)
  end

  # Regex for finding one vixie cron header.
  def self.native_header_regex
    %r{# DO NOT EDIT THIS FILE.*?Cron version.*?vixie.*?\n}m
  end

  # If a vixie cron header is found, it should be dropped, cron will insert
  # a new one in any case, so we need to avoid duplicates.
  def self.drop_native_header
    true
  end

  # See if we can match the record against an existing cron job.
  def self.match(record, resources)
    # if the record is named, do not even bother (#19876)
    # except the resource name was implicitly generated (#3220)
    return false if record[:name] && !record[:unmanaged]
    resources.each do |_name, resource|
      # Match the command first, since it's the most important one.
      next unless record[:target] == resource[:target]
      next unless record[:command] == resource.value(:command)

      # Now check the time fields
      compare_fields = self::TIME_FIELDS + [:special]

      matched = true
      compare_fields.each do |field|
        # If the resource does not manage a property (say monthday) it should
        # always match. If it is the other way around (e.g. resource defines
        # a should value for :special but the record does not have it, we do
        # not match
        next unless resource[field]
        unless record.include?(field)
          matched = false
          break
        end

        if (record_value = record[field]) && (resource_value = resource.value(field))
          # The record translates '*' into absent in the post_parse hook and
          # the resource type does exactly the opposite (alias :absent to *)
          next if resource_value == '*' && record_value == :absent
          next if resource_value == record_value
        end
        matched = false
        break
      end
      return resource if matched
    end
    false
  end

  @name_index = 0

  # Collapse name and env records.
  def self.prefetch_hook(records)
    name = nil
    envs = nil
    result = []
    records.each do |record|
      case record[:record_type]
      when :comment
        if record[:name]
          name = record[:name]
          record[:skip] = true

          # Start collecting env values
          envs = []
        end
      when :environment
        # If we're collecting env values (meaning we're in a named cronjob),
        # store the line and skip the record.
        if envs
          envs << record[:line]
          record[:skip] = true
        end
      when :blank
        # nothing
      else
        if name
          record[:name] = name
          name = nil
        else
          cmd_string = record[:command].gsub(%r{\s+}, '_')
          index = (@name_index += 1)
          record[:name] = "unmanaged:#{cmd_string}-#{index}"
          record[:unmanaged] = true
        end
        if envs.nil? || envs.empty?
          record[:environment] = :absent
        else
          # Collect all of the environment lines, and mark the records to be skipped,
          # since their data is included in our crontab record.
          record[:environment] = envs

          # And turn off env collection again
          envs = nil
        end
      end
      result << record unless record[:skip]
    end
    result
  end

  def self.to_file(records)
    text = super
    # Apparently Freebsd will "helpfully" add a new TZ line to every
    # single cron line, but not in all cases (e.g., it doesn't do it
    # on my machine).  This is my attempt to fix it so the TZ lines don't
    # multiply.
    if text =~ %r{(^TZ=.+\n)}
      tz = Regexp.last_match(1)
      text.sub!(tz, '')
      text = tz + text
    end
    text
  end

  def user=(user)
    # we have to mark the target as modified first, to make sure that if
    # we move a cronjob from userA to userB, userA's crontab will also
    # be rewritten
    mark_target_modified
    @property_hash[:user] = user
    @property_hash[:target] = user
  end

  def user
    @property_hash[:user] || @property_hash[:target]
  end

  CRONTAB_DIR = case Facter.value('osfamily')
                when 'Debian', 'HP-UX', 'Solaris'
                  '/var/spool/cron/crontabs'
                when %r{BSD}
                  '/var/cron/tabs'
                when 'Darwin'
                  '/usr/lib/cron/tabs/'
                else
                  '/var/spool/cron'
                end

  # Return the directory holding crontab files stored on the local system.
  #
  # @api private
  def self.crontab_dir
    CRONTAB_DIR
  end

  # Yield the names of all crontab files stored on the local system.
  #
  # @note Ignores files that are not writable for the puppet process and hidden
  #   files that start with .keep
  #
  # @api private
  def self.enumerate_crontabs
    Puppet.debug "looking for crontabs in #{CRONTAB_DIR}"
    return unless File.readable?(CRONTAB_DIR)
    Dir.foreach(CRONTAB_DIR) do |file|
      path = File.join(CRONTAB_DIR, file)
      # Gentoo creates .keep_PACKAGE-SLOT files to make sure the directory is not
      # removed
      yield(file) if File.file?(path) && File.writable?(path) && !file.start_with?('.keep_')
    end
  end

  # Include all plausible crontab files on the system
  # in the list of targets (#11383 / PUP-1381)
  def self.targets(resources = nil)
    targets = super(resources)
    enumerate_crontabs do |target|
      targets << target
    end
    targets.uniq
  end
end