aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/puppet/provider/cron/crontab.rb298
-rw-r--r--lib/puppet/type/cron.rb469
2 files changed, 767 insertions, 0 deletions
diff --git a/lib/puppet/provider/cron/crontab.rb b/lib/puppet/provider/cron/crontab.rb
new file mode 100644
index 0000000..fec5511
--- /dev/null
+++ b/lib/puppet/provider/cron/crontab.rb
@@ -0,0 +1,298 @@
+require 'puppet/provider/parsedfile'
+
+Puppet::Type.type(:cron).provide(:crontab, parent: Puppet::Provider::ParsedFile, default_target: ENV['USER'] || 'root') 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::Util::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 # rubocop: disable Lint/EmptyWhen
+ # 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'
+ '/var/spool/cron/crontabs'
+ when %r{BSD}
+ '/var/cron/tabs'
+ when 'Darwin'
+ '/usr/lib/cron/tabs/'
+ else
+ '/var/spool/cron'
+ end
+
+ # Yield the names of all crontab files stored on the local system.
+ #
+ # @note Ignores files that are not writable for the puppet process.
+ #
+ # @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 = "#{CRONTAB_DIR}/#{file}"
+ yield(file) if File.file?(path) && File.writable?(path)
+ 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
diff --git a/lib/puppet/type/cron.rb b/lib/puppet/type/cron.rb
new file mode 100644
index 0000000..e07ce70
--- /dev/null
+++ b/lib/puppet/type/cron.rb
@@ -0,0 +1,469 @@
+require 'etc'
+require 'facter'
+require 'puppet/util/filetype'
+
+Puppet::Type.newtype(:cron) do
+ @doc = <<-'EOT'
+ Installs and manages cron jobs. Every cron resource created by Puppet
+ requires a command and at least one periodic attribute (hour, minute,
+ month, monthday, weekday, or special). While the name of the cron job is
+ not part of the actual job, the name is stored in a comment beginning with
+ `# Puppet Name: `. These comments are used to match crontab entries created
+ by Puppet with cron resources.
+
+ If an existing crontab entry happens to match the scheduling and command of a
+ cron resource that has never been synced, Puppet defers to the existing
+ crontab entry and does not create a new entry tagged with the `# Puppet Name: `
+ comment.
+
+ Example:
+
+ cron { 'logrotate':
+ command => '/usr/sbin/logrotate',
+ user => 'root',
+ hour => 2,
+ minute => 0,
+ }
+
+ Note that all periodic attributes can be specified as an array of values:
+
+ cron { 'logrotate':
+ command => '/usr/sbin/logrotate',
+ user => 'root',
+ hour => [2, 4],
+ }
+
+ ...or using ranges or the step syntax `*/2` (although there's no guarantee
+ that your `cron` daemon supports these):
+
+ cron { 'logrotate':
+ command => '/usr/sbin/logrotate',
+ user => 'root',
+ hour => ['2-4'],
+ minute => '*/10',
+ }
+
+ **Important:** _The Cron type will not reset parameters that are
+ removed from a manifest_. For example, removing a `minute => 10` parameter
+ will not reset the minute component of the associated cronjob to `*`.
+ These changes must be expressed by setting the parameter to
+ `minute => absent` because Puppet only manages parameters that are out of
+ sync with manifest entries.
+
+ **Autorequires:** If Puppet is managing the user account specified by the
+ `user` property of a cron resource, then the cron resource will autorequire
+ that user.
+ EOT
+ ensurable
+
+ # A base class for all of the Cron parameters, since they all have
+ # similar argument checking going on.
+ class CronParam < Puppet::Property
+ class << self
+ attr_accessor :boundaries, :default
+ end
+
+ # We have to override the parent method, because we consume the entire
+ # "should" array
+ def insync?(is)
+ is_to_s(is) == should_to_s
+ end
+
+ # A method used to do parameter input handling. Converts integers
+ # in string form to actual integers, and returns the value if it's
+ # an integer or false if it's just a normal string.
+ def numfix(num)
+ if num =~ %r{^\d+$}
+ num.to_i
+ elsif num.is_a?(Integer)
+ num
+ else
+ false
+ end
+ end
+
+ # Verify that a number is within the specified limits. Return the
+ # number if it is, or false if it is not.
+ def limitcheck(num, lower, upper)
+ (num >= lower && num <= upper) && num
+ end
+
+ # Verify that a value falls within the specified array. Does case
+ # insensitive matching, and supports matching either the entire word
+ # or the first three letters of the word.
+ def alphacheck(value, ary)
+ tmp = value.downcase
+
+ # If they specified a shortened version of the name, then see
+ # if we can lengthen it (e.g., mon => monday).
+ if tmp.length == 3
+ ary.each_with_index do |name, index|
+ if tmp.casecmp(name[0..2]).zero?
+ return index
+ end
+ end
+ elsif ary.include?(tmp)
+ return ary.index(tmp)
+ end
+
+ false
+ end
+
+ def should_to_s(value = @should)
+ if value
+ if value.is_a?(Array) && (name == :command || value[0].is_a?(Symbol))
+ value = value[0]
+ end
+ super(value)
+ else
+ nil
+ end
+ end
+
+ def is_to_s(value = @is) # rubocop: disable Style/PredicateName
+ if value
+ if value.is_a?(Array) && (name == :command || value[0].is_a?(Symbol))
+ value = value[0]
+ end
+ super(value)
+ else
+ nil
+ end
+ end
+
+ def should
+ if @should && @should[0] == :absent
+ :absent
+ else
+ @should
+ end
+ end
+
+ def should=(ary)
+ super
+ @should.flatten!
+ end
+
+ # The method that does all of the actual parameter value
+ # checking; called by all of the +param<name>=+ methods.
+ # Requires the value, type, and bounds, and optionally supports
+ # a boolean of whether to do alpha checking, and if so requires
+ # the ary against which to do the checking.
+ munge do |value|
+ # Support 'absent' as a value, so that they can remove
+ # a value
+ if ['absent', :absent].include? value
+ return :absent
+ end
+
+ # Allow step syntax
+ if value =~ %r{^\*/[0-9]+$}
+ return value
+ end
+
+ # Allow ranges
+ if value =~ %r{^[0-9]+-[0-9]+$}
+ return value
+ end
+
+ # Allow ranges with step
+ if value =~ %r{^[0-9]+-[0-9]+/[0-9]+$}
+ return value
+ end
+
+ if value == '*'
+ return :absent
+ end
+
+ return value unless self.class.boundaries
+ lower, upper = self.class.boundaries
+ retval = nil
+ num = numfix(value)
+ if num
+ retval = limitcheck(num, lower, upper)
+ elsif respond_to?(:alpha)
+ # If it has an alpha method defined, then we check
+ # to see if our value is in that list and if so we turn
+ # it into a number
+ retval = alphacheck(value, alpha)
+ end
+
+ raise _('%{value} is not a valid %{name}') % { value: value, name: self.class.name } unless retval
+ return retval.to_s if retval
+ end
+ end
+
+ # Somewhat uniquely, this property does not actually change anything -- it
+ # just calls +@resource.sync+, which writes out the whole cron tab for
+ # the user in question. There is no real way to change individual cron
+ # jobs without rewriting the entire cron file.
+ #
+ # Note that this means that managing many cron jobs for a given user
+ # could currently result in multiple write sessions for that user.
+ newproperty(:command, parent: CronParam) do
+ desc "The command to execute in the cron job. The environment
+ provided to the command varies by local system rules, and it is
+ best to always provide a fully qualified command. The user's
+ profile is not sourced when the command is run, so if the
+ user's environment is desired it should be sourced manually.
+
+ All cron parameters support `absent` as a value; this will
+ remove any existing values for that field."
+
+ def retrieve
+ return_value = super
+ return_value = return_value[0] if return_value && return_value.is_a?(Array)
+
+ return_value
+ end
+
+ def should
+ if @should
+ if @should.is_a? Array
+ @should[0]
+ else
+ devfail 'command is not an array'
+ end
+ else
+ nil
+ end
+ end
+
+ def munge(value)
+ value.strip
+ end
+ end
+
+ newproperty(:special) do
+ desc "A special value such as 'reboot' or 'annually'.
+ Only available on supported systems such as Vixie Cron.
+ Overrides more specific time of day/week settings.
+ Set to 'absent' to make puppet revert to a plain numeric schedule."
+
+ def specials
+ ['reboot', 'yearly', 'annually', 'monthly', 'weekly', 'daily', 'midnight', 'hourly', 'absent'] +
+ [:absent]
+ end
+
+ validate do |value|
+ raise ArgumentError, _('Invalid special schedule %{value}') % { value: value.inspect } unless specials.include?(value)
+ end
+
+ def munge(value)
+ # Support value absent so that a schedule can be
+ # forced to change to numeric.
+ if ['absent', :absent].include? value
+ return :absent
+ end
+ value
+ end
+ end
+
+ newproperty(:minute, parent: CronParam) do
+ self.boundaries = [0, 59]
+ desc "The minute at which to run the cron job.
+ Optional; if specified, must be between 0 and 59, inclusive."
+ end
+
+ newproperty(:hour, parent: CronParam) do
+ self.boundaries = [0, 23]
+ desc "The hour at which to run the cron job. Optional;
+ if specified, must be between 0 and 23, inclusive."
+ end
+
+ newproperty(:weekday, parent: CronParam) do
+ def alpha
+ ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
+ end
+ self.boundaries = [0, 7]
+ desc "The weekday on which to run the command. Optional; if specified,
+ must be either:
+
+ - A number between 0 and 7, inclusive, with 0 or 7 being Sunday
+ - The name of the day, such as 'Tuesday'."
+ end
+
+ newproperty(:month, parent: CronParam) do
+ def alpha
+ # The ___placeholder accounts for the fact that month is unique among
+ # "nameable" crontab entries in that it does not use 0-based indexing.
+ # Padding the array with a placeholder introduces the appropriate shift
+ # in indices.
+ ['___placeholder', 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']
+ end
+ self.boundaries = [1, 12]
+ desc "The month of the year. Optional; if specified,
+ must be either:
+
+ - A number between 1 and 12, inclusive, with 1 being January
+ - The name of the month, such as 'December'."
+ end
+
+ newproperty(:monthday, parent: CronParam) do
+ self.boundaries = [1, 31]
+ desc "The day of the month on which to run the
+ command. Optional; if specified, must be between 1 and 31."
+ end
+
+ newproperty(:environment) do
+ desc "Any environment settings associated with this cron job. They
+ will be stored between the header and the job in the crontab. There
+ can be no guarantees that other, earlier settings will not also
+ affect a given cron job.
+
+
+ Also, Puppet cannot automatically determine whether an existing,
+ unmanaged environment setting is associated with a given cron
+ job. If you already have cron jobs with environment settings,
+ then Puppet will keep those settings in the same place in the file,
+ but will not associate them with a specific job.
+
+ Settings should be specified exactly as they should appear in
+ the crontab, like `PATH=/bin:/usr/bin:/usr/sbin`."
+
+ validate do |value|
+ unless value =~ %r{^\s*(\w+)\s*=\s*(.*)\s*$} || value == :absent || value == 'absent'
+ raise ArgumentError, _('Invalid environment setting %{value}') % { value: value.inspect }
+ end
+ end
+
+ def insync?(is)
+ if is.is_a? Array
+ is.sort == @should.sort
+ else
+ is == @should
+ end
+ end
+
+ attr_reader :should
+
+ def should_to_s(newvalue = @should)
+ if newvalue
+ newvalue.join(',')
+ else
+ nil
+ end
+ end
+ end
+
+ newparam(:name) do
+ desc "The symbolic name of the cron job. This name
+ is used for human reference only and is generated automatically
+ for cron jobs found on the system. This generally won't
+ matter, as Puppet will do its best to match existing cron jobs
+ against specified jobs (and Puppet adds a comment to cron jobs it adds),
+ but it is at least possible that converting from unmanaged jobs to
+ managed jobs might require manual intervention."
+
+ isnamevar
+ end
+
+ newproperty(:user) do
+ desc "The user who owns the cron job. This user must
+ be allowed to run cron jobs, which is not currently checked by
+ Puppet.
+
+ This property defaults to the user running Puppet or `root`.
+
+ The default crontab provider executes the system `crontab` using
+ the user account specified by this property."
+
+ defaultto do
+ unless provider.is_a?(@resource.class.provider(:crontab))
+ struct = Etc.getpwuid(Process.uid)
+ struct.respond_to?(:name) && struct.name || 'root'
+ end
+ end
+ end
+
+ # Autorequire the owner of the crontab entry.
+ autorequire(:user) do
+ self[:user]
+ end
+
+ newproperty(:target) do
+ desc "The name of the crontab file in which the cron job should be stored.
+
+ This property defaults to the value of the `user` property if set, the
+ user running Puppet or `root`.
+
+ For the default crontab provider, this property is functionally
+ equivalent to the `user` property and should be avoided. In particular,
+ setting both `user` and `target` to different values will result in
+ undefined behavior."
+
+ defaultto do
+ if provider.is_a?(@resource.class.provider(:crontab))
+ val = @resource.should(:user)
+ if val
+ val
+ else
+ struct = Etc.getpwuid(Process.uid)
+ struct.respond_to?(:name) && struct.name || 'root'
+ end
+ elsif provider.class.ancestors.include?(Puppet::Provider::ParsedFile)
+ provider.class.default_target
+ else
+ nil
+ end
+ end
+ end
+
+ validate do
+ return true unless self[:special]
+ return true if self[:special] == :absent
+ # there is a special schedule in @should, so we don't want to see
+ # any numeric should values
+ [:minute, :hour, :weekday, :monthday, :month].each do |field|
+ next unless self[field]
+ next if self[field] == :absent
+ raise ArgumentError, _('%{cron} cannot specify both a special schedule and a value for %{field}') % { cron: ref, field: field }
+ end
+ end
+
+ # We have to reorder things so that :provide is before :target
+
+ attr_accessor :uid
+
+ # Marks the resource as "being purged".
+ #
+ # @api public
+ #
+ # @note This overrides the Puppet::Type method in order to handle
+ # an edge case that has so far been observed during testing only.
+ # Without forcing the should-value for the user property to be
+ # identical to the original cron file, purging from a fixture
+ # will not work, because the user property defaults to the user
+ # running the test. It is not clear whether this scenario can apply
+ # during normal operation.
+ #
+ # @note Also, when not forcing the should-value for the target
+ # property, unpurged file content (such as comments) can end up
+ # being written to the default target (i.e. the current login name).
+ def purging
+ self[:target] = provider.target
+ self[:user] = provider.target
+ super
+ end
+
+ def value(name)
+ name = name.to_sym
+ ret = nil
+ obj = @parameters[name]
+ if obj
+ ret = obj.should || obj.retrieve
+
+ if ret == :absent
+ ret = nil
+ end
+ end
+
+ unless ret
+ unless [:command, :special].include? name
+ ret = '*'
+ end
+ end
+
+ ret
+ end
+end