From 4cb67172dd0b038fb2f2057a67efeb3064afef9f Mon Sep 17 00:00:00 2001 From: Glenn Sarti Date: Fri, 1 Sep 2017 12:34:30 -0700 Subject: [PATCH 1/3] (PUP-7894) Add Scheduled Task V2 API Class and smoke tests Previously Puppet used the V1 API for scheduled tasks however this has been deprecated in favor of the V2 Win32OLE objects. This commit adds a parent class which can query and modify tasks via the V2 API and adds a facade class called Puppet::Util::Windows::TaskScheduler2V1Task which is binary compatible with the older V1 class Win32::TaskScheduler. This commit also adds basic smoke tests for the V2 API for CRUD operations. --- lib/puppet/util/windows/taskscheduler2.rb | 655 ++++++++++++++++++ .../util/windows/taskscheduler2_v1task.rb | 576 +++++++++++++++ .../util/windows/taskscheduler2.rb | 203 ++++++ 3 files changed, 1434 insertions(+) create mode 100644 lib/puppet/util/windows/taskscheduler2.rb create mode 100644 lib/puppet/util/windows/taskscheduler2_v1task.rb create mode 100644 spec/integration/util/windows/taskscheduler2.rb 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 From 39729004954684569947b91e3094d7cf7539f709 Mon Sep 17 00:00:00 2001 From: Glenn Sarti Date: Mon, 11 Sep 2017 13:56:28 -0700 Subject: [PATCH 2/3] (PUP-7894) Add a new provider for Scheduled Taks which uses the V2 API This commit adds a new non-default provider for the scheduled_task puppet type which instead uses the V2 API for scheduled tasks. As this is not the default users will need to opt in to this functionality. --- .../scheduled_task/taskscheduler_api2.rb | 591 ++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 lib/puppet/provider/scheduled_task/taskscheduler_api2.rb 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 From 11b78a9d02fd8ba0d4b2fb192bf8c08745aae0a3 Mon Sep 17 00:00:00 2001 From: Glenn Sarti Date: Tue, 12 Sep 2017 14:54:09 -0700 Subject: [PATCH 3/3] (PUP-7894) Add tests for new V2 Scheduled Task provider This commit changes the unit tests for the scheduled_task provider by running the tests against both providers (win32_taskscheduler, the default, and taskscheduler_api2). This ensures that any behaviors are consistent in both providers. The concrete class which services each provider is refactored into the concrete_klass variable which is then used in the tests themselves. --- .../win32_taskscheduler_spec.rb | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) 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