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] record # @param [Array] 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