diff --git a/lib/puppet/provider/scheduled_task/taskscheduler_api2.rb b/lib/puppet/provider/scheduled_task/taskscheduler_api2.rb new file mode 100644 index 00000000000..0be12dbe453 --- /dev/null +++ b/lib/puppet/provider/scheduled_task/taskscheduler_api2.rb @@ -0,0 +1,591 @@ +require 'puppet/parameter' + +if Puppet.features.microsoft_windows? + require 'puppet/util/windows/taskscheduler2_v1task' +end + +Puppet::Type.type(:scheduled_task).provide(:taskscheduler_api2) do + desc "This provider manages scheduled tasks on Windows. + This is a technical preview using the newer V2 API interface but + still editing V1 compatbile scheduled tasks." + + confine :operatingsystem => :windows + + def self.instances + Puppet::Util::Windows::TaskScheduler2V1Task.new.tasks.collect do |job_file| + job_title = File.basename(job_file, '.job') + + new( + :provider => :win32_taskscheduler, + :name => job_title + ) + end + end + + def exists? + Puppet::Util::Windows::TaskScheduler2V1Task.new.exists? resource[:name] + end + + def task + return @task if @task + + @task ||= Puppet::Util::Windows::TaskScheduler2V1Task.new + @task.activate(resource[:name] + '.job') if exists? + + @task + end + + def clear_task + @task = nil + @triggers = nil + end + + def enabled + task.flags & Win32::TaskScheduler::DISABLED == 0 ? :true : :false + end + + def command + task.application_name + end + + def arguments + task.parameters + end + + def working_dir + task.working_directory + end + + def user + account = task.account_information + return 'system' if account == '' + account + end + + def trigger + return @triggers if @triggers + + @triggers = [] + task.trigger_count.times do |i| + trigger = begin + task.trigger(i) + rescue Win32::TaskScheduler::Error + # Win32::TaskScheduler can't handle all of the + # trigger types Windows uses, so we need to skip the + # unhandled types to prevent "puppet resource" from + # blowing up. + nil + end + next unless trigger and scheduler_trigger_types.include?(trigger['trigger_type']) + + puppet_trigger = {} + case trigger['trigger_type'] + when Win32::TaskScheduler::TASK_TIME_TRIGGER_DAILY + puppet_trigger['schedule'] = 'daily' + puppet_trigger['every'] = trigger['type']['days_interval'].to_s + when Win32::TaskScheduler::TASK_TIME_TRIGGER_WEEKLY + puppet_trigger['schedule'] = 'weekly' + puppet_trigger['every'] = trigger['type']['weeks_interval'].to_s + puppet_trigger['day_of_week'] = days_of_week_from_bitfield(trigger['type']['days_of_week']) + when Win32::TaskScheduler::TASK_TIME_TRIGGER_MONTHLYDATE + puppet_trigger['schedule'] = 'monthly' + puppet_trigger['months'] = months_from_bitfield(trigger['type']['months']) + puppet_trigger['on'] = days_from_bitfield(trigger['type']['days']) + when Win32::TaskScheduler::TASK_TIME_TRIGGER_MONTHLYDOW + puppet_trigger['schedule'] = 'monthly' + puppet_trigger['months'] = months_from_bitfield(trigger['type']['months']) + puppet_trigger['which_occurrence'] = occurrence_constant_to_name(trigger['type']['weeks']) + puppet_trigger['day_of_week'] = days_of_week_from_bitfield(trigger['type']['days_of_week']) + when Win32::TaskScheduler::TASK_TIME_TRIGGER_ONCE + puppet_trigger['schedule'] = 'once' + end + puppet_trigger['start_date'] = self.class.normalized_date("#{trigger['start_year']}-#{trigger['start_month']}-#{trigger['start_day']}") + puppet_trigger['start_time'] = self.class.normalized_time("#{trigger['start_hour']}:#{trigger['start_minute']}") + puppet_trigger['enabled'] = trigger['flags'] & Win32::TaskScheduler::TASK_TRIGGER_FLAG_DISABLED == 0 + puppet_trigger['minutes_interval'] = trigger['minutes_interval'] ||= 0 + puppet_trigger['minutes_duration'] = trigger['minutes_duration'] ||= 0 + puppet_trigger['index'] = i + + @triggers << puppet_trigger + end + + @triggers + end + + def user_insync?(current, should) + return false unless current + + # Win32::TaskScheduler can return the 'SYSTEM' account as the + # empty string. + current = 'system' if current == '' + + # By comparing account SIDs we don't have to worry about case + # sensitivity, or canonicalization of the account name. + Puppet::Util::Windows::SID.name_to_sid(current) == Puppet::Util::Windows::SID.name_to_sid(should[0]) + end + + def trigger_insync?(current, should) + should = [should] unless should.is_a?(Array) + current = [current] unless current.is_a?(Array) + return false unless current.length == should.length + + current_in_sync = current.all? do |c| + should.any? {|s| triggers_same?(c, s)} + end + + should_in_sync = should.all? do |s| + current.any? {|c| triggers_same?(c,s)} + end + + current_in_sync && should_in_sync + end + + def command=(value) + task.application_name = value + end + + def arguments=(value) + task.parameters = value + end + + def working_dir=(value) + task.working_directory = value + end + + def enabled=(value) + if value == :true + task.flags = task.flags & ~Win32::TaskScheduler::DISABLED + else + task.flags = task.flags | Win32::TaskScheduler::DISABLED + end + end + + def trigger=(value) + desired_triggers = value.is_a?(Array) ? value : [value] + current_triggers = trigger.is_a?(Array) ? trigger : [trigger] + + extra_triggers = [] + desired_to_search = desired_triggers.dup + current_triggers.each do |current| + if found = desired_to_search.find {|desired| triggers_same?(current, desired)} + desired_to_search.delete(found) + else + extra_triggers << current['index'] + end + end + + needed_triggers = [] + current_to_search = current_triggers.dup + desired_triggers.each do |desired| + if found = current_to_search.find {|current| triggers_same?(current, desired)} + current_to_search.delete(found) + else + needed_triggers << desired + end + end + + extra_triggers.reverse_each do |index| + task.delete_trigger(index) + end + + needed_triggers.each do |trigger_hash| + # Even though this is an assignment, the API for + # Win32::TaskScheduler ends up appending this trigger to the + # list of triggers for the task, while #add_trigger is only able + # to replace existing triggers. *shrug* + task.trigger = translate_hash_to_trigger(trigger_hash) + end + end + + def user=(value) + self.fail("Invalid user: #{value}") unless Puppet::Util::Windows::SID.name_to_sid(value) + + if value.to_s.downcase != 'system' + task.set_account_information(value, resource[:password]) + else + # Win32::TaskScheduler treats a nil/empty username & password as + # requesting the SYSTEM account. + task.set_account_information(nil, nil) + end + end + + def create + clear_task + @task = Puppet::Util::Windows::TaskScheduler2V1Task.new(resource[:name], dummy_time_trigger) + self.command = resource[:command] + + [:arguments, :working_dir, :enabled, :trigger, :user].each do |prop| + send("#{prop}=", resource[prop]) if resource[prop] + end + end + + def destroy + Puppet::Util::Windows::TaskScheduler2V1Task.new.delete(resource[:name] + '.job') + end + + def flush + unless resource[:ensure] == :absent + self.fail('Parameter command is required.') unless resource[:command] + # HACK: even though the user may actually be insync?, for task changes to + # fully propagate, it is necessary to explicitly set the user for the task, + # even when it is SYSTEM (and has a nil password) + # this is a Windows security feature with the v1 COM APIs that prevent + # arbitrary reassignment of a task scheduler command to run as SYSTEM + # without the authorization to do so + self.user = resource[:user] + task.save + @task = nil + end + end + + def triggers_same?(current_trigger, desired_trigger) + return false unless current_trigger['schedule'] == desired_trigger['schedule'] + return false if current_trigger.has_key?('enabled') && !current_trigger['enabled'] + + desired = desired_trigger.dup + desired['start_date'] ||= current_trigger['start_date'] if current_trigger.has_key?('start_date') + desired['every'] ||= current_trigger['every'] if current_trigger.has_key?('every') + desired['months'] ||= current_trigger['months'] if current_trigger.has_key?('months') + desired['on'] ||= current_trigger['on'] if current_trigger.has_key?('on') + desired['day_of_week'] ||= current_trigger['day_of_week'] if current_trigger.has_key?('day_of_week') + + translate_hash_to_trigger(current_trigger) == translate_hash_to_trigger(desired) + end + + def self.normalized_date(date_string) + date = Date.parse("#{date_string}") + "#{date.year}-#{date.month}-#{date.day}" + end + + def self.normalized_time(time_string) + Time.parse("#{time_string}").strftime('%H:%M') + end + + def dummy_time_trigger + now = Time.now + { + 'flags' => 0, + 'random_minutes_interval' => 0, + 'end_day' => 0, + 'end_year' => 0, + 'minutes_interval' => 0, + 'end_month' => 0, + 'minutes_duration' => 0, + 'start_year' => now.year, + 'start_month' => now.month, + 'start_day' => now.day, + 'start_hour' => now.hour, + 'start_minute' => now.min, + 'trigger_type' => Win32::TaskScheduler::ONCE, + } + end + + def translate_hash_to_trigger(puppet_trigger) + trigger = dummy_time_trigger + + if puppet_trigger['enabled'] == false + trigger['flags'] |= Win32::TaskScheduler::TASK_TRIGGER_FLAG_DISABLED + else + trigger['flags'] &= ~Win32::TaskScheduler::TASK_TRIGGER_FLAG_DISABLED + end + + extra_keys = puppet_trigger.keys.sort - ['index', 'enabled', 'schedule', 'start_date', 'start_time', 'every', 'months', 'on', 'which_occurrence', 'day_of_week', 'minutes_interval', 'minutes_duration'] + self.fail "Unknown trigger option(s): #{Puppet::Parameter.format_value_for_display(extra_keys)}" unless extra_keys.empty? + self.fail "Must specify 'start_time' when defining a trigger" unless puppet_trigger['start_time'] + + case puppet_trigger['schedule'] + when 'daily' + trigger['trigger_type'] = Win32::TaskScheduler::DAILY + trigger['type'] = { + 'days_interval' => Integer(puppet_trigger['every'] || 1) + } + when 'weekly' + trigger['trigger_type'] = Win32::TaskScheduler::WEEKLY + trigger['type'] = { + 'weeks_interval' => Integer(puppet_trigger['every'] || 1) + } + + trigger['type']['days_of_week'] = if puppet_trigger['day_of_week'] + bitfield_from_days_of_week(puppet_trigger['day_of_week']) + else + scheduler_days_of_week.inject(0) {|day_flags,day| day_flags |= day} + end + when 'monthly' + trigger['type'] = { + 'months' => bitfield_from_months(puppet_trigger['months'] || (1..12).to_a), + } + + if puppet_trigger.keys.include?('on') + if puppet_trigger.has_key?('day_of_week') or puppet_trigger.has_key?('which_occurrence') + self.fail "Neither 'day_of_week' nor 'which_occurrence' can be specified when creating a monthly date-based trigger" + end + + trigger['trigger_type'] = Win32::TaskScheduler::MONTHLYDATE + trigger['type']['days'] = bitfield_from_days(puppet_trigger['on']) + elsif puppet_trigger.keys.include?('which_occurrence') or puppet_trigger.keys.include?('day_of_week') + self.fail 'which_occurrence cannot be specified as an array' if puppet_trigger['which_occurrence'].is_a?(Array) + %w{day_of_week which_occurrence}.each do |field| + self.fail "#{field} must be specified when creating a monthly day-of-week based trigger" unless puppet_trigger.has_key?(field) + end + + trigger['trigger_type'] = Win32::TaskScheduler::MONTHLYDOW + trigger['type']['weeks'] = occurrence_name_to_constant(puppet_trigger['which_occurrence']) + trigger['type']['days_of_week'] = bitfield_from_days_of_week(puppet_trigger['day_of_week']) + else + self.fail "Don't know how to create a 'monthly' schedule with the options: #{puppet_trigger.keys.sort.join(', ')}" + end + when 'once' + self.fail "Must specify 'start_date' when defining a one-time trigger" unless puppet_trigger['start_date'] + + trigger['trigger_type'] = Win32::TaskScheduler::ONCE + else + self.fail "Unknown schedule type: #{puppet_trigger["schedule"].inspect}" + end + + integer_interval = -1 + if puppet_trigger['minutes_interval'] + integer_interval = Integer(puppet_trigger['minutes_interval']) + self.fail 'minutes_interval must be an integer greater or equal to 0' if integer_interval < 0 + trigger['minutes_interval'] = integer_interval + end + + integer_duration = -1 + if puppet_trigger['minutes_duration'] + integer_duration = Integer(puppet_trigger['minutes_duration']) + self.fail 'minutes_duration must be an integer greater than minutes_interval and equal to or greater than 0' if integer_duration <= integer_interval && integer_duration != 0 + trigger['minutes_duration'] = integer_duration + end + + if integer_interval > 0 && integer_duration == -1 + minutes_in_day = 1440 + integer_duration = minutes_in_day + trigger['minutes_duration'] = minutes_in_day + end + + if integer_interval >= integer_duration && integer_interval > 0 + self.fail 'minutes_interval cannot be set without minutes_duration also being set to a number greater than 0' + end + + if start_date = puppet_trigger['start_date'] + start_date = Date.parse(start_date) + self.fail "start_date must be on or after 1753-01-01" unless start_date >= Date.new(1753, 1, 1) + + trigger['start_year'] = start_date.year + trigger['start_month'] = start_date.month + trigger['start_day'] = start_date.day + end + + start_time = Time.parse(puppet_trigger['start_time']) + trigger['start_hour'] = start_time.hour + trigger['start_minute'] = start_time.min + + trigger + end + + def validate_trigger(value) + value = [value] unless value.is_a?(Array) + + value.each do |t| + if t.has_key?('index') + self.fail "'index' is read-only on scheduled_task triggers and should be removed ('index' is usually provided in puppet resource scheduled_task)." + end + + if t.has_key?('enabled') + self.fail "'enabled' is read-only on scheduled_task triggers and should be removed ('enabled' is usually provided in puppet resource scheduled_task)." + end + + translate_hash_to_trigger(t) + end + + true + end + + private + + def bitfield_from_months(months) + bitfield = 0 + + months = [months] unless months.is_a?(Array) + months.each do |month| + integer_month = Integer(month) rescue nil + self.fail 'Month must be specified as an integer in the range 1-12' unless integer_month == month.to_f and integer_month.between?(1,12) + + bitfield |= scheduler_months[integer_month - 1] + end + + bitfield + end + + def bitfield_from_days(days) + bitfield = 0 + + days = [days] unless days.is_a?(Array) + days.each do |day| + # The special "day" of 'last' is represented by day "number" + # 32. 'last' has the special meaning of "the last day of the + # month", no matter how many days there are in the month. + day = 32 if day == 'last' + + integer_day = Integer(day) + self.fail "Day must be specified as an integer in the range 1-31, or as 'last'" unless integer_day = day.to_f and integer_day.between?(1,32) + + bitfield |= 1 << integer_day - 1 + end + + bitfield + end + + def bitfield_from_days_of_week(days_of_week) + bitfield = 0 + + days_of_week = [days_of_week] unless days_of_week.is_a?(Array) + days_of_week.each do |day_of_week| + bitfield |= day_of_week_name_to_constant(day_of_week) + end + + bitfield + end + + def months_from_bitfield(bitfield) + months = [] + + scheduler_months.each do |month| + if bitfield & month != 0 + months << month_constant_to_number(month) + end + end + + months + end + + def days_from_bitfield(bitfield) + days = [] + + i = 0 + while bitfield > 0 + if bitfield & 1 > 0 + # Day 32 has the special meaning of "the last day of the + # month", no matter how many days there are in the month. + days << (i == 31 ? 'last' : i + 1) + end + + bitfield = bitfield >> 1 + i += 1 + end + + days + end + + def days_of_week_from_bitfield(bitfield) + days_of_week = [] + + scheduler_days_of_week.each do |day_of_week| + if bitfield & day_of_week != 0 + days_of_week << day_of_week_constant_to_name(day_of_week) + end + end + + days_of_week + end + + def scheduler_trigger_types + [ + Win32::TaskScheduler::TASK_TIME_TRIGGER_DAILY, + Win32::TaskScheduler::TASK_TIME_TRIGGER_WEEKLY, + Win32::TaskScheduler::TASK_TIME_TRIGGER_MONTHLYDATE, + Win32::TaskScheduler::TASK_TIME_TRIGGER_MONTHLYDOW, + Win32::TaskScheduler::TASK_TIME_TRIGGER_ONCE + ] + end + + def scheduler_days_of_week + [ + Win32::TaskScheduler::SUNDAY, + Win32::TaskScheduler::MONDAY, + Win32::TaskScheduler::TUESDAY, + Win32::TaskScheduler::WEDNESDAY, + Win32::TaskScheduler::THURSDAY, + Win32::TaskScheduler::FRIDAY, + Win32::TaskScheduler::SATURDAY + ] + end + + def scheduler_months + [ + Win32::TaskScheduler::JANUARY, + Win32::TaskScheduler::FEBRUARY, + Win32::TaskScheduler::MARCH, + Win32::TaskScheduler::APRIL, + Win32::TaskScheduler::MAY, + Win32::TaskScheduler::JUNE, + Win32::TaskScheduler::JULY, + Win32::TaskScheduler::AUGUST, + Win32::TaskScheduler::SEPTEMBER, + Win32::TaskScheduler::OCTOBER, + Win32::TaskScheduler::NOVEMBER, + Win32::TaskScheduler::DECEMBER + ] + end + + def scheduler_occurrences + [ + Win32::TaskScheduler::FIRST_WEEK, + Win32::TaskScheduler::SECOND_WEEK, + Win32::TaskScheduler::THIRD_WEEK, + Win32::TaskScheduler::FOURTH_WEEK, + Win32::TaskScheduler::LAST_WEEK + ] + end + + def day_of_week_constant_to_name(constant) + case constant + when Win32::TaskScheduler::SUNDAY; 'sun' + when Win32::TaskScheduler::MONDAY; 'mon' + when Win32::TaskScheduler::TUESDAY; 'tues' + when Win32::TaskScheduler::WEDNESDAY; 'wed' + when Win32::TaskScheduler::THURSDAY; 'thurs' + when Win32::TaskScheduler::FRIDAY; 'fri' + when Win32::TaskScheduler::SATURDAY; 'sat' + end + end + + def day_of_week_name_to_constant(name) + case name + when 'sun'; Win32::TaskScheduler::SUNDAY + when 'mon'; Win32::TaskScheduler::MONDAY + when 'tues'; Win32::TaskScheduler::TUESDAY + when 'wed'; Win32::TaskScheduler::WEDNESDAY + when 'thurs'; Win32::TaskScheduler::THURSDAY + when 'fri'; Win32::TaskScheduler::FRIDAY + when 'sat'; Win32::TaskScheduler::SATURDAY + end + end + + def month_constant_to_number(constant) + month_num = 1 + while constant >> month_num - 1 > 1 + month_num += 1 + end + month_num + end + + def occurrence_constant_to_name(constant) + case constant + when Win32::TaskScheduler::FIRST_WEEK; 'first' + when Win32::TaskScheduler::SECOND_WEEK; 'second' + when Win32::TaskScheduler::THIRD_WEEK; 'third' + when Win32::TaskScheduler::FOURTH_WEEK; 'fourth' + when Win32::TaskScheduler::LAST_WEEK; 'last' + end + end + + def occurrence_name_to_constant(name) + case name + when 'first'; Win32::TaskScheduler::FIRST_WEEK + when 'second'; Win32::TaskScheduler::SECOND_WEEK + when 'third'; Win32::TaskScheduler::THIRD_WEEK + when 'fourth'; Win32::TaskScheduler::FOURTH_WEEK + when 'last'; Win32::TaskScheduler::LAST_WEEK + end + end +end diff --git a/lib/puppet/util/windows/taskscheduler2.rb b/lib/puppet/util/windows/taskscheduler2.rb new file mode 100644 index 00000000000..8519a6582e5 --- /dev/null +++ b/lib/puppet/util/windows/taskscheduler2.rb @@ -0,0 +1,655 @@ +require 'puppet/util/windows/com' + +# The TaskScheduler2 class encapsulates taskscheduler settings and behavior using the v2 API +# https://msdn.microsoft.com/en-us/library/windows/desktop/aa383600(v=vs.85).aspx + +# @api private +class Puppet::Util::Windows::TaskScheduler2 + # The error class raised if any task scheduler specific calls fail. + class Error < Puppet::Util::Windows::Error; end + + # The name of the root folder for tasks + ROOT_FOLDER = '\\' + + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa378137(v=vs.85).aspx + S_OK = 0 + + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383558(v=vs.85).aspx + TASK_ENUM_HIDDEN = 0x1 + + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa380596(v=vs.85).aspx + TASK_ACTION_EXEC = 0 + TASK_ACTION_COM_HANDLER = 5 + TASK_ACTION_SEND_EMAIL = 6 + TASK_ACTION_SHOW_MESSAGE = 7 + + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383557(v=vs.85).aspx + # Undocumented values + # Win7/2008 R2 = 3 + # Win8/Server 2012 R2 or Server 2016 = 4 + # Windows 10 = 6 + TASK_COMPATIBILITY_AT = 0 + TASK_COMPATIBILITY_V1 = 1 + TASK_COMPATIBILITY_V2 = 2 + + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383617(v=vs.85).aspx + TASK_STATE_UNKNOWN = 0 + TASK_STATE_DISABLED = 1 + TASK_STATE_QUEUED = 2 + TASK_STATE_READY = 3 + TASK_STATE_RUNNING = 4 + + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383915%28v=vs.85%29.aspx + TASK_TRIGGER_EVENT = 0 + TASK_TRIGGER_TIME = 1 + TASK_TRIGGER_DAILY = 2 + TASK_TRIGGER_WEEKLY = 3 + TASK_TRIGGER_MONTHLY = 4 + TASK_TRIGGER_MONTHLYDOW = 5 + TASK_TRIGGER_IDLE = 6 + TASK_TRIGGER_REGISTRATION = 7 + TASK_TRIGGER_BOOT = 8 + TASK_TRIGGER_LOGON = 9 + TASK_TRIGGER_SESSION_STATE_CHANGE = 11 + + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382538%28v=vs.85%29.aspx + TASK_VALIDATE_ONLY = 0x1 + TASK_CREATE = 0x2 + TASK_UPDATE = 0x4 + TASK_CREATE_OR_UPDATE = 0x6 + TASK_DISABLE = 0x8 + TASK_DONT_ADD_PRINCIPAL_ACE = 0x10 + TASK_IGNORE_REGISTRATION_TRIGGERS = 0x20 + + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383566(v=vs.85).aspx + TASK_LOGON_NONE = 0 + TASK_LOGON_PASSWORD = 1 + TASK_LOGON_S4U = 2 + TASK_LOGON_INTERACTIVE_TOKEN = 3 + TASK_LOGON_GROUP = 4 + TASK_LOGON_SERVICE_ACCOUNT = 5 + TASK_LOGON_INTERACTIVE_TOKEN_OR_PASSWORD = 6 + + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa380747(v=vs.85).aspx + TASK_RUNLEVEL_LUA = 0 + TASK_RUNLEVEL_HIGHEST = 1 + + def initialize(task_name = nil) + @task_service = nil + @pITask = nil + + @task_service = WIN32OLE.new('Schedule.Service') + @task_service.connect() + + activate(task_name) unless task_name.nil? + end + + # TODO SHOULD BE tested + def get_folder_path_from_task(task_name) + path = task_name.rpartition('\\')[0] + + path.empty? ? ROOT_FOLDER : path + end + + # TODO SHOULD BE tested + def get_task_name_from_task(task_name) + task_name.rpartition('\\')[2] + end + + # Returns an array of scheduled task names. + # By default EVERYTHING is enumerated + # option hash + # include_child_folders: recurses into child folders for tasks. Default true + # include_compatibility: Only include tasks which have any of the specified compatibility levels. Default empty array (everything is permitted) + # + def enum_task_names(folder_path = ROOT_FOLDER, options = {}) + raise Error.new(_('No current task scheduler. Schedule.Service is NULL.')) if @task_service.nil? + raise TypeError unless folder_path.is_a?(String) + + options[:include_child_folders] = true if options[:include_child_folders].nil? + options[:include_compatibility] = [] if options[:include_compatibility].nil? + + array = [] + + task_folder = @task_service.GetFolder(folder_path) + + task_folder.GetTasks(TASK_ENUM_HIDDEN).each do |task| + included = true + + included = included && options[:include_compatibility].include?(task.Definition.Settings.Compatibility) unless options[:include_compatibility].empty? + + array << task.Path if included + end + return array unless options[:include_child_folders] + + task_folder.GetFolders(0).each do |child_folder| + array = array + enum_task_names(child_folder.Path, options) + end + + array + end + + def activate(task) + raise Error.new(_('No current task scheduler. Schedule.Service is NULL.')) if @task_service.nil? + raise TypeError unless task.is_a?(String) + + task_folder = @task_service.GetFolder(get_folder_path_from_task(task)) + + begin + @pITask = task_folder.GetTask(get_task_name_from_task(task)) + rescue WIN32OLERuntimeError => e + @pITask = nil + # TODO win32ole errors are horrible. Assume the task doesn't exist + end + @pITaskDefinition = nil + + @pITask + end + + def deactivate() + @pITask = nil + @pITaskDefinition = nil + end + + def definition() + if @pITaskDefinition.nil? && !@pITask.nil? + # Create a new editable Task Defintion based off of the currently activated task + @pITaskDefinition = @task_service.NewTask(0) + @pITaskDefinition.XmlText = @pITask.XML + end + + @pITaskDefinition + end + + # Delete the specified task name. + # + def delete(task) + raise Error.new(_('No current task scheduler. Schedule.Service is NULL.')) if @task_service.nil? + raise TypeError unless task.is_a?(String) + + task_folder = @task_service.GetFolder(get_folder_path_from_task(task)) + + result = -1 + begin + result = task_folder.DeleteTask(get_task_name_from_task(task),0) + rescue WIN32OLERuntimeError => e + # TODO win32ole errors are horrible. Assume the task doesn't exist so deletion is successful + return true + end + + result == Puppet::Util::Windows::COM::S_OK + end + + # Execute the current task. + # + def run(arguments = nil) + raise Error.new(_('No currently active task. ITask is NULL.')) if @pITask.nil? + + @pITask.Run(arguments) + end + + # Saves the current task. Tasks must be saved before they can be activated. + # + def save() + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + task_path = @pITask.nil? ? @task_defn_path : @pITask.Path + + task_folder = @task_service.GetFolder(get_folder_path_from_task(task_path)) + + task_folder.RegisterTaskDefinition(get_task_name_from_task(task_path), + definition, TASK_CREATE_OR_UPDATE, nil, nil, + definition.Principal.LogonType) + end + + # Terminate the current task. + # + def terminate + raise Error.new(_('No currently active task. ITask is NULL.')) if @pITask.nil? + + @pITask.Stop(0) + end + + # TODO Need to use the password + def set_principal(user, password) + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + if (user.nil? || user == "") && (password.nil? || password == "") + # Setup for the local system account + definition.Principal.UserId = 'SYSTEM' + definition.Principal.LogonType = TASK_LOGON_SERVICE_ACCOUNT + definition.Principal.RunLevel = TASK_RUNLEVEL_HIGHEST + return true + else + # TODO!!! + raise NotImplementedError + end + end + + # Returns the user associated with the task or nil if no user has yet + # been associated with the task. + def principal + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.Principal + end + + # Returns the compatibility level of the task. + # + def compatibility + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.Settings.Compatibility + end + + # Sets the compatibility with the task. + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa381846(v=vs.85).aspx + # + def compatibility=(value) + # TODO Do we need warnings about this? could be dangerous? + definition.Settings.Compatibility = value + end + + # Returns the task's priority level. Possible values are 'idle', + # 'normal', 'high', 'realtime', 'below_normal', 'above_normal', + # and 'unknown'. + # Note - This is an approximation due to how the priority class and thread priority + # levels differ + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383070(v=vs.85).aspx + # + def priority + case priority_value + when 0 + 'realtime' + when 1 + 'high' + when 2,3 + 'above_normal' + when 4,5,6 + 'normal' + when 7,8 + 'below_normal' + when 9,10 + 'idle' + else + 'unknown' + end + end + + # Returns the task's priority level as an integer + # + def priority_value + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.Settings.Priority + end + + # Sets the priority of the task. The +priority+ should be a numeric + # priority constant value, from 0 to 10 inclusive + # + def priority_value=(value) + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + raise TypeError unless value.is_a?(Numeric) + raise TypeError if value < 0 + raise TypeError if value > 10 + + definition.Settings.Priority = value + + value + end + + def new_task_defintion(task_name) + raise Error.new(_("task '%{task}' already exists") % { task: task_name }) if exists?(task_name) + + @pITaskDefinition = @task_service.NewTask(0) + @task_defn_path = task_name + @pITask = nil + + true + end + + # Returns the number of actions associated with the active task. + # + def action_count + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.Actions.count + end + + def action(index) + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + action = nil + + begin + action = definition.Actions.Item(index) + rescue WIN32OLERuntimeError => err + # E_INVALIDARG 0x80070057 from # https://msdn.microsoft.com/en-us/library/windows/desktop/aa378137%28v=vs.85%29.aspx?f=255&MSPPError=-2147217396 + if err.message =~ /80070057/m + action = nil + else + raise + end + end + + action + end + + def create_action(action_type) + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.Actions.Create(action_type) + end + + # Returns the number of triggers associated with the active task. + # + def trigger_count + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.Triggers.count + end + + # Deletes the trigger at the specified index. + # + def delete_trigger(index) + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.Triggers.Remove(index) + + index + end + + # Returns a hash that describes the trigger at the given index for the + # current task. + # + # Returns nil if the index does not exist + # + # Note - This is a 1 based array (not zero) + # + def trigger(index) + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + trigger = nil + + begin + trigger = populate_hash_from_trigger(definition.Triggers.Item(index)) + rescue WIN32OLERuntimeError => err + # E_INVALIDARG 0x80070057 from # https://msdn.microsoft.com/en-us/library/windows/desktop/aa378137%28v=vs.85%29.aspx?f=255&MSPPError=-2147217396 + if err.message =~ /80070057/m + trigger = nil + else + raise + end + end + + trigger + end + + def append_trigger(trigger_hash) + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + obj = definition.Triggers.create(trigger_hash['type']) + + set_properties_from_hash(obj, trigger_hash) + + obj + end + + def set_trigger(index, trigger_hash) + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + obj = definition.Triggers.Item(index) + + set_properties_from_hash(obj, trigger_hash) + + obj + end + + # Returns the status of the currently active task. Possible values are + # 'ready', 'running', 'queued', 'disabled' or 'unknown'. + # + def status + raise Error.new(_('No currently active task. ITask is NULL.')) if @pITask.nil? + + case @pITask.State + when TASK_STATE_READY + status = 'ready' + when TASK_STATE_RUNNING + status = 'running' + when TASK_STATE_QUEUED + status = 'queued' + when TASK_STATE_DISABLED + status = 'disabled' + else + status = 'unknown' + end + + status + end + + # Returns the exit code from the last scheduled run. + # + def exit_code + raise Error.new(_('No currently active task. ITask is NULL.')) if @pITask.nil? + + # Note Exit Code 267011 is generated when the task has never been run + status = @pITask.LastTaskResult + + status + end + + # Returns the comment associated with the task, if any. + # + def comment + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.RegistrationInfo.Description + end + + # Sets the comment for the task. + # + def comment=(comment) + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.RegistrationInfo.Description = comment + + comment + end + + # Returns the name of the user who created the task. + # + def creator + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.RegistrationInfo.Author + end + + # Sets the creator for the task. + # + def creator=(creator) + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.RegistrationInfo.Author = creator + + creator + end + + # Returns a Time object that indicates the next time the task will run. + # nil if the task has no scheduled time + # + def next_run_time + raise Error.new(_('No currently active task. ITask is NULL.')) if @pITask.nil? + + time = @pITask.NextRunTime + + # The API will still return a time WAAAY in the past if there is no schedule. + # As this is looking forward, if the next execution is 'scheduled' in the 1900s assume + # this task is not actually scheduled at all + time = nil if time.year < 2000 + + time + end + + # Returns a Time object indicating the most recent time the task ran or + # nil if the task has never run. + # + def most_recent_run_time + raise Error.new(_('No currently active task. ITask is NULL.')) if @pITask.nil? + + time = @pITask.LastRunTime + + # The API will still return a time WAAAY in the past if the task has not run. + # If the last execution is in the 1900s assume this task has not run previosuly + time.year < 2000 ? nil : time + end + + def xml_definition + raise Error.new(_('No currently active task. ITask is NULL.')) if @pITask.nil? + + @pITask.XML + end + + # From https://msdn.microsoft.com/en-us/library/windows/desktop/aa381850(v=vs.85).aspx + # + # The format for this string is PnYnMnDTnHnMnS, where nY is the number of years, nM is the number of months, + # nD is the number of days, 'T' is the date/time separator, nH is the number of hours, nM is the number of minutes, + # and nS is the number of seconds (for example, PT5M specifies 5 minutes and P1M4DT2H5M specifies one month, + # four days, two hours, and five minutes) + def time_limit_to_hash(time_limit) + regex = /^P((?'year'\d+)Y)?((?'month'\d+)M)?((?'day'\d+)D)?T((?'hour'\d+)H)?((?'minute'\d+)M)?((?'second'\d+)S)?$/ + + matches = regex.match(time_limit) + return nil if matches.nil? + + { + :year => matches['year'], + :month => matches['month'], + :day => matches['day'], + :minute => matches['minute'], + :hour => matches['hour'], + :second => matches['second'], + } + end + + # Converts a hash table describing year, month, day etc. into a timelimit string as per + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa381850(v=vs.85).aspx + # returns PT0S if there is nothing set. + def hash_to_time_limit(hash) + limit = 'P' + limit = limit + hash[:year].to_s + 'Y' unless hash[:year].nil? || hash[:year].zero? + limit = limit + hash[:month].to_s + 'M' unless hash[:month].nil? || hash[:month].zero? + limit = limit + hash[:day].to_s + 'D' unless hash[:day].nil? || hash[:day].zero? + limit = limit + 'T' + limit = limit + hash[:hour].to_s + 'H' unless hash[:hour].nil? || hash[:hour].zero? + limit = limit + hash[:minute].to_s + 'M' unless hash[:minute].nil? || hash[:minute].zero? + limit = limit + hash[:second].to_s + 'S' unless hash[:second].nil? || hash[:second].zero? + + limit == 'PT' ? 'PT0S' : limit + end + + def duration_hash_to_seconds(value) + time = 0 + # Note - the Year and Month calculations are approximate + time = time + value[:year].to_i * (365.2422 * 24 * 60**2).to_i unless value[:year].nil? + time = time + value[:month].to_i * (365.2422 * 2 * 60**2).to_i unless value[:month].nil? + time = time + value[:day].to_i * 24 * 60**2 unless value[:day].nil? + time = time + value[:hour].to_i * 60**2 unless value[:hour].nil? + time = time + value[:minute].to_i * 60 unless value[:minute].nil? + time = time + value[:second].to_i unless value[:second].nil? + + time + end + + # Returns the maximum length of time, in milliseconds, that the task + # will run before terminating. + # + def max_run_time_as_ms + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + # A value of PT0S will enable the task to run indefinitely. + max_time = time_limit_to_hash(definition.Settings.ExecutionTimeLimit) + + max_time.nil? ? nil : duration_hash_to_seconds(max_time) * 1000 + end + + # Sets the maximum length of time, in milliseconds, that the task can run + # before terminating. Returns the value you specified if successful. + # + def max_run_time=(max_run_time) + raise Error.new(_('No currently active task. ITask is NULL.')) if definition.nil? + + definition.Settings.ExecutionTimeLimit = max_run_time + + max_run_time + end + + # Returns whether or not the scheduled task exists. + def exists?(job_name) + # task name comparison is case insensitive + enum_task_names.any? { |name| name.casecmp(job_name) == 0 } + end + + private + + # Recursively converts a WIN32OLE Object in to a hash. This method + # only outputs the Get Methods for an Object that has no parameters on the methods + # i.e. they are Object properties + # + def win32ole_to_hash(win32_obj) + hash = {} + + win32_obj.ole_get_methods.each do |method| + # Only interested in get methods with no params i.e. object properties + if method.params.count == 0 + value = nil + begin + value = win32_obj.invoke(method.name) + rescue WIN32OLERuntimeError => err + # E_NOTIMPL 0x80004001 from # https://msdn.microsoft.com/en-us/library/windows/desktop/aa378137(v=vs.85).aspx + if err.message =~ /80004001/m + # Somehow the interface has the OLE Method, but the underlying object does not implement the method. In this case + # just return nil and swallow the error + value = nil + else + raise + end + end + if value.is_a?(WIN32OLE) + # Recurse into the object tree + hash[method.name.downcase] = win32ole_to_hash(value) + else + hash[method.name.downcase] = value + end + end + end + + hash + end + + # Recursively sets properties on a WIN32OLE Object from a hash. This method + # only set the Put Methods for an Object + def set_properties_from_hash(ole_obj, prop_hash) + method_list = ole_obj.ole_put_methods.map { |method| method.name.downcase } + + prop_hash.each do |k,v| + if v.is_a?(Hash) + set_properties_from_hash ole_obj.invoke(k), v + else + new_val = v + # Ruby 2.3.1 crashes when setting an empty string e.g. '', instead use nil + new_val = nil if v.is_a?(String) && v.empty? + ole_obj.setproperty(k,new_val) if method_list.include?(k.downcase) + end + end + end + + def populate_hash_from_trigger(task_trigger) + return nil if task_trigger.nil? + + hash = win32ole_to_hash(task_trigger) + + hash['type_name'] = task_trigger.ole_type.name + + hash + end +end diff --git a/lib/puppet/util/windows/taskscheduler2_v1task.rb b/lib/puppet/util/windows/taskscheduler2_v1task.rb new file mode 100644 index 00000000000..d5da216ec75 --- /dev/null +++ b/lib/puppet/util/windows/taskscheduler2_v1task.rb @@ -0,0 +1,576 @@ +require 'puppet/util/windows/taskscheduler2' +require 'puppet/util/windows/taskscheduler' # Needed for the WIN32::ScheduledTask flag constants + +# This class is used to manage V1 compatible tasks using the Task Scheduler V2 API +# It is designed to be a binary compatible API to puppet/util/windows/taskscheduler.rb but +# will only surface the features used by the Puppet scheduledtask provider +# +class Puppet::Util::Windows::TaskScheduler2V1Task + # The error class raised if any task scheduler specific calls fail. + class Error < Puppet::Util::Windows::Error; end + + public + # Returns a new TaskScheduler object. If a work_item (and possibly the + # the trigger) are passed as arguments then a new work item is created and + # associated with that trigger, although you can still activate other tasks + # with the same handle. + # + # This is really just a bit of convenience. Passing arguments to the + # constructor is the same as calling TaskScheduler.new plus + # TaskScheduler#new_work_item. + # + def initialize(work_item = nil, trigger = nil) + @task = Puppet::Util::Windows::TaskScheduler2.new() + + if work_item + if trigger + raise TypeError unless trigger.is_a?(Hash) + new_work_item(work_item, trigger) + end + end + end + + def enum() + array = [] + @task.enum_task_names(Puppet::Util::Windows::TaskScheduler2::ROOT_FOLDER, + include_child_folders: false, + include_compatibility: [Puppet::Util::Windows::TaskScheduler2::TASK_COMPATIBILITY_AT, Puppet::Util::Windows::TaskScheduler2::TASK_COMPATIBILITY_V1]).each do |item| + array << @task.get_task_name_from_task(item) + end + + array + end + + alias :tasks :enum + + def validate_task_name(task_name) + # The Puppet provider and some other instances may pass a '.job' suffix as per the V1 API + # This is not needed for the V2 API so we just remove it + task_name = task_name.slice(0,task_name.length - 4) if task_name.end_with?('.job') + + task_name + end + + def activate(task_name) + raise TypeError unless task_name.is_a?(String) + + full_taskname = Puppet::Util::Windows::TaskScheduler2::ROOT_FOLDER + validate_task_name(task_name) + + result = @task.activate(full_taskname) + return nil if result.nil? + if @task.compatibility != Puppet::Util::Windows::TaskScheduler2::TASK_COMPATIBILITY_AT && @task.compatibility != Puppet::Util::Windows::TaskScheduler2::TASK_COMPATIBILITY_V1 + @task.deactivate + result = nil + end + + result + end + + def delete(task_name) + + @task.delete(validate_task_name(task_name)) + end + + def run() + @task.run(nil) + end + + def save(file = nil) + raise NotImplementedError unless file.nil? + + @task.save + end + + def terminate + @task.terminate + end + + def machine=(host) + # The Puppet scheduledtask provider never calls this method. + raise NotImplementedError + end + + alias :host= :machine= + + # Sets the +user+ and +password+ for the given task. If the user and + # password are set properly then true is returned. + # + # In some cases the job may be created, but the account information was + # bad. In this case the task is created but a warning is generated and + # false is returned. + # + # Note that if intending to use SYSTEM, specify an empty user and nil password + # + # Calling task.set_account_information('SYSTEM', nil) will generally not + # work, except for one special case where flags are also set like: + # task.flags = Win32::TaskScheduler::TASK_FLAG_RUN_ONLY_IF_LOGGED_ON + # + # This must be done prior to the 1st save() call for the task to be + # properly registered and visible through the MMC snap-in / schtasks.exe + # + def set_account_information(user, password) + @task.set_principal(user, password) + end + + def account_information + principal = @task.principal + + principal.nil? ? nil : principal.UserId + end + + def application_name + action = default_action + action.nil? ? nil : action.Path + end + + def application_name=(app) + action = default_action(true) + action.Path = app + + app + end + + def parameters + action = default_action + action.nil? ? nil : action.Arguments + end + + def parameters=(param) + action = default_action(true) + action.Arguments = param + + param + end + + def working_directory + action = default_action + action.nil? ? nil : action.WorkingDirectory + end + + def working_directory=(dir) + action = default_action + action.WorkingDirectory = dir + + dir + end + + def priority + @task.priority + end + + def priority=(value) + raise TypeError unless value.is_a?(Numeric) + + @task.priority_value = value + + value + end + + # Creates a new work item (scheduled job) with the given +trigger+. The + # trigger variable is a hash of options that define when the scheduled + # job should run. + # + def new_work_item(task_name, task_trigger) + raise TypeError unless task_trigger.is_a?(Hash) + + task_name = Puppet::Util::Windows::TaskScheduler2::ROOT_FOLDER + validate_task_name(task_name) + + @task.new_task_defintion(task_name) + + @task.compatibility = Puppet::Util::Windows::TaskScheduler2::TASK_COMPATIBILITY_V1 + + append_trigger(task_trigger) + + set_account_information('',nil) + + @task.definition + end + + alias :new_task :new_work_item + + def trigger_count + @task.trigger_count + end + + def delete_trigger(v1index) + # The older V1 API uses a starting index of zero, wherease the V2 API uses one. + # Need to increment by one to maintain the same behavior + @task.delete_trigger(v1index + 1) + end + + # TODO Need to convert the API v2 style triggers into API V1 equivalent hash + def trigger(v1index) + # The older V1 API uses a starting index of zero, wherease the V2 API uses one. + # Need to increment by one to maintain the same behavior + populate_v1trigger(@task.trigger(v1index + 1)) + end + + # Sets the trigger for the currently active task. + # + # Note - This method name is a mis-nomer. It's actually appending a newly created trigger to the trigger collection. + def trigger=(v1trigger) + append_trigger(v1trigger) + end + def append_trigger(v1trigger) + raise Error.new(_('No currently active task. ITask is NULL.')) if @task.definition.nil? + raise TypeError unless v1trigger.is_a?(Hash) + + v2trigger = populate_v2trigger(v1trigger) + @task.append_trigger(v2trigger) + + v1trigger + end + + # Adds a trigger at the specified index. + # + # Note - This method name is a mis-nomer. It's actually setting a trigger at the specified index + def add_trigger(v1index, v1trigger) + set_trigger(v1index, v1trigger) + end + def set_trigger(v1index, v1trigger) + raise Error.new(_('No currently active task. ITask is NULL.')) if @task.definition.nil? + raise TypeError unless v1trigger.is_a?(Hash) + + v2trigger = populate_v2trigger(v1trigger) + # The older V1 API uses a starting index of zero, wherease the V2 API uses one. + # Need to increment by one to maintain the same behavior + @task.set_trigger(v1index + 1, v2trigger) + + v1trigger + end + + def flags + raise Error.new(_('No currently active task. ITask is NULL.')) if @task.definition.nil? + + flags = 0 + + # Generate the V1 Flags integer from the task definition + # flags list - https://msdn.microsoft.com/en-us/library/windows/desktop/aa381283%28v=vs.85%29.aspx + # TODO Need to implement the rest of the flags + flags = flags | Win32::TaskScheduler::DISABLED if !@task.definition.Settings.Enabled + + flags + end + + def flags=(flags) + raise Error.new(_('No currently active task. ITask is NULL.')) if @task.definition.nil? + + # TODO Need to implement the rest of the flags + @task.definition.Settings.Enabled = !(flags & Win32::TaskScheduler::DISABLED) + + flags + end + + def status + @task.status + end + + def exit_code + @task.exit_code + end + + def comment + @task.comment + end + + def comment=(comment) + @task.comment = comment + + comment + end + + def creator + @task.creator + end + + def creator=(creator) + @task.creator = creator + + creator + end + + def next_run_time + @task.next_run_time + end + + def most_recent_run_time + @task.most_recent_run_time + end + + def max_run_time + @task.max_run_time_as_ms + end + + # Sets the maximum length of time, in milliseconds, that the task can run + # before terminating. Returns the value you specified if successful. + # + def max_run_time=(max_run_time) + raise TypeError unless max_run_time.is_a?(Numeric) + + # Convert runtime into seconds + + max_run_time = max_run_time / (1000) + mm, ss = max_run_time.divmod(60) + hh, mm = mm.divmod(60) + dd, hh = hh.divmod(24) + + @task.max_run_time = @task.hash_to_time_limit({ + :day => dd, + :hour => hh, + :minute => mm, + :second => ss, + }) + + #raise Error.new(_('No currently active task. ITask is NULL.')) if @pITask.nil? + #raise TypeError unless max_run_time.is_a?(Numeric) + + #@pITask.SetMaxRunTime(max_run_time) + + max_run_time + end + + def exists?(job_name) + # task name comparison is case insensitive + tasks.any? { |name| name.casecmp(job_name) == 0 } + end + + private + # :stopdoc: + + # Used for the new_work_item method + ValidTriggerKeys = [ + 'end_day', + 'end_month', + 'end_year', + 'flags', + 'minutes_duration', + 'minutes_interval', + 'random_minutes_interval', + 'start_day', + 'start_hour', + 'start_minute', + 'start_month', + 'start_year', + 'trigger_type', + 'type' + ] + + ValidTypeKeys = [ + 'days_interval', + 'weeks_interval', + 'days_of_week', + 'months', + 'days', + 'weeks' + ] + + # Private method that validates keys, and converts all keys to lowercase + # strings. + # + def transform_and_validate(hash) + new_hash = {} + + hash.each{ |key, value| + key = key.to_s.downcase + if key == 'type' + new_type_hash = {} + raise ArgumentError unless value.is_a?(Hash) + value.each{ |subkey, subvalue| + subkey = subkey.to_s.downcase + if ValidTypeKeys.include?(subkey) + new_type_hash[subkey] = subvalue + else + raise ArgumentError, "Invalid type key '#{subkey}'" + end + } + new_hash[key] = new_type_hash + else + if ValidTriggerKeys.include?(key) + new_hash[key] = value + else + raise ArgumentError, "Invalid key '#{key}'" + end + end + } + + new_hash + end + + def normalize_datetime(year, month, day, hour, minute) + DateTime.new(year, month, day, hour, minute, 0).strftime('%FT%T') + end + + # TODO Needs tests? probably not + def default_action(create_if_missing = false) + if @task.action_count < 1 + return nil unless create_if_missing + # V1 tasks only support TASK_ACTION_EXEC + action = @task.create_action(Puppet::Util::Windows::TaskScheduler2::TASK_ACTION_EXEC) + else + action = @task.action(1) # ActionsCollection is a 1 based array + end + + # As this class is emulating the older V1 API we only support execution actions (not email etc.) + return nil unless action.Type == Puppet::Util::Windows::TaskScheduler2::TASK_ACTION_EXEC + + action + end + + def trigger_date_part_to_int(value, datepart) + return 0 if value.nil? + return 0 unless value.is_a?(String) + return 0 if value.empty? + + DateTime.parse(value).strftime(datepart).to_i + end + + def trigger_duration_to_minutes(value) + return 0 if value.nil? + return 0 unless value.is_a?(String) + return 0 if value.empty? + + duration = @task.duration_hash_to_seconds(@task.time_limit_to_hash(value)) + + duration / 60 + end + + def trigger_string_to_int(value) + return 0 if value.nil? + return value if value.is_a?(Integer) + return 0 unless value.is_a?(String) + return 0 if value.empty? + + value.to_i + end + + # Convert a V2 compatible Trigger has into the older V1 trigger hash + def populate_v1trigger(v2trigger) + + trigger_flags = 0 + trigger_flags = trigger_flags | Win32::TaskScheduler::TASK_TRIGGER_FLAG_HAS_END_DATE unless v2trigger['endboundary'].empty? + # There is no corresponding setting for the V1 flag TASK_TRIGGER_FLAG_KILL_AT_DURATION_END + trigger_flags = trigger_flags | Win32::TaskScheduler::TASK_TRIGGER_FLAG_DISABLED unless v2trigger['enabled'] + + v1trigger = { + 'start_year' => trigger_date_part_to_int(v2trigger['startboundary'], '%Y'), + 'start_month' => trigger_date_part_to_int(v2trigger['startboundary'], '%m'), + 'start_day' => trigger_date_part_to_int(v2trigger['startboundary'], '%d'), + 'end_year' => trigger_date_part_to_int(v2trigger['endboundary'], '%Y'), + 'end_month' => trigger_date_part_to_int(v2trigger['endboundary'], '%m'), + 'end_day' => trigger_date_part_to_int(v2trigger['endboundary'], '%d'), + 'start_hour' => trigger_date_part_to_int(v2trigger['startboundary'], '%H'), + 'start_minute' => trigger_date_part_to_int(v2trigger['startboundary'], '%M'), + 'minutes_duration' => trigger_duration_to_minutes(v2trigger['repetition']['duration']), + 'minutes_interval' => trigger_duration_to_minutes(v2trigger['repetition']['interval']), + 'flags' => trigger_flags, + 'random_minutes_interval' => trigger_string_to_int(v2trigger['randomdelay']) + } + + case v2trigger['type'] + when Puppet::Util::Windows::TaskScheduler2::TASK_TRIGGER_TIME + v1trigger['trigger_type'] = :TASK_TIME_TRIGGER_ONCE + v1trigger['type'] = {} + when Puppet::Util::Windows::TaskScheduler2::TASK_TRIGGER_DAILY + v1trigger['trigger_type'] = :TASK_TIME_TRIGGER_DAILY + v1trigger['type'] = {} + v1trigger['type']['days_interval'] = trigger_string_to_int(v2trigger['daysinterval']) + when Puppet::Util::Windows::TaskScheduler2::TASK_TRIGGER_WEEKLY + v1trigger['trigger_type'] = :TASK_TIME_TRIGGER_WEEKLY + v1trigger['type'] = {} + v1trigger['type']['weeks_interval'] = trigger_string_to_int(v2trigger['weeksinterval']) + v1trigger['type']['days_of_week'] = trigger_string_to_int(v2trigger['daysofweek']) + when Puppet::Util::Windows::TaskScheduler2::TASK_TRIGGER_MONTHLY + v1trigger['trigger_type'] = :TASK_TIME_TRIGGER_MONTHLYDATE + v1trigger['type'] = {} + v1trigger['type']['days'] = trigger_string_to_int(v2trigger['daysofmonth']) + v1trigger['type']['months'] = trigger_string_to_int(v2trigger['monthsofyear']) + when Puppet::Util::Windows::TaskScheduler2::TASK_TRIGGER_MONTHLYDOW + v1trigger['trigger_type'] = :TASK_TIME_TRIGGER_MONTHLYDOW + v1trigger['type'] = {} + v1trigger['type']['weeks'] = trigger_string_to_int(v2trigger['weeksofmonth']) + v1trigger['type']['days_of_week'] = trigger_string_to_int(v2trigger['daysofweek']) + v1trigger['type']['months'] = trigger_string_to_int(v2trigger['monthsofyear']) + else + raise Error.new(_("Unknown trigger type %{type}") % { type: v2trigger['type'] }) + end + + v1trigger + end + + # Convert the older V1 trigger hash into a V2 compatible Trigger hash + def populate_v2trigger(v1trigger) + v1trigger = transform_and_validate(v1trigger) + + # Default ITaskTrigger interface properties + v2trigger = { + 'enabled' => true, + 'endboundary' => '', + 'executiontimelimit' => '', + 'repetition'=> { + 'interval' => '', + 'duration' => '', + 'stopatdurationend' => false, + }, + 'startboundary' => '', + } + + v2trigger['repetition']['interval'] = "PT#{v1trigger['minutes_interval']}M" unless v1trigger['minutes_interval'].nil? || v1trigger['minutes_interval'].zero? + v2trigger['repetition']['duration'] = "PT#{v1trigger['minutes_duration']}M" unless v1trigger['minutes_duration'].nil? || v1trigger['minutes_duration'].zero? + v2trigger['startboundary'] = normalize_datetime(v1trigger['start_year'], + v1trigger['start_month'], + v1trigger['start_day'], + v1trigger['start_hour'], + v1trigger['start_minute'] + ) + + tmp = v1trigger['type'].is_a?(Hash) ? v1trigger['type'] : nil + + case v1trigger['trigger_type'] + when :TASK_TIME_TRIGGER_DAILY + v2trigger['type'] = Puppet::Util::Windows::TaskScheduler2::TASK_TRIGGER_DAILY + v2trigger['daysinterval'] = tmp['days_interval'] + # Static V2 settings which are not set by the Puppet scheduledtask provider + v2trigger['randomdelay'] = '' + + when :TASK_TIME_TRIGGER_WEEKLY + v2trigger['type'] = Puppet::Util::Windows::TaskScheduler2::TASK_TRIGGER_WEEKLY + v2trigger['daysofweek'] = tmp['days_of_week'] + v2trigger['weeksinterval'] = tmp['weeks_interval'] + # Static V2 settings which are not set by the Puppet scheduledtask provider + v2trigger['runonlastweekofmonth'] = false + v2trigger['randomdelay'] = '' + + when :TASK_TIME_TRIGGER_MONTHLYDATE + v2trigger['type'] = Puppet::Util::Windows::TaskScheduler2::TASK_TRIGGER_MONTHLY + v2trigger['daysofmonth'] = tmp['days'] + v2trigger['monthsofyear'] = tmp['months'] + # Static V2 settings which are not set by the Puppet scheduledtask provider + v2trigger['runonlastweekofmonth'] = false + v2trigger['randomdelay'] = '' + + when :TASK_TIME_TRIGGER_MONTHLYDOW + v2trigger['type'] = Puppet::Util::Windows::TaskScheduler2::TASK_TRIGGER_MONTHLYDOW + v2trigger['daysofweek'] = tmp['days_of_week'] + v2trigger['monthsofyear'] = tmp['months'] + v2trigger['weeksofmonth'] = tmp['weeks'] + # Static V2 settings which are not set by the Puppet scheduledtask provider + v2trigger['runonlastweekofmonth'] = false + v2trigger['randomdelay'] = '' + + when :TASK_TIME_TRIGGER_ONCE + v2trigger['type'] = Puppet::Util::Windows::TaskScheduler2::TASK_TRIGGER_TIME + # Static V2 settings which are not set by the Puppet scheduledtask provider + v2trigger['randomdelay'] = '' + else + raise Error.new(_("Unknown V1 trigger type %{type}") % { type: v1trigger['trigger_type'] }) + end + + # Convert the V1 Trigger Flags into V2 API settings + # There V1 flag TASK_TRIGGER_FLAG_HAS_END_DATE is already expressed in the endboundary setting + # There is no corresponding setting for the V1 flag TASK_TRIGGER_FLAG_KILL_AT_DURATION_END + raise Error.new(_('The TASK_TRIGGER_FLAG_KILL_AT_DURATION_END flag can not be used on Version 2 API triggers')) if (v1trigger['flags'] & ~Win32::TaskScheduler::TASK_TRIGGER_FLAG_KILL_AT_DURATION_END) != 0 + v2trigger['enabled'] = (v1trigger['flags'] & ~Win32::TaskScheduler::TASK_TRIGGER_FLAG_DISABLED).zero? + + v2trigger + end +end diff --git a/spec/integration/util/windows/taskscheduler2.rb b/spec/integration/util/windows/taskscheduler2.rb new file mode 100644 index 00000000000..af3dc514f49 --- /dev/null +++ b/spec/integration/util/windows/taskscheduler2.rb @@ -0,0 +1,203 @@ +#! /usr/bin/env ruby + +require 'spec_helper' +require 'puppet/util/windows/taskscheduler2' + +RSpec::Matchers.define :be_same_as_powershell_command do |ps_cmd| + define_method :run_ps do |cmd| + full_cmd = "powershell.exe -NoLogo -NoProfile -NonInteractive -Command \"#{cmd}\"" + + result = `#{full_cmd}` + + result.strip + end + + match do |actual| + from_ps = run_ps(ps_cmd) + + # This matcher probably won't tolerate UTF8 characters + actual.to_s == from_ps + end + + failure_message do |actual| + "expected that #{actual} would match #{run_ps(ps_cmd)} from PowerShell command #{ps_cmd}" + end +end + +def create_test_task(task_name = nil, task_compatiblity = Puppet::Util::Windows::TaskScheduler2::TASK_COMPATIBILITY_V2) + task_name = Puppet::Util::Windows::TaskScheduler2::ROOT_FOLDER + 'puppet_task_' + SecureRandom.uuid.to_s if task_name.nil? + task = Puppet::Util::Windows::TaskScheduler2.new() + task.new_task_defintion(task_name) + task.compatibility = task_compatiblity # Puppet::Util::Windows::TaskScheduler2::TASK_COMPATIBILITY_V2 + task.append_trigger({ + "type" => 1, + "id" => "", + "repetition" => { + "interval" => "", + "duration" => "", + "stopatdurationend" => false + }, + "executiontimelimit" => "", + "startboundary" => "2017-09-11T14:02:00", + "endboundary" => "", + "enabled" => true, + "randomdelay" => "", + "type_name" => "ITimeTrigger" + }) + new_action = task.create_action(Puppet::Util::Windows::TaskScheduler2::TASK_ACTION_EXEC) + new_action.Path = 'cmd.exe' + new_action.Arguments = '/c exit 0' + task.set_principal('',nil) + task.definition.Settings.Enabled = false + task.save + + task_name +end + +describe "Puppet::Util::Windows::TaskScheduler2", :if => Puppet.features.microsoft_windows? do + let(:subject_taskname) { nil } + let(:subject) { Puppet::Util::Windows::TaskScheduler2.new(subject_taskname) } + + describe '#enum_task_names' do + before(:all) do + # Need a V1 task as a test fixture + @task_name = create_test_task(nil, Puppet::Util::Windows::TaskScheduler2::TASK_COMPATIBILITY_V1) + end + + after(:all) do + Puppet::Util::Windows::TaskScheduler2.new().delete(@task_name) + end + + it 'should return all tasks by default' do + subject_count = subject.enum_task_names.count + ps_cmd = '(Get-ScheduledTask | Measure-Object).count' + expect(subject_count).to be_same_as_powershell_command(ps_cmd) + end + + it 'should not recurse folders if specified' do + subject_count = subject.enum_task_names(Puppet::Util::Windows::TaskScheduler2::ROOT_FOLDER, { :include_child_folders => false}).count + ps_cmd = '(Get-ScheduledTask | ? { $_.TaskPath -eq \'\\\' } | Measure-Object).count' + expect(subject_count).to be_same_as_powershell_command(ps_cmd) + end + + it 'should only return compatible tasks if specified' do + subject_count = subject.enum_task_names(Puppet::Util::Windows::TaskScheduler2::ROOT_FOLDER, { :include_compatibility => [Puppet::Util::Windows::TaskScheduler2::TASK_COMPATIBILITY_V1]}).count + ps_cmd = '(Get-ScheduledTask | ? { [Int]$_.Settings.Compatibility -eq 1 } | Measure-Object).count' + expect(subject_count).to be_same_as_powershell_command(ps_cmd) + end + end + + describe '#activate' do + before(:all) do + @task_name = create_test_task + end + + after(:all) do + Puppet::Util::Windows::TaskScheduler2.new().delete(@task_name) + end + + it 'should return nil for a task that does not exist' do + expect(subject.activate('/this task will never exist')).to be_nil + end + + it 'should activate a task that exists' do + expect(subject.activate(@task_name)).to_not be_nil + end + end + + describe '#delete' do + before(:all) do + @task_name = task_name = Puppet::Util::Windows::TaskScheduler2::ROOT_FOLDER + 'puppet_task_' + SecureRandom.uuid.to_s + end + + after(:all) do + Puppet::Util::Windows::TaskScheduler2.new().delete(@task_name) + end + + it 'should delete a task that exists' do + create_test_task(@task_name) + + ps_cmd = '(Get-ScheduledTask | ? { $_.URI -eq \'' + @task_name + '\' } | Measure-Object).count' + expect(1).to be_same_as_powershell_command(ps_cmd) + + Puppet::Util::Windows::TaskScheduler2.new().delete(@task_name) + expect(0).to be_same_as_powershell_command(ps_cmd) + end + end + + describe 'create a task' do + before(:all) do + @task_name = create_test_task + end + + after(:all) do + Puppet::Util::Windows::TaskScheduler2.new().delete(@task_name) + end + + context 'given a test task fixture' do + it 'should be disabled' do + subject = Puppet::Util::Windows::TaskScheduler2.new(@task_name) + expect(subject.definition.Settings.Enabled).to eq(false) + end + + it 'should be V2 compatible' do + subject = Puppet::Util::Windows::TaskScheduler2.new(@task_name) + expect(subject.compatibility).to eq(Puppet::Util::Windows::TaskScheduler2::TASK_COMPATIBILITY_V2) + end + + it 'should have a single trigger' do + subject = Puppet::Util::Windows::TaskScheduler2.new(@task_name) + expect(subject.trigger_count).to eq(1) + end + + it 'should have a trigger of type TimeTrigger' do + subject = Puppet::Util::Windows::TaskScheduler2.new(@task_name) + expect(subject.trigger(1)['type']).to eq(Puppet::Util::Windows::TaskScheduler2::TASK_TRIGGER_TIME) + end + + it 'should have a single action' do + subject = Puppet::Util::Windows::TaskScheduler2.new(@task_name) + expect(subject.action_count).to eq(1) + end + + it 'should have an action of type Execution' do + subject = Puppet::Util::Windows::TaskScheduler2.new(@task_name) + expect(subject.action(1).Type).to eq(Puppet::Util::Windows::TaskScheduler2::TASK_ACTION_EXEC) + end + + it 'should have the specified action path' do + subject = Puppet::Util::Windows::TaskScheduler2.new(@task_name) + expect(subject.action(1).Path).to eq('cmd.exe') + end + + it 'should have the specified action arguments' do + subject = Puppet::Util::Windows::TaskScheduler2.new(@task_name) + expect(subject.action(1).Arguments).to eq('/c exit 0') + end + end + end + + describe 'modify a task' do + before(:all) do + @task_name = create_test_task + end + + after(:all) do + Puppet::Util::Windows::TaskScheduler2.new().delete(@task_name) + end + + context 'given a test task fixture' do + it 'should change the action path' do + ps_cmd = '(Get-ScheduledTask | ? { $_.URI -eq \'' + @task_name + '\' }).Actions[0].Execute' + + subject = Puppet::Util::Windows::TaskScheduler2.new(@task_name) + expect('cmd.exe').to be_same_as_powershell_command(ps_cmd) + + subject.action(1).Path = 'notepad.exe' + subject.save + expect('notepad.exe').to be_same_as_powershell_command(ps_cmd) + end + end + end + +end diff --git a/spec/unit/provider/scheduled_task/win32_taskscheduler_spec.rb b/spec/unit/provider/scheduled_task/win32_taskscheduler_spec.rb index d16297e1128..c47cd5ce11f 100644 --- a/spec/unit/provider/scheduled_task/win32_taskscheduler_spec.rb +++ b/spec/unit/provider/scheduled_task/win32_taskscheduler_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' require 'puppet/util/windows/taskscheduler' if Puppet.features.microsoft_windows? +require 'puppet/util/windows/taskscheduler2_v1task' if Puppet.features.microsoft_windows? shared_examples_for "a trigger that handles start_date and start_time" do let(:trigger) do @@ -13,6 +14,7 @@ before :each do Win32::TaskScheduler.any_instance.stubs(:save) + Puppet::Util::Windows::TaskScheduler2V1Task.any_instance.stubs(:save) end describe 'the given start_date' do @@ -109,7 +111,20 @@ def time_component end end -describe Puppet::Type.type(:scheduled_task).provider(:win32_taskscheduler), :if => Puppet.features.microsoft_windows? do +# The Win32::TaskScheduler and Puppet::Util::Windows::TaskScheduler2V1Task classes should be +# API compatible and behave the same way. What differs is which Windows API is used to query +# and affect the system. This means for testing, any tests should be the same no matter what +# provider or concrete class (which the provider uses) is used. +klass_list = Puppet.features.microsoft_windows? ? [Win32::TaskScheduler, Puppet::Util::Windows::TaskScheduler2V1Task] : [] +klass_list.each do |concrete_klass| + +if concrete_klass == Puppet::Util::Windows::TaskScheduler2V1Task + task_provider = :taskscheduler_api2 +else + task_provider = :win32_taskscheduler +end + +describe Puppet::Type.type(:scheduled_task).provider(task_provider), :if => Puppet.features.microsoft_windows? do before :each do Puppet::Type.type(:scheduled_task).stubs(:defaultprovider).returns(described_class) end @@ -588,14 +603,14 @@ def time_component describe '#exists?' do before :each do @mock_task = stub - @mock_task.responds_like(Win32::TaskScheduler.new) + @mock_task.responds_like(concrete_klass.new) described_class.any_instance.stubs(:task).returns(@mock_task) - Win32::TaskScheduler.stubs(:new).returns(@mock_task) + concrete_klass.stubs(:new).returns(@mock_task) end let(:resource) { Puppet::Type.type(:scheduled_task).new(:name => 'Test Task', :command => 'C:\Windows\System32\notepad.exe') } - it "should delegate to Win32::TaskScheduler using the resource's name" do + it "should delegate to #{concrete_klass.name.to_s} using the resource's name" do @mock_task.expects(:exists?).with('Test Task').returns(true) expect(resource.provider.exists?).to eq(true) @@ -606,9 +621,9 @@ def time_component before :each do @mock_task = stub @new_mock_task = stub - @mock_task.responds_like(Win32::TaskScheduler.new) - @new_mock_task.responds_like(Win32::TaskScheduler.new) - Win32::TaskScheduler.stubs(:new).returns(@mock_task, @new_mock_task) + @mock_task.responds_like(concrete_klass.new) + @new_mock_task.responds_like(concrete_klass.new) + concrete_klass.stubs(:new).returns(@mock_task, @new_mock_task) described_class.any_instance.stubs(:exists?).returns(false) end @@ -675,7 +690,7 @@ def time_component describe '.instances' do it 'should use the list of .job files to construct the list of scheduled_tasks' do job_files = ['foo.job', 'bar.job', 'baz.job'] - Win32::TaskScheduler.any_instance.stubs(:tasks).returns(job_files) + concrete_klass.any_instance.stubs(:tasks).returns(job_files) job_files.each do |job| job = File.basename(job, '.job') @@ -1559,10 +1574,10 @@ def time_component before :each do @mock_task = stub - @mock_task.responds_like(Win32::TaskScheduler.new) + @mock_task.responds_like(concrete_klass.new) @mock_task.stubs(:exists?).returns(true) @mock_task.stubs(:activate) - Win32::TaskScheduler.stubs(:new).returns(@mock_task) + concrete_klass.stubs(:new).returns(@mock_task) @command = 'C:\Windows\System32\notepad.exe' end @@ -1627,10 +1642,10 @@ def time_component before :each do @mock_task = stub - @mock_task.responds_like(Win32::TaskScheduler.new) + @mock_task.responds_like(concrete_klass.new) @mock_task.stubs(:exists?).returns(true) @mock_task.stubs(:activate) - Win32::TaskScheduler.stubs(:new).returns(@mock_task) + concrete_klass.stubs(:new).returns(@mock_task) end describe '#command=' do @@ -1684,10 +1699,10 @@ def time_component before :each do @mock_task = stub - @mock_task.responds_like(Win32::TaskScheduler.new) + @mock_task.responds_like(concrete_klass.new) @mock_task.stubs(:exists?).returns(true) @mock_task.stubs(:activate) - Win32::TaskScheduler.stubs(:new).returns(@mock_task) + concrete_klass.stubs(:new).returns(@mock_task) end it 'should not consider all duplicate current triggers in sync with a single desired trigger' do @@ -1738,10 +1753,10 @@ def time_component describe '#user=', :if => Puppet.features.microsoft_windows? do before :each do @mock_task = stub - @mock_task.responds_like(Win32::TaskScheduler.new) + @mock_task.responds_like(concrete_klass.new) @mock_task.stubs(:exists?).returns(true) @mock_task.stubs(:activate) - Win32::TaskScheduler.stubs(:new).returns(@mock_task) + concrete_klass.stubs(:new).returns(@mock_task) end it 'should use nil for user and password when setting the user to the SYSTEM account' do @@ -2058,3 +2073,5 @@ def delete_task_with_retry(task, name, attempts = 3) end end end + +end