diff --git a/.sync.yml b/.sync.yml new file mode 100644 index 00000000..bb518560 --- /dev/null +++ b/.sync.yml @@ -0,0 +1,5 @@ +--- +NOTICE: + copyright_holders: + - name: 'Puppet, Inc.' + begin: 2018 diff --git a/README.md b/README.md index b1b3e417..82dea430 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,5 @@ - # scheduled_task -Welcome to your new module. A short overview of the generated parts can be found in the PDK documentation at https://docs.puppet.com/pdk/1.0/pdk_generating_modules.html#module-contents . - -Below you'll find the default README template ready for some content. - - - - - - - #### Table of Contents 1. [Description](#description) @@ -25,57 +14,40 @@ Below you'll find the default README template ready for some content. ## Description -Start with a one- or two-sentence summary of what the module does and/or what problem it solves. This is your 30-second elevator pitch for your module. Consider including OS/Puppet version it works with. - -You can give more descriptive information in a second paragraph. This paragraph should answer the questions: "What does this module *do*?" and "Why would I use it?" If your module has a range of functionality (installation, configuration, management, etc.), this is the time to mention it. +This module adds a new [scheduled_task](https://puppet.com/docs/puppet/latest/types/scheduled_task.html) provider capable of using the more modern Version 2 Windows API for task management. ## Setup -### What scheduled_task affects **OPTIONAL** - -If it's obvious what your module touches, you can skip this section. For example, folks can probably figure out that your mysql_instance module affects their MySQL instances. - -If there's more that they should know about, though, this is the place to mention: - -* Files, packages, services, or operations that the module will alter, impact, or execute. -* Dependencies that your module automatically installs. -* Warnings or other important notices. +### Beginning with scheduled_task -### Setup Requirements **OPTIONAL** +The scheduled_task module adapts the Puppet [scheduled_task](https://puppet.com/docs/puppet/latest/types/scheduled_task.html) resource to run using a modern API. To get started, install the module and declare 'taskscheduler_api2' as the provider, for example: -If your module requires anything extra before setting up (pluginsync enabled, another module, etc.), mention it here. - -If your most recent release breaks compatibility or requires particular steps for upgrading, you might want to include an additional "Upgrading" section here. - -### Beginning with scheduled_task - -The very basic steps needed for a user to get the module up and running. This can include setup steps, if necessary, or it can be an example of the most basic use of the module. +~~~ puppet +scheduled_task { 'Run Notepad': + command => "notepad.exe", + ... + provider => 'taskscheduler_api2' +} +~~~ ## Usage -This section is where you describe how to customize, configure, and do the fancy stuff with your module here. It's especially helpful if you include usage examples and code samples for doing things with your module. +See the [Puppet resource documentation](https://puppet.com/docs/puppet/latest/types/scheduled_task.html) for more information. ## Reference -Users need a complete list of your module's classes, types, defined types providers, facts, and functions, along with the parameters for each. You can provide this list either via Puppet Strings code comments or as a complete list in the README Reference section. +### Provider -* If you are using Puppet Strings code comments, this Reference section should include Strings information so that your users know how to access your documentation. +* taskscheduler_api2: Adapts the Puppet scheduled_task resource to use the modern Version 2 API. -* If you are not using Puppet Strings, include a list of all of your classes, defined types, and so on, along with their parameters. Each element in this listing should include: +### Type - * The data type, if applicable. - * A description of what the element does. - * Valid values, if the data type doesn't make it obvious. - * Default value, if any. +* scheduled_task: See the [Puppet resource documentation](https://puppet.com/docs/puppet/latest/types/scheduled_task.html) for more information. ## Limitations -This is where you list OS compatibility, version compatibility, etc. If there are Known Issues, you might want to include them under their own heading here. +* Only supported on Windows Server 2008 and above, and Windows 7 and above. ## Development -Since your module is awesome, other users will want to play with it. Let them know what the ground rules for contributing are. - -## Release Notes/Contributors/Etc. **Optional** - -If you aren't using changelog, put your release notes here (though you should consider using changelog). You can also add any additional sections you feel are necessary or important to include here. Please use the `## ` header. +Puppet modules on the Puppet Forge are open projects, and community contributions are essential for keeping them great. We can’t access the huge number of platforms and myriad hardware, software, and deployment configurations that Puppet is intended to serve, therefore want to keep it as easy as possible to contribute changes so that our modules work in your environment. There are a few guidelines that we need contributors to follow so that we can have a chance of keeping on top of things. For guidelines on how to contribute, see our [module contribution guide.](https://docs.puppet.com/forge/contributing.html) diff --git a/examples/create_task_v1.pp b/examples/create_task_v1.pp new file mode 100644 index 00000000..b5b10a12 --- /dev/null +++ b/examples/create_task_v1.pp @@ -0,0 +1,9 @@ +scheduled_task { 'Run Notepad': + ensure => present, + command => 'C:\Windows\System32\notepad.exe', + trigger => { + schedule => daily, + start_time => '12:00', + }, + provider => 'taskscheduler_api2' +} diff --git a/examples/delete_task_v1.pp b/examples/delete_task_v1.pp new file mode 100644 index 00000000..bbc9f9ea --- /dev/null +++ b/examples/delete_task_v1.pp @@ -0,0 +1,4 @@ +scheduled_task { 'Run Notepad': + ensure => absent, + provider => 'taskscheduler_api2' +} 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 00000000..17e7c518 --- /dev/null +++ b/lib/puppet/provider/scheduled_task/taskscheduler_api2.rb @@ -0,0 +1,590 @@ +require 'puppet/parameter' + +if Puppet.features.microsoft_windows? + require File.join(File.dirname(__FILE__), '../../../puppet_x/puppetlabs/scheduled_task/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 + PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2V1Task.new.tasks.collect do |job_file| + job_title = File.basename(job_file, '.job') + new( + :provider => :taskscheduler_api2, + :name => job_title + ) + end + end + + def exists? + PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2V1Task.new.exists? resource[:name] + end + + def task + return @task if @task + + @task ||= PuppetX::PuppetLabs::ScheduledTask::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 = PuppetX::PuppetLabs::ScheduledTask::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 + PuppetX::PuppetLabs::ScheduledTask::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_x/puppetlabs/scheduled_task/taskscheduler2.rb b/lib/puppet_x/puppetlabs/scheduled_task/taskscheduler2.rb new file mode 100644 index 00000000..c3f24185 --- /dev/null +++ b/lib/puppet_x/puppetlabs/scheduled_task/taskscheduler2.rb @@ -0,0 +1,318 @@ +require 'puppet/util/windows' + +# 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 +module PuppetX +module PuppetLabs +module ScheduledTask + +module TaskScheduler2 + # The name of the root folder for tasks + ROOT_FOLDER = '\\'.freeze + + # 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/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 + + RESERVED_FOR_FUTURE_USE = 0 + + def self.folder_path_from_task_path(task_path) + path = task_path.rpartition('\\')[0] + + path.empty? ? ROOT_FOLDER : path + end + + def self.task_name_from_task_path(task_path) + task_path.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 self.enum_task_names(folder_path = ROOT_FOLDER, enum_options = {}) + raise TypeError unless folder_path.is_a?(String) + + options = { + :include_child_folders => true, + :include_compatibility => [], + }.merge(enum_options) + + array = [] + + task_folder = task_service.GetFolder(folder_path) + filter_compatibility = !options[:include_compatibility].empty? + task_folder.GetTasks(TASK_ENUM_HIDDEN).each do |task| + next if filter_compatibility && !options[:include_compatibility].include?(task.Definition.Settings.Compatibility) + array << task.Path + end + return array unless options[:include_child_folders] + + task_folder.GetFolders(RESERVED_FOR_FUTURE_USE).each do |child_folder| + array += enum_task_names(child_folder.Path, options) + end + + array + end + + def self.task(task_path) + raise TypeError unless task_path.is_a?(String) + + task_folder = task_service.GetFolder(folder_path_from_task_path(task_path)) + + task_object = task_folder.GetTask(task_name_from_task_path(task_path)) + + task_object + end + + def self.new_task_definition + task_service.NewTask(0) + end + + def self.task_definition(task) + definition = task_service.NewTask(0) + definition.XmlText = task.XML + + definition + end + + # Creates or Updates an existing task with the supplied task definition + # If task_object is a string then this is a new task and the supplied object is the new task's full path + # Otherwise we expect a Win32OLE Task object to be passed through + def self.save(task_object, definition, password = nil) + task_path = task_object.is_a?(String) ? task_object : task_object.Path + + task_folder = task_service.GetFolder(folder_path_from_task_path(task_path)) + task_user = nil + task_password = nil + + case definition.Principal.LogonType + when TASK_LOGON_PASSWORD, TASK_LOGON_INTERACTIVE_TOKEN_OR_PASSWORD + task_user = definition.Principal.UserId + task_password = password + end + task_folder.RegisterTaskDefinition(task_name_from_task_path(task_path), + definition, TASK_CREATE_OR_UPDATE, task_user, task_password, + definition.Principal.LogonType) + end + + # Delete the specified task name. + # + def self.delete(task_path) + raise TypeError unless task_path.is_a?(String) + task_folder = task_service.GetFolder(folder_path_from_task_path(task_path)) + + task_folder.DeleteTask(task_name_from_task_path(task_path),0) + end + + # General Properties + def self.principal(definition) + definition.Principal + end + + def self.set_principal(definition, user) + if (user.nil? || user == "") + # 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 + definition.Principal.UserId = user + definition.Principal.LogonType = TASK_LOGON_PASSWORD + definition.Principal.RunLevel = TASK_RUNLEVEL_HIGHEST + return true + end + end + + # Returns the compatibility level of the task. + # + def self.compatibility(definition) + 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 self.set_compatibility(definition, value) + definition.Settings.Compatibility = value + end + + # Task Actions + # Returns the number of actions associated with the active task. + # + def self.action_count(definition) + definition.Actions.count + end + + def self.action(definition, index) + result = nil + + begin + result = 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 + result = nil + else + raise + end + end + + result + end + + def self.create_action(definition, action_type) + definition.Actions.Create(action_type) + end + + # Task Triggers + def self.trigger_count(definition) + definition.Triggers.count + end + + # Returns a Win32OLE Trigger Object for the trigger at the given index for the + # supplied definition. + # + # Returns nil if the index does not exist + # + # Note - This is a 1 based array (not zero) + # + def self.trigger(definition, index) + result = nil + + begin + result = 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 + result = nil + else + raise + end + end + + result + end + + def self.append_new_trigger(definition, trigger_type) + definition.Triggers.create(trigger_type) + end + + # Deletes the trigger at the specified index. + # + def self.delete_trigger(definition, index) + definition.Triggers.Remove(index) + + index + end + + # Helpers + + # From https://msdn.microsoft.com/en-us/library/windows/desktop/aa381850(v=vs.85).aspx + # https://en.wikipedia.org/wiki/ISO_8601#Durations + # + # 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 self.duration_to_hash(duration) + regex = /^P((?'year'\d+)Y)?((?'month'\d+)M)?((?'day'\d+)D)?(T((?'hour'\d+)H)?((?'minute'\d+)M)?((?'second'\d+)S)?)?$/ + + matches = regex.match(duration) + 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 + # https://en.wikipedia.org/wiki/ISO_8601#Durations + # returns PT0S if there is nothing set. + def self.hash_to_duration(hash) + duration = 'P' + duration += hash[:year].to_s + 'Y' unless hash[:year].nil? || hash[:year].zero? + duration += hash[:month].to_s + 'M' unless hash[:month].nil? || hash[:month].zero? + duration += hash[:day].to_s + 'D' unless hash[:day].nil? || hash[:day].zero? + duration += 'T' + duration += hash[:hour].to_s + 'H' unless hash[:hour].nil? || hash[:hour].zero? + duration += hash[:minute].to_s + 'M' unless hash[:minute].nil? || hash[:minute].zero? + duration += hash[:second].to_s + 'S' unless hash[:second].nil? || hash[:second].zero? + + duration == 'PT' ? 'PT0S' : duration + end + + # Private methods + def self.task_service + if @service_object.nil? + @service_object = WIN32OLE.new('Schedule.Service') + @service_object.connect() + end + @service_object + end + private_class_method :task_service +end + +end +end +end diff --git a/lib/puppet_x/puppetlabs/scheduled_task/taskscheduler2_v1task.rb b/lib/puppet_x/puppetlabs/scheduled_task/taskscheduler2_v1task.rb new file mode 100644 index 00000000..851404fa --- /dev/null +++ b/lib/puppet_x/puppetlabs/scheduled_task/taskscheduler2_v1task.rb @@ -0,0 +1,595 @@ +# 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 +# +require_relative './taskscheduler2' +require 'puppet/util/windows/taskscheduler' # Needed for the WIN32::ScheduledTask flag constants + +module PuppetX +module PuppetLabs +module ScheduledTask + +class 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) + @tasksched = PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2 + + if work_item + if trigger + raise TypeError unless trigger.is_a?(Hash) + new_work_item(work_item, trigger) + end + end + end + + # Returns an array of scheduled task names. + # + # Emulates V1 tasks by appending the '.job' suffix + # + def enum + @tasksched.enum_task_names(PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::ROOT_FOLDER, + include_child_folders: false, + include_compatibility: [PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_COMPATIBILITY_AT, PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_COMPATIBILITY_V1]).map do |item| + @tasksched.task_name_from_task_path(item) + '.job' + end + end + alias :tasks :enum + + # Activate the specified task. + # + def activate(task_name) + raise TypeError unless task_name.is_a?(String) + normal_task_name = normalize_task_name(task_name) + raise Error.new(_("Scheduled Task %{task_name} does not exist") % { task_name: normal_task_name }) unless exists?(normal_task_name) + + full_taskname = PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::ROOT_FOLDER + normal_task_name + + @task = @tasksched.task(full_taskname) + @full_task_path = full_taskname + @definition = @tasksched.task_definition(@task) + @task_password = nil + + @task + end + + # Delete the specified task name. + # + def delete(task_name) + full_taskname = PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::ROOT_FOLDER + normalize_task_name(task_name) + @tasksched.delete(full_taskname) + end + + # Execute the current task. + # + def run + raise NotImplementedError + end + + # Saves the current task. Tasks must be saved before they can be activated. + # The .job file itself is typically stored in the C:\WINDOWS\Tasks folder. + # + # If +file+ (an absolute path) is specified then the job is saved to that + # file instead. A '.job' extension is recommended but not enforced. + # + def save(file = nil) + task_object = @task.nil? ? @full_task_path : @task + @tasksched.save(task_object, @definition, @task_password) + end + + # Terminate the current task. + # + def terminate + raise NotImplementedError + end + + # Set the host on which the various TaskScheduler methods will execute. + # + def machine=(host) + 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_password = password + @tasksched.set_principal(@definition, user) + end + + # Returns the user associated with the task or nil if no user has yet + # been associated with the task. + # + def account_information + principal = @tasksched.principal(@definition) + principal.nil? ? nil : principal.UserId + end + + # Returns the name of the application associated with the task. + # + def application_name + action = default_action(create_if_missing: false) + action.nil? ? nil : action.Path + end + + # Sets the application name associated with the task. + # + def application_name=(app) + action = default_action(create_if_missing: true) + action.Path = app + app + end + + # Returns the command line parameters for the task. + # + def parameters + action = default_action(create_if_missing: false) + action.nil? ? nil : action.Arguments + end + + # Sets the parameters for the task. These parameters are passed as command + # line arguments to the application the task will run. To clear the command + # line parameters set it to an empty string. + # + def parameters=(param) + action = default_action(create_if_missing: true) + action.Arguments = param + param + end + + # Returns the working directory for the task. + # + def working_directory + action = default_action(create_if_missing: false) + action.nil? ? nil : action.WorkingDirectory + end + + # Sets the working directory for the task. + # + def working_directory=(dir) + action = default_action(create_if_missing: false) + action.WorkingDirectory = dir + dir + end + + # Returns the task's priority level. Possible values are 'idle', + # 'normal', 'high', 'realtime', 'below_normal', 'above_normal', + # and 'unknown'. + # + def priority + raise NotImplementedError + end + + # Sets the priority of the task. The +priority+ should be a numeric + # priority constant value. + # + def priority=(priority) + raise NotImplementedError + 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) + + @full_task_path = PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::ROOT_FOLDER + normalize_task_name(task_name) + @definition = @tasksched.new_task_definition + @task = nil + @task_password = nil + + @tasksched.set_compatibility(@definition, PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_COMPATIBILITY_V1) + + append_trigger(task_trigger) + + set_account_information('',nil) + + @definition + end + alias :new_task :new_work_item + + # Returns the number of triggers associated with the active task. + # + def trigger_count + @tasksched.trigger_count(@definition) + end + + # Deletes the trigger at the specified index. + # + def delete_trigger(index) + # 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 + @tasksched.delete_trigger(@definition, index + 1) + end + + # Returns a hash that describes the trigger at the given index for the + # current task. + # + def trigger(index) + # 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 + trigger_object = @tasksched.trigger(@definition, index + 1) + trigger_object.nil? ? nil : v2trigger_to_v1hash(trigger_object) + 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 + + # Adds a trigger at the specified index. + # + def add_trigger(index, trigger) + raise NotImplementedError + end + + # Returns the flags (integer) that modify the behavior of the work item. You + # must OR the return value to determine the flags yourself. + # + def flags + flags = 0 + flags = flags | Win32::TaskScheduler::DISABLED if !@definition.Settings.Enabled + flags + end + + # Sets an OR'd value of flags that modify the behavior of the work item. + # + def flags=(flags) + @definition.Settings.Enabled = (flags & Win32::TaskScheduler::DISABLED == 0) + end + + # Returns the status of the currently active task. Possible values are + # 'ready', 'running', 'not scheduled' or 'unknown'. + # + def status + raise NotImplementedError + end + + # Returns the exit code from the last scheduled run. + # + def exit_code + raise NotImplementedError + end + + # Returns the comment associated with the task, if any. + # + def comment + raise NotImplementedError + end + + # Sets the comment for the task. + # + def comment=(comment) + raise NotImplementedError + end + + # Returns the name of the user who created the task. + # + def creator + raise NotImplementedError + end + + # Sets the creator for the task. + # + def creator=(creator) + raise NotImplementedError + end + + # Returns a Time object that indicates the next time the task will run. + # + def next_run_time + raise NotImplementedError + 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 NotImplementedError + end + + # Returns the maximum length of time, in milliseconds, that the task + # will run before terminating. + # + def max_run_time + raise NotImplementedError + 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 NotImplementedError + end + + # Returns whether or not the scheduled task exists. + def exists?(job_name) + # task name comparison is case insensitive + enum.any? { |name| name.casecmp(job_name + '.job') == 0 } + end + + private + # :stopdoc: + + def normalize_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 + + # Find the first TASK_ACTION_EXEC action + def default_action(create_if_missing: false) + action = nil + (1..@tasksched.action_count(@definition)).each do |i| + index_action = @tasksched.action(@definition, i) + action = index_action if index_action.Type == PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_ACTION_EXEC + break if action + end + + if action.nil? && create_if_missing + action = @tasksched.create_action(@definition, PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_ACTION_EXEC) + end + + action + end + + # Used for validating a trigger hash from Puppet + 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 do |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 + end + + new_hash + end + + def normalize_datetime(year, month, day, hour, minute) + DateTime.new(year, month, day, hour, minute, 0).strftime('%FT%T') + end + + def append_trigger(v1trigger) + raise TypeError unless v1trigger.is_a?(Hash) + v1trigger = transform_and_validate(v1trigger) + + trigger_object = nil + trigger_settings = v1trigger['type'] + + case v1trigger['trigger_type'] + when :TASK_TIME_TRIGGER_DAILY + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa446858(v=vs.85).aspx + trigger_object = @tasksched.append_new_trigger(@definition, PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_TRIGGER_DAILY) + trigger_object.DaysInterval = trigger_settings['days_interval'] + # Static V2 settings which are not set by the Puppet scheduledtask type + trigger_object.Randomdelay = 0 + + when :TASK_TIME_TRIGGER_WEEKLY + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa384019(v=vs.85).aspx + trigger_object = @tasksched.append_new_trigger(@definition, PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_TRIGGER_WEEKLY) + trigger_object.DaysOfWeek = trigger_settings['days_of_week'] + trigger_object.WeeksInterval = trigger_settings['weeks_interval'] + # Static V2 settings which are not set by the Puppet scheduledtask type + trigger_object.Randomdelay = 0 + + when :TASK_TIME_TRIGGER_MONTHLYDATE + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382062(v=vs.85).aspx + trigger_object = @tasksched.append_new_trigger(@definition, PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_TRIGGER_MONTHLY) + trigger_object.DaysOfMonth = trigger_settings['days'] + trigger_object.Monthsofyear = trigger_settings['months'] + # Static V2 settings which are not set by the Puppet scheduledtask type + trigger_object.RunOnLastDayOfMonth = false + trigger_object.Randomdelay = 0 + + when :TASK_TIME_TRIGGER_MONTHLYDOW + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382055(v=vs.85).aspx + trigger_object = @tasksched.append_new_trigger(@definition, PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_TRIGGER_MONTHLYDOW) + trigger_object.DaysOfWeek = trigger_settings['days_of_week'] + trigger_object.Monthsofyear = trigger_settings['months'] + trigger_object.Weeksofmonth = trigger_settings['weeks'] + # Static V2 settings which are not set by the Puppet scheduledtask type + trigger_object.RunonLastWeekOfMonth = false + trigger_object.Randomdelay = 0 + + when :TASK_TIME_TRIGGER_ONCE + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383622(v=vs.85).aspx + trigger_object = @tasksched.append_new_trigger(@definition, PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_TRIGGER_TIME) + # Static V2 settings which are not set by the Puppet scheduledtask type + trigger_object.Randomdelay = 0 + else + raise Error.new(_("Unknown V1 trigger type %{type}") % { type: v1trigger['trigger_type'] }) + end + + # Values for all Trigger Types + trigger_object.Repetition.Interval = "PT#{v1trigger['minutes_interval']}M" unless v1trigger['minutes_interval'].nil? || v1trigger['minutes_interval'].zero? + trigger_object.Repetition.Duration = "PT#{v1trigger['minutes_duration']}M" unless v1trigger['minutes_duration'].nil? || v1trigger['minutes_duration'].zero? + trigger_object.StartBoundary = normalize_datetime(v1trigger['start_year'], + v1trigger['start_month'], + v1trigger['start_day'], + v1trigger['start_hour'], + v1trigger['start_minute'] + ) + # Static V2 settings which are not set by the Puppet scheduledtask type + trigger_object.Repetition.StopAtDurationEnd = false + + v1trigger + 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_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 + + def duration_hash_to_seconds(value) + return 0 if value.nil? + 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 + + def trigger_duration_to_minutes(value) + return 0 if value.nil? + return 0 unless value.is_a?(String) + return 0 if value.empty? + + duration = duration_hash_to_seconds(@tasksched.duration_to_hash(value)) + + duration / 60 + end + + def v2trigger_to_v1hash(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.ole_type.to_s + when 'ITimeTrigger' + v1trigger['trigger_type'] = :TASK_TIME_TRIGGER_ONCE + v1trigger['type'] = { 'once' => nil } + when 'IDailyTrigger' + v1trigger['trigger_type'] = :TASK_TIME_TRIGGER_DAILY + v1trigger['type'] = { + 'days_interval' => trigger_string_to_int(v2trigger.DaysInterval) + } + when 'IWeeklyTrigger' + v1trigger['trigger_type'] = :TASK_TIME_TRIGGER_WEEKLY + v1trigger['type'] = { + 'weeks_interval' => trigger_string_to_int(v2trigger.WeeksInterval), + 'days_of_week' => trigger_string_to_int(v2trigger.DaysOfWeek) + } + when 'IMonthlyTrigger' + v1trigger['trigger_type'] = :TASK_TIME_TRIGGER_MONTHLYDATE + v1trigger['type'] = { + 'days' => trigger_string_to_int(v2trigger.DaysOfMonth), + 'months' => trigger_string_to_int(v2trigger.MonthsOfYear) + } + when 'IMonthlyDOWTrigger' + v1trigger['trigger_type'] = :TASK_TIME_TRIGGER_MONTHLYDOW + v1trigger['type'] = { + 'weeks' => trigger_string_to_int(v2trigger.WeeksOfMonth), + 'days_of_week' => trigger_string_to_int(v2trigger.DaysOfWeek), + 'months' => trigger_string_to_int(v2trigger.MonthsOfYear) + } + else + raise Error.new(_("Unknown trigger type %{type}") % { type: v2trigger.ole_type.to_s }) + end + + v1trigger + end +end + +end +end +end diff --git a/spec/integration/puppet_x/puppetlabs/scheduled_task/taskscheduler2_spec.rb b/spec/integration/puppet_x/puppetlabs/scheduled_task/taskscheduler2_spec.rb new file mode 100644 index 00000000..c7a68811 --- /dev/null +++ b/spec/integration/puppet_x/puppetlabs/scheduled_task/taskscheduler2_spec.rb @@ -0,0 +1,182 @@ +#!/usr/bin/env ruby +require 'spec_helper' +require 'puppet_x/puppetlabs/scheduled_task/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 = PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_COMPATIBILITY_V2) + tasksched = PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2 + task_name = tasksched::ROOT_FOLDER + 'puppet_task_' + SecureRandom.uuid.to_s if task_name.nil? + definition = tasksched.new_task_definition + # Set task settings + tasksched.set_compatibility(definition, task_compatiblity) + tasksched.set_principal(definition, '') + definition.Settings.Enabled = false + # Create a trigger + trigger = tasksched.append_new_trigger(definition, tasksched::TASK_TRIGGER_TIME) + trigger.StartBoundary = '2017-09-11T14:02:00' + # Create an action + new_action = tasksched.create_action(definition, tasksched::TASK_ACTION_EXEC) + new_action.Path = 'cmd.exe' + new_action.Arguments = '/c exit 0' + tasksched.save(task_name, definition) + + task_name +end + +describe "PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2", :if => Puppet.features.microsoft_windows? do + let(:subject_taskname) { nil } + let(:subject) { PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2 } + + describe '#enum_task_names' do + before(:all) do + # Need a V1 task as a test fixture + @task_name = create_test_task(nil, PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_COMPATIBILITY_V1) + end + + after(:all) do + PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2.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(PuppetX::PuppetLabs::ScheduledTask::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(PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::ROOT_FOLDER, { :include_compatibility => [PuppetX::PuppetLabs::ScheduledTask::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 '#delete' do + before(:each) do + @task_name = PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::ROOT_FOLDER + 'puppet_task_' + SecureRandom.uuid.to_s + end + + after(:each) do + begin + PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2.delete(@task_name) + rescue => _details + # Ignore any errors + end + end + + it 'should delete a task that exists' do + create_test_task(@task_name) + + # Can't use URI as it is empty string on some OS. Just construct the URI + # using path and name + ps_cmd = '(Get-ScheduledTask | ? { $_.TaskPath + $_.TaskName -eq \'' + @task_name + '\' } | Measure-Object).count' + expect(1).to be_same_as_powershell_command(ps_cmd) + + subject.delete(@task_name) + expect(0).to be_same_as_powershell_command(ps_cmd) + end + + it 'should raise an error for a task that does not exist' do + # 80070002 is file not found error code + expect{ subject.delete('task_does_not_exist') }.to raise_error(WIN32OLERuntimeError,/80070002/) + end + end + + describe 'create a task' do + before(:all) do + @task_name = create_test_task + end + + after(:all) do + PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2.delete(@task_name) + end + + let(:task_object) { subject.task(@task_name) } + let(:task_definition) { subject.task_definition(task_object) } + + context 'given a test task fixture' do + it 'should be disabled' do + expect(task_definition.Settings.Enabled).to eq(false) + end + + it 'should be V2 compatible' do + expect(subject.compatibility(task_definition)).to eq(PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_COMPATIBILITY_V2) + end + + it 'should have a single trigger' do + expect(subject.trigger_count(task_definition)).to eq(1) + end + + it 'should have a trigger of type TimeTrigger' do + expect(subject.trigger(task_definition, 1).Type).to eq(PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_TRIGGER_TIME) + end + + it 'should have a single action' do + expect(subject.action_count(task_definition)).to eq(1) + end + + it 'should have an action of type Execution' do + expect(subject.action(task_definition, 1).Type).to eq(PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2::TASK_ACTION_EXEC) + end + + it 'should have the specified action path' do + expect(subject.action(task_definition, 1).Path).to eq('cmd.exe') + end + + it 'should have the specified action arguments' do + expect(subject.action(task_definition, 1).Arguments).to eq('/c exit 0') + end + end + end + + describe 'modify a task' do + before(:each) do + @task_name = create_test_task + end + + after(:each) do + PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2.delete(@task_name) + end + + context 'given a test task fixture' do + it 'should change the action path' do + # Can't use URI as it is empty string on some OS. Just construct the URI + # using path and name + ps_cmd = '(Get-ScheduledTask | ? { $_.TaskPath + $_.TaskName -eq \'' + @task_name + '\' }).Actions[0].Execute' + + task_object = subject.task(@task_name) + task_definition = subject.task_definition(task_object) + expect('cmd.exe').to be_same_as_powershell_command(ps_cmd) + + subject.action(task_definition, 1).Path = 'notepad.exe' + subject.save(task_object, task_definition) + expect('notepad.exe').to be_same_as_powershell_command(ps_cmd) + end + end + end +end diff --git a/spec/integration/puppet_x/puppetlabs/scheduled_task/taskscheduler2_v1task_spec.rb b/spec/integration/puppet_x/puppetlabs/scheduled_task/taskscheduler2_v1task_spec.rb new file mode 100644 index 00000000..88797cfd --- /dev/null +++ b/spec/integration/puppet_x/puppetlabs/scheduled_task/taskscheduler2_v1task_spec.rb @@ -0,0 +1,100 @@ +#!/usr/bin/env ruby +require 'spec_helper' + +require 'puppet/util/windows/taskscheduler' if Puppet.features.microsoft_windows? +require 'puppet_x/puppetlabs/scheduled_task/taskscheduler2_v1task' if Puppet.features.microsoft_windows? + +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 + +# These integration tests confirm that the tasks created in a V1 scheduled task APi are visible +# in the V2 API, and that changes in the V2 API will appear in the V1 API. + +describe "PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2V1Task", :if => Puppet.features.microsoft_windows? do + let(:subjectv1) { Win32::TaskScheduler.new() } + let(:subjectv2) { PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2V1Task.new() } + + context "When created by a V1 API" do + before(:all) do + @task_name = 'puppet_task_' + SecureRandom.uuid.to_s + + task = Win32::TaskScheduler.new(@task_name, dummy_time_trigger) + task.application_name = 'cmd.exe' + task.parameters = '/c exit 0' + task.flags = Win32::TaskScheduler::DISABLED + task.save + end + + after(:all) do + obj = Win32::TaskScheduler.new() + obj.delete(@task_name) if obj.exists?(@task_name) + end + + it 'should be visible by the V2 API' do + expect(subjectv2.exists?(@task_name)).to be true + end + + it 'should have same properties in the V2 API' do + v1task = subjectv1.activate(@task_name) + v2task = subjectv2.activate(@task_name) + + expect(subjectv2.flags).to eq(subjectv1.flags) + expect(subjectv2.parameters).to eq(subjectv1.parameters) + expect(subjectv2.application_name).to eq(subjectv1.application_name) + expect(subjectv2.trigger_count).to eq(subjectv1.trigger_count) + expect(subjectv2.trigger(0)).to eq(subjectv1.trigger(0)) + end + end + + context "When modified by a V2 API" do + before(:all) do + @task_name = 'puppet_task_' + SecureRandom.uuid.to_s + + task = Win32::TaskScheduler.new(@task_name, dummy_time_trigger) + task.application_name = 'cmd.exe' + task.parameters = '/c exit 0' + task.flags = Win32::TaskScheduler::DISABLED + task.save + end + + after(:all) do + obj = Win32::TaskScheduler.new() + obj.delete(@task_name) if obj.exists?(@task_name) + end + + it 'should be visible by the V2 API' do + expect(subjectv2.exists?(@task_name)).to be true + end + + it 'should have same properties in the V1 API' do + arguments_after = '/c exit 255' + subjectv2.activate(@task_name) + subjectv2.parameters = arguments_after + subjectv2.save + + subjectv1.activate(@task_name) + + expect(subjectv1.flags).to eq(subjectv2.flags) + expect(subjectv1.parameters).to eq(arguments_after) + expect(subjectv1.application_name).to eq(subjectv2.application_name) + expect(subjectv1.trigger_count).to eq(subjectv2.trigger_count) + expect(subjectv1.trigger(0)).to eq(subjectv2.trigger(0)) + end + end +end diff --git a/spec/unit/puppet/provider/scheduled_task/win32_taskscheduler_spec.rb b/spec/unit/puppet/provider/scheduled_task/win32_taskscheduler_spec.rb index d16297e1..3af98288 100644 --- a/spec/unit/puppet/provider/scheduled_task/win32_taskscheduler_spec.rb +++ b/spec/unit/puppet/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_x/puppetlabs/scheduled_task/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) + PuppetX::PuppetLabs::ScheduledTask::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 PuppetX::PuppetLabs::ScheduledTask::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, PuppetX::PuppetLabs::ScheduledTask::TaskScheduler2V1Task] : [] +klass_list.each do |concrete_klass| + +if concrete_klass == PuppetX::PuppetLabs::ScheduledTask::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,11 +690,11 @@ 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') - described_class.expects(:new).with(:provider => :win32_taskscheduler, :name => job) + described_class.expects(:new).with(:provider => task_provider, :name => job) end described_class.instances @@ -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