3
require 'puppet/type/state'
4
require 'puppet/filetype'
5
require 'puppet/type/parsedtype'
8
# Model the actual cron jobs. Supports all of the normal cron job fields
9
# as parameters, with the 'command' as the single state. Also requires a
10
# completely symbolic 'name' paremeter, which gets written to the file
11
# and is used to manage the job.
14
# A stupid hack because Cron is the only parsed type that I haven't
15
# converted to using providers. This whole stupid type needs to
17
class CronHackParam < Puppet::State::ParsedParam
18
# Normally this would retrieve the current value, but our state is not
19
# actually capable of doing so.
21
# If we've synced, then just copy the values over and return.
22
# This allows this state to behave like any other state.
23
if defined? @synced and @synced
24
# by default, we only copy over the first value.
30
unless defined? @is and ! @is.nil?
35
# If the ensure state is out of sync, it will always be called
36
# first, so I don't need to worry about that.
37
def sync(nostore = false)
38
ebase = @parent.class.name.to_s
41
if self.class.name == :ensure
42
# We're either creating or destroying the object
47
# If we're creating it, then sync all of the other states
48
# but tell them not to store (we'll store just once,
51
@parent.eachstate { |state|
52
next if state == self or state.name == :ensure
56
elsif self.should == :absent
61
# We don't do the work here, it gets done in 'store'
66
# This should really only be done once per run, rather than
67
# every time. I guess we need some kind of 'flush' mechanism.
74
return (ebase + "_" + tail).intern
78
# A base class for all of the Cron parameters, since they all have
79
# similar argument checking going on. We're stealing the base class
80
# from parsedtype, and we should probably subclass Cron from there,
81
# but it was just too annoying to do.
82
class CronParam < CronHackParam
84
attr_accessor :boundaries, :default
87
# We have to override the parent method, because we consume the entire
90
if defined? @should and @should
91
self.is_to_s == self.should_to_s
97
# A method used to do parameter input handling. Converts integers
98
# in string form to actual integers, and returns the value if it's
99
# an integer or false if it's just a normal string.
103
elsif num.is_a?(Integer)
110
# Verify that a number is within the specified limits. Return the
111
# number if it is, or false if it is not.
112
def limitcheck(num, lower, upper)
113
if num >= lower and num <= upper
120
# Verify that a value falls within the specified array. Does case
121
# insensitive matching, and supports matching either the entire word
122
# or the first three letters of the word.
123
def alphacheck(value, ary)
126
# If they specified a shortened version of the name, then see
127
# if we can lengthen it (e.g., mon => monday).
129
ary.each_with_index { |name, index|
136
return ary.index(tmp)
145
if self.name == :command or @should[0].is_a? Symbol
157
unless @is.is_a?(Array)
161
if self.name == :command or @is[0].is_a? Symbol
171
# The method that does all of the actual parameter value
172
# checking; called by all of the +param<name>=+ methods.
173
# Requires the value, type, and bounds, and optionally supports
174
# a boolean of whether to do alpha checking, and if so requires
175
# the ary against which to do the checking.
177
# Support 'absent' as a value, so that they can remove
179
if value == "absent" or value == :absent
183
# Allow the */2 syntax
184
if value =~ /^\*\/[0-9]+$/
189
if value =~ /^[0-9]+-[0-9]+$/
197
return value unless self.class.boundaries
198
lower, upper = self.class.boundaries
200
if num = numfix(value)
201
retval = limitcheck(num, lower, upper)
202
elsif respond_to?(:alpha)
203
# If it has an alpha method defined, then we check
204
# to see if our value is in that list and if so we turn
206
retval = alphacheck(value, alpha())
212
self.fail "%s is not a valid %s" %
213
[value, self.class.name]
219
# Override 'newstate' so that all states default to having the
220
# correct parent type
221
def self.newstate(name, options = {}, &block)
222
options[:parent] ||= Puppet::State::CronParam
223
super(name, options, &block)
226
# Somewhat uniquely, this state does not actually change anything -- it
227
# just calls +@parent.sync+, which writes out the whole cron tab for
228
# the user in question. There is no real way to change individual cron
229
# jobs without rewriting the entire cron file.
231
# Note that this means that managing many cron jobs for a given user
232
# could currently result in multiple write sessions for that user.
233
newstate(:command, :parent => CronParam) do
234
desc "The command to execute in the cron job. The environment
235
provided to the command varies by local system rules, and it is
236
best to always provide a fully qualified command. The user's
237
profile is not sourced when the command is run, so if the
238
user's environment is desired it should be sourced manually.
240
All cron parameters support ``absent`` as a value; this will
241
remove any existing values for that field."
245
if @should.is_a? Array
248
devfail "command is not an array"
256
newstate(:special, :parent => CronHackParam) do
257
desc "Special schedules only supported on FreeBSD."
260
%w{reboot yearly annually monthly weekly daily midnight hourly}
264
unless specials().include?(value)
265
raise ArgumentError, "Invalid special schedule %s" %
272
self.boundaries = [0, 59]
273
desc "The minute at which to run the cron job.
274
Optional; if specified, must be between 0 and 59, inclusive."
278
self.boundaries = [0, 23]
279
desc "The hour at which to run the cron job. Optional;
280
if specified, must be between 0 and 23, inclusive."
283
newstate(:weekday) do
285
%w{sunday monday tuesday wednesday thursday friday saturday}
287
self.boundaries = [0, 6]
288
desc "The weekday on which to run the command.
289
Optional; if specified, must be between 0 and 6, inclusive, with
290
0 being Sunday, or must be the name of the day (e.g., Tuesday)."
295
%w{january february march april may june july
296
august september october november december}
298
self.boundaries = [1, 12]
299
desc "The month of the year. Optional; if specified
300
must be between 1 and 12 or the month name (e.g., December)."
303
newstate(:monthday) do
304
self.boundaries = [1, 31]
305
desc "The day of the month on which to run the
306
command. Optional; if specified, must be between 1 and 31."
309
newstate(:environment, :parent => CronHackParam) do
310
desc "Any environment settings associated with this cron job. They
311
will be stored between the header and the job in the crontab. There
312
can be no guarantees that other, earlier settings will not also
313
affect a given cron job.
315
Also, Puppet cannot automatically determine whether an existing,
316
unmanaged environment setting is associated with a given cron
317
job. If you already have cron jobs with environment settings,
318
then Puppet will keep those settings in the same place in the file,
319
but will not associate them with a specific job.
321
Settings should be specified exactly as they should appear in
322
the crontab, e.g., 'PATH=/bin:/usr/bin:/usr/sbin'. Multiple
323
settings should be specified as an array."
326
unless value =~ /^\s*(\w+)\s*=\s*(.+)\s*$/
327
raise ArgumentError, "Invalid environment setting %s" %
334
return @is.sort == @should.sort
336
return @is == @should
346
desc "The symbolic name of the cron job. This name
347
is used for human reference only and is generated automatically
348
for cron jobs found on the system. This generally won't
349
matter, as Puppet will do its best to match existing cron jobs
350
against specified jobs (and Puppet adds a comment to cron jobs it
351
adds), but it is at least possible that converting from
352
unmanaged jobs to managed jobs might require manual
355
The names can only have alphanumeric characters plus the '-'
361
unless value =~ /^[-\w]+$/
362
raise ArgumentError, "Invalid name format '%s'" % value
368
desc "The user to run the command as. This user must
369
be allowed to run cron jobs, which is not currently checked by
372
The user defaults to whomever Puppet is running as."
374
defaultto { ENV["USER"] }
379
# Make sure the user is not an array
380
if @value.is_a? Array
386
@doc = "Installs and manages cron jobs. All fields except the command
387
and the user are optional, although specifying no periodic
388
fields would result in the command being executed every
389
minute. While the name of the cron job is not part of the actual
390
job, it is used by Puppet to store and retrieve it.
392
If you specify a cron job that matches an existing job in every way
393
except name, then the jobs will be considered equivalent and the
394
new name will be permanently associated with that job. Once this
395
association is made and synced to disk, you can then manage the job
396
normally (e.g., change the schedule of the job).
401
command => \"/usr/sbin/logrotate\",
3
require 'puppet/util/filetype'
5
Puppet::Type.newtype(:cron) do
6
@doc = "Installs and manages cron jobs. All fields except the command
7
and the user are optional, although specifying no periodic
8
fields would result in the command being executed every
9
minute. While the name of the cron job is not part of the actual
10
job, it is used by Puppet to store and retrieve it.
12
If you specify a cron job that matches an existing job in every way
13
except name, then the jobs will be considered equivalent and the
14
new name will be permanently associated with that job. Once this
15
association is made and synced to disk, you can then manage the job
16
normally (e.g., change the schedule of the job).
21
command => \"/usr/sbin/logrotate\",
27
Note that all cron values can be specified as an array of values::
30
command => \"/usr/sbin/logrotate\",
35
Or using ranges, or the step syntax ``*/2`` (although there's no guarantee that
36
your ``cron`` daemon supports it)::
39
command => \"/usr/sbin/logrotate\",
47
# A base class for all of the Cron parameters, since they all have
48
# similar argument checking going on.
49
class CronParam < Puppet::Property
412
attr_accessor :filetype
425
# In addition to removing the instances in @objects, Cron has to remove
426
# per-user cron tab information.
434
case Facter["operatingsystem"].value
436
return Puppet::FileType.filetype(:suntab)
438
return Puppet::FileType.filetype(:crontab)
442
self.filetype = self.defaulttype()
444
# Override the default Puppet::Type method, because instances
445
# also need to be deleted from the @instances hash
446
def self.delete(child)
447
if @instances.include?(child[:user])
448
if @instances[child[:user]].include?(child)
449
@instances[child[:user]].delete(child)
455
# Return the fields found in the cron tab.
457
return [:minute, :hour, :monthday, :month, :weekday, :command]
460
# Convert our hash to an object
461
def self.hash2obj(hash)
463
namevar = self.namevar
464
unless hash.include?(namevar) and hash[namevar]
465
Puppet.info "Autogenerating name for %s" % hash[:command]
466
hash[:name] = "autocron-%s" % hash.object_id
469
unless hash.include?(:command)
470
raise Puppet::DevError, "No command for %s" % name
472
# if the cron already exists with that name...
473
if obj = (self[hash[:name]] || match(hash))
474
# Mark the cron job as present
475
obj.is = [:ensure, :present]
477
# Mark all of the values appropriately
478
hash.each { |param, value|
479
if state = obj.state(param)
481
elsif val = obj[param]
484
# There is a value on disk, but it should go away
485
obj.is = [param, value]
490
# create a new cron job, since no existing one
493
:name => hash[namevar]
496
obj.is = [:ensure, :present]
501
hash.each { |param, value|
502
obj.is = [param, value]
509
# Return the header placed at the top of each generated file, warning
510
# users that modifying this file manually is probably a bad idea.
512
%{# HEADER This file was autogenerated at #{Time.now} by puppet. While it
513
# HEADER can still be managed manually, it is definitely not recommended.
514
# HEADER Note particularly that the comments starting with 'Puppet Name' should
515
# HEADER not be deleted, as doing so could cause duplicate cron jobs.\n}
518
def self.instance(obj)
520
unless @instances.include?(user)
521
@instances[user] = []
524
@instances[user] << obj
528
# Look for cron jobs for each user
529
Puppet::Type.type(:user).list_by_name.each { |user|
530
self.retrieve(user, false)
533
self.collect { |c| c }
536
# See if we can match the hash against an existing cron job.
538
self.find_all { |obj|
539
obj[:user] == hash[:user] and obj.value(:command) == hash[:command][0]
541
# we now have a cron job whose command exactly matches
542
# let's see if the other fields match
544
# First check the @special stuff
546
next unless obj.value(:special) == hash[:special]
549
# Then the normal fields.
551
fields().each do |field|
552
next if field == :command
553
if hash[field] and ! obj.value(field)
554
#Puppet.info "Cron is missing %s: %s and %s" %
555
# [field, hash[field].inspect, obj.value(field).inspect]
560
if ! hash[field] and obj.value(field)
561
#Puppet.info "Hash is missing %s: %s and %s" %
562
# [field, obj.value(field).inspect, hash[field].inspect]
567
# FIXME It'd be great if I could somehow reuse how the
568
# fields are turned into text, but....
569
next if (hash[field] == [:absent] and obj.value(field) == "*")
570
next if (hash[field].join(",") == obj.value(field))
571
#Puppet.info "Did not match %s: %s vs %s" %
572
# [field, obj.value(field).inspect, hash[field].inspect]
51
attr_accessor :boundaries, :default
54
# We have to override the parent method, because we consume the entire
57
if defined? @should and @should
58
self.is_to_s(is) == self.should_to_s
64
# A method used to do parameter input handling. Converts integers
65
# in string form to actual integers, and returns the value if it's
66
# an integer or false if it's just a normal string.
70
elsif num.is_a?(Integer)
77
# Verify that a number is within the specified limits. Return the
78
# number if it is, or false if it is not.
79
def limitcheck(num, lower, upper)
80
if num >= lower and num <= upper
87
# Verify that a value falls within the specified array. Does case
88
# insensitive matching, and supports matching either the entire word
89
# or the first three letters of the word.
90
def alphacheck(value, ary)
93
# If they specified a shortened version of the name, then see
94
# if we can lengthen it (e.g., mon => monday).
96
ary.each_with_index { |name, index|
103
return ary.index(tmp)
583
# Parse a user's cron job into individual cron objects.
585
# Autogenerates names for any jobs that don't already have one; these
586
# names will get written back to the file.
588
# This method also stores existing comments, and it stores all cron
589
# jobs in order, mostly so that comments are retained in the order
590
# they were written and in proximity to the same jobs.
591
def self.parse(user, text)
596
text.chomp.split("\n").each { |line|
598
when /^# Puppet Name: (.+)$/
602
# add other comments to the list as they are
603
@instances[user] << line
605
when /^\s*(\w+)\s*=\s*(.+)\s*$/:
606
# Match env settings.
610
@instances[user] << line
613
when /^@(\w+)\s+(.+)/ # FreeBSD special cron crap
614
fields().each do |field|
615
next if field == :command
616
hash[field] = :absent
621
if match = /^(\S+) (\S+) (\S+) (\S+) (\S+) (.+)$/.match(line)
622
fields().zip(match.captures).each { |param, value|
624
hash[param] = [:absent]
627
hash[param] = [value]
629
# We always want the 'is' value to be an
631
hash[param] = value.split(",")
636
# Don't fail on unmatched lines, just warn on them
638
Puppet.warning "Could not match '%s'" % line
644
# We have to dup here so that we don't remove the settings
645
# in @is on the object.
646
hash[:environment] = envs.dup
651
# Now convert our hash to an object.
660
# Retrieve a given user's cron job, using the @filetype's +retrieve+
661
# method. Returns nil if there was no cron job; else, returns the
662
# number of cron instances found.
663
def self.retrieve(user, checkuser = true)
664
# First make sure the user exists, unless told not to
667
Puppet::Util.uid(user)
669
raise Puppet::Error, "User %s not found" % user
673
@tabs[user] ||= @filetype.new(user)
674
text = @tabs[user].read
676
# there is no cron file
679
# Preemptively mark everything absent, so that retrieving it
680
# can mark it present again.
681
self.find_all { |obj|
684
obj.is = [:ensure, :absent]
687
# Get rid of the old instances, so we don't get duplicates
688
if @instances.include?(user)
689
@instances[user].clear
691
@instances[user] = []
694
self.parse(user, text)
698
# Remove a user's cron tab.
699
def self.remove(user)
700
@tabs[user] ||= @filetype.new(user)
704
# Store the user's cron tab. Collects the text of the new tab and
705
# sends it to the +@filetype+ module's +write+ function. Also adds
706
# header, warning users not to modify the file directly.
708
unless @instances.include?(user) or @objects.find do |n,o|
711
Puppet.notice "No cron instances for %s" % user
715
@tabs[user] ||= @filetype.new(user)
718
next unless inst[:user] == user
719
unless (@instances[user] and @instances[user].include? inst)
720
@instances[user] ||= []
721
@instances[user] << inst
724
@tabs[user].write(self.tab(user))
727
# Collect all Cron instances for a given user and convert them
730
Puppet.info "Writing cron tab for %s" % user
731
if @instances.include?(user)
732
tab = @instances[user].reject { |obj|
733
if obj.is_a?(self) and obj.should(:ensure) == :absent
746
# Apparently Freebsd will "helpfully" add a new TZ line to every
747
# single cron line, but not in all cases (e.g., it doesn't do it
748
# on my machine. This is my attempt to fix it so the TZ lines don't
751
return tab.sub(/\n/, "\n" + self.header)
753
return self.header() + tab
757
Puppet.notice "No cron instances for %s" % user
761
# Return the tab object itself. Pretty much just used for testing.
762
def self.tabobj(user)
766
# Return the last time a given user's cron tab was loaded. Could
767
# be used for reducing writes, but currently is not.
768
def self.loaded?(user)
769
if @tabs.include?(user)
770
return @loaded[user].loaded
782
# nothing, since the 'Cron.tab' method just doesn't write out
783
# crons whose 'ensure' states are set to 'absent'.
788
@states.include?(:ensure) and @states[:ensure].is == :present
791
# Override the default Puppet::Type method because we need to call
792
# the +@filetype+ retrieve method.
794
unless @parameters.include?(:user)
795
self.fail "You must specify the cron user"
798
self.class.retrieve(self[:user])
799
if withtab = self.class["testwithtab"]
800
Puppet.info withtab.is(:ensure).inspect
802
self.eachstate { |st|
805
if withtab = self.class["testwithtab"]
806
Puppet.info withtab.is(:ensure).inspect
810
# Write the entire user's cron tab out.
812
self.class.store(self[:user])
815
# Convert the current object a cron-style string. Adds the cron name
816
# as a comment above the cron job, in the form '# Puppet Name: <name>'.
820
# Collect all of the values that we have
821
self.class.fields().each { |param|
822
hash[param] = self.value(param)
825
devfail "Got no value for %s" % param
831
str = "# Puppet Name: %s\n" % self.name
833
if @states.include?(:environment) and
834
@states[:environment].should != :absent
835
envs = @states[:environment].should
836
unless envs.is_a? Array
840
envs.each do |line| str += (line + "\n") end
844
if special = self.value(:special)
845
line = str + "@%s %s" %
846
[special, self.value(:command)]
848
line = str + self.class.fields.collect { |f|
849
if hash[f] and hash[f] != :absent
861
name = name.intern if name.is_a? String
863
if @states.include?(name)
864
ret = @states[name].should_to_s
867
ret = @states[name].is_to_s
878
devfail "No command, somehow"
882
#ret = (self.class.validstate?(name).default || "*").to_s
110
def should_to_s(newvalue = @should)
112
unless newvalue.is_a?(Array)
113
newvalue = [newvalue]
115
if self.name == :command or newvalue[0].is_a? Symbol
125
def is_to_s(currentvalue = @is)
127
unless currentvalue.is_a?(Array)
131
if self.name == :command or currentvalue[0].is_a? Symbol
134
currentvalue.join(",")
142
if @should and @should[0] == :absent
154
# The method that does all of the actual parameter value
155
# checking; called by all of the +param<name>=+ methods.
156
# Requires the value, type, and bounds, and optionally supports
157
# a boolean of whether to do alpha checking, and if so requires
158
# the ary against which to do the checking.
160
# Support 'absent' as a value, so that they can remove
162
if value == "absent" or value == :absent
166
# Allow the */2 syntax
167
if value =~ /^\*\/[0-9]+$/
172
if value =~ /^[0-9]+-[0-9]+$/
180
return value unless self.class.boundaries
181
lower, upper = self.class.boundaries
183
if num = numfix(value)
184
retval = limitcheck(num, lower, upper)
185
elsif respond_to?(:alpha)
186
# If it has an alpha method defined, then we check
187
# to see if our value is in that list and if so we turn
189
retval = alphacheck(value, alpha())
195
self.fail "%s is not a valid %s" %
196
[value, self.class.name]
201
# Somewhat uniquely, this property does not actually change anything -- it
202
# just calls +@resource.sync+, which writes out the whole cron tab for
203
# the user in question. There is no real way to change individual cron
204
# jobs without rewriting the entire cron file.
206
# Note that this means that managing many cron jobs for a given user
207
# could currently result in multiple write sessions for that user.
208
newproperty(:command, :parent => CronParam) do
209
desc "The command to execute in the cron job. The environment
210
provided to the command varies by local system rules, and it is
211
best to always provide a fully qualified command. The user's
212
profile is not sourced when the command is run, so if the
213
user's environment is desired it should be sourced manually.
215
All cron parameters support ``absent`` as a value; this will
216
remove any existing values for that field."
220
if return_value && return_value.is_a?(Array)
221
return_value = return_value[0]
229
if @should.is_a? Array
232
devfail "command is not an array"
240
newproperty(:special) do
241
desc "Special schedules only supported on FreeBSD."
244
%w{reboot yearly annually monthly weekly daily midnight hourly}
248
unless specials().include?(value)
249
raise ArgumentError, "Invalid special schedule %s" %
255
newproperty(:minute, :parent => CronParam) do
256
self.boundaries = [0, 59]
257
desc "The minute at which to run the cron job.
258
Optional; if specified, must be between 0 and 59, inclusive."
261
newproperty(:hour, :parent => CronParam) do
262
self.boundaries = [0, 23]
263
desc "The hour at which to run the cron job. Optional;
264
if specified, must be between 0 and 23, inclusive."
267
newproperty(:weekday, :parent => CronParam) do
269
%w{sunday monday tuesday wednesday thursday friday saturday}
271
self.boundaries = [0, 6]
272
desc "The weekday on which to run the command.
273
Optional; if specified, must be between 0 and 6, inclusive, with
274
0 being Sunday, or must be the name of the day (e.g., Tuesday)."
277
newproperty(:month, :parent => CronParam) do
279
%w{january february march april may june july
280
august september october november december}
282
self.boundaries = [1, 12]
283
desc "The month of the year. Optional; if specified
284
must be between 1 and 12 or the month name (e.g., December)."
287
newproperty(:monthday, :parent => CronParam) do
288
self.boundaries = [1, 31]
289
desc "The day of the month on which to run the
290
command. Optional; if specified, must be between 1 and 31."
293
newproperty(:environment) do
294
desc "Any environment settings associated with this cron job. They
295
will be stored between the header and the job in the crontab. There
296
can be no guarantees that other, earlier settings will not also
297
affect a given cron job.
300
Also, Puppet cannot automatically determine whether an existing,
301
unmanaged environment setting is associated with a given cron
302
job. If you already have cron jobs with environment settings,
303
then Puppet will keep those settings in the same place in the file,
304
but will not associate them with a specific job.
306
Settings should be specified exactly as they should appear in
307
the crontab, e.g., ``PATH=/bin:/usr/bin:/usr/sbin``."
310
unless value =~ /^\s*(\w+)\s*=\s*(.+)\s*$/ or value == :absent or value == "absent"
311
raise ArgumentError, "Invalid environment setting %s" %
318
return is.sort == @should.sort
324
def is_to_s(newvalue)
326
if newvalue.is_a?(Array)
340
def should_to_s(newvalue = @should)
350
desc "The symbolic name of the cron job. This name
351
is used for human reference only and is generated automatically
352
for cron jobs found on the system. This generally won't
353
matter, as Puppet will do its best to match existing cron jobs
354
against specified jobs (and Puppet adds a comment to cron jobs it
355
adds), but it is at least possible that converting from
356
unmanaged jobs to managed jobs might require manual
362
newproperty(:user) do
363
desc "The user to run the command as. This user must
364
be allowed to run cron jobs, which is not currently checked by
367
The user defaults to whomever Puppet is running as."
369
defaultto { Etc.getpwuid(Process.uid).name || "root" }
372
newproperty(:target) do
373
desc "Where the cron job should be stored. For crontab-style
374
entries this is the same as the user and defaults that way.
375
Other providers default accordingly."
378
if provider.is_a?(@resource.class.provider(:crontab))
379
if val = @resource.should(:user)
383
"You must provide a user with crontab entries"
385
elsif provider.class.ancestors.include?(Puppet::Provider::ParsedFile)
386
provider.class.default_target
393
# We have to reorder things so that :provide is before :target
398
name = symbolize(name)
400
if obj = @parameters[name]
415
devfail "No command, somehow"
419
#ret = (self.class.validproperty?(name).default || "*").to_s
892
# $Id: cron.rb 1807 2006-10-18 04:03:01Z luke $