From a01889579a9b92111591b1dbbdebd9f605e14551 Mon Sep 17 00:00:00 2001 From: Michael T Lombardi Date: Mon, 20 May 2019 15:22:55 -0500 Subject: [PATCH] (MODULES-7203) Support nonroot task folders Prior to this commit the type and providers did not support specifying subfolders in which to place a scheduled task. This commit adds support to the type and the taskscheduler_api2 provider for specifying scheduled tasks in subfolders. This feature only exists for tasks whose compatibility is 2 or higher which prevents it from being used on the legacy win32_taskscheduler provider altogether and necessitates some validation prior to runtime. This commit adds such validation by failing with descriptive errors during resource validation. This new functionality requires the ability to scaffold folders in which to place the scheduled task, which now happens automatically and only if the folder path does not already exist. This commit does NOT add functionality for pruning the folders on task deletion, this should be addressed in a future commit. It does update the documentation and tests for the new feature as well as ensure all existing tests continue to pass. --- CHANGELOG.md | 4 + README.md | 3 + .../scheduled_task/taskscheduler_api2.rb | 6 + .../scheduled_task/win32_taskscheduler.rb | 6 + lib/puppet/type/scheduled_task.rb | 13 ++- .../puppetlabs/scheduled_task/task.rb | 31 ++++- .../puppetlabs/scheduled_task/task_spec.rb | 109 +++++++++++------- .../win32_taskscheduler_spec.rb | 34 ++++++ 8 files changed, 159 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef91c818..0119b7e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## [Unreleased] +### Added + +- Ability to specify scheduled tasks in subfolders by prepending the folder path to the task name ([MODULES-7203](https://tickets.puppetlabs.com/browse/.MODULES-7203)). + ## [1.0.1] - 2019-03-07 ### Fixed diff --git a/README.md b/README.md index 8405f879..5158a394 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,9 @@ All attributes except `name`, `command`, and `trigger` are optional; see the des The name assigned to the scheduled task. This will uniquely identify the task on the system. +If specifying a scheduled task inside of subfolder(s), specify the path from root, such as `subfolder/mytaskname`. +This will create the scheduled task `mytaskname` in the container named `subfolder`. +You can only specify a taskname inside of subfolders if the compatibility is set to 2 or higher and when using the taskscheduler2_api provider. ##### `ensure` diff --git a/lib/puppet/provider/scheduled_task/taskscheduler_api2.rb b/lib/puppet/provider/scheduled_task/taskscheduler_api2.rb index 9d064369..d6f78edf 100644 --- a/lib/puppet/provider/scheduled_task/taskscheduler_api2.rb +++ b/lib/puppet/provider/scheduled_task/taskscheduler_api2.rb @@ -207,4 +207,10 @@ def validate_trigger(value) true end + + def validate_name + if @resource[:name].match?(/\\/) && @resource[:compatibility] < 2 + raise Puppet::ResourceError, "#{@resource[:name]} specifies a path including subfolders and a compatibility of #{@resource[:compatibility]} - tasks in subfolders are only supported on version 2 and later of the API. Specify a compatibility of 2 or higher or do not specify a subfolder path." + end + end end diff --git a/lib/puppet/provider/scheduled_task/win32_taskscheduler.rb b/lib/puppet/provider/scheduled_task/win32_taskscheduler.rb index 2ef0c51a..5404332b 100644 --- a/lib/puppet/provider/scheduled_task/win32_taskscheduler.rb +++ b/lib/puppet/provider/scheduled_task/win32_taskscheduler.rb @@ -196,4 +196,10 @@ def validate_trigger(value) true end + + def validate_name + if @resource[:name].match?(/\\/) + raise Puppet::ResourceError, "#{@resource[:name]} specifies a path including subfolders which are not supported by the version of the Task Scheduler API used by this provider. Use the taskscheduler_api2 provider instead." + end + end end diff --git a/lib/puppet/type/scheduled_task.rb b/lib/puppet/type/scheduled_task.rb index e078bfa1..866ee4a1 100644 --- a/lib/puppet/type/scheduled_task.rb +++ b/lib/puppet/type/scheduled_task.rb @@ -22,8 +22,13 @@ end newparam(:name) do - desc "The name assigned to the scheduled task. This will uniquely - identify the task on the system." + desc "The name assigned to the scheduled task. This will uniquely + identify the task on the system. If specifying a scheduled task + inside of subfolder(s), specify the path from root, such as + `subfolder/mytaskname`. This will create the scheduled task + `mytaskname` in the container named `subfolder`. You can only + specify a taskname inside of subfolders if the compatibility is + set to 2 or higher and when using the taskscheduler2_api provider." isnamevar end @@ -244,4 +249,8 @@ def is_to_s(current_value=@is) super(current_value) end end + + validate do + provider.validate_name if provider.respond_to?(:validate_name) + end end diff --git a/lib/puppet_x/puppetlabs/scheduled_task/task.rb b/lib/puppet_x/puppetlabs/scheduled_task/task.rb index 98a7a8f5..4e01f4fe 100644 --- a/lib/puppet_x/puppetlabs/scheduled_task/task.rb +++ b/lib/puppet_x/puppetlabs/scheduled_task/task.rb @@ -141,9 +141,9 @@ def initialize(task_name, compatibility_level = nil) # def self.tasks(compatibility = V2_COMPATIBILITY) enum_task_names(ROOT_FOLDER, - include_child_folders: false, + include_child_folders: true, include_compatibility: compatibility).map do |item| - task_name_from_task_path(item) + item.partition('\\')[2] end end @@ -181,9 +181,16 @@ def self.enum_task_names(folder_path = ROOT_FOLDER, enum_options = {}) end # Returns whether or not the scheduled task exists. - def self.exists?(job_name) - # task name comparison is case insensitive - tasks.any? { |name| name.casecmp(job_name) == 0 } + def self.exists?(task_path) + raise TypeError unless task_path.is_a?(String) + begin + task_folder = task_service.GetFolder(folder_path_from_task_path(task_path)) + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa381363(v=vs.85).aspx + _task = task_folder.GetTask(task_name_from_task_path(task_path)) + rescue + return false + end + true end # Delete the specified task name. @@ -201,6 +208,7 @@ def self.delete(task_name) def save task_path = @task ? @task.Path : @full_task_path + self.class.create_folder(self.class.folder_path_from_task_path(task_path)) task_folder = self.class.task_service.GetFolder(self.class.folder_path_from_task_path(task_path)) task_user = nil task_password = nil @@ -372,6 +380,19 @@ def self.folder_path_from_task_path(task_path) path.empty? ? ROOT_FOLDER : path end + # create_folder returns "S_OK" if created or an HRESULT error code. + # It will create the full path specified, not just a the last child. + def self.create_folder(path) + begin + task_service.GetFolder(path) + rescue WIN32OLERuntimeError => e + unless Error.is_com_error_type(e, Error::ERROR_FILE_NOT_FOUND) + raise Puppet::Error.new( _("GetFolder failed with: %{error}") % { error: e }, e ) + end + task_service.GetFolder(ROOT_FOLDER).CreateFolder(path) + end + end + def self.task(task_path) raise TypeError unless task_path.is_a?(String) service = task_service diff --git a/spec/integration/puppet_x/puppetlabs/scheduled_task/task_spec.rb b/spec/integration/puppet_x/puppetlabs/scheduled_task/task_spec.rb index 11986beb..7dba5564 100644 --- a/spec/integration/puppet_x/puppetlabs/scheduled_task/task_spec.rb +++ b/spec/integration/puppet_x/puppetlabs/scheduled_task/task_spec.rb @@ -105,7 +105,7 @@ def create_task(task_name = nil, task_compatiblity = nil, triggers = []) describe '#enum_task_names' do before :each do skip('Not on Windows platform') unless Puppet.features.microsoft_windows? - end + end before(:all) do # Need a V1 task as a test fixture @@ -140,61 +140,90 @@ def create_task(task_name = nil, task_compatiblity = nil, triggers = []) end describe 'create a task' do - before :each do - skip('Not on Windows platform') unless Puppet.features.microsoft_windows? - end - - before(:all) do - skip('Not on Windows platform') unless Puppet.features.microsoft_windows? - _, @task_name = create_task(nil, nil, [ manifest_triggers[0] ]) - # find the task by name and examine its properties through COM - service = WIN32OLE.new('Schedule.Service') - service.connect() - @task_definition = service - .GetFolder(subject::ROOT_FOLDER) - .GetTask(@task_name) - .Definition - end - after(:all) do - if Puppet.features.microsoft_windows? - subject.delete(@task_name) + context 'in the root folder' do + before :each do + skip('Not on Windows platform') unless Puppet.features.microsoft_windows? end - end - context 'given a test task fixture' do - it 'should be enabled by default' do - expect(@task_definition.Settings.Enabled).to eq(true) + before(:all) do + skip('Not on Windows platform') unless Puppet.features.microsoft_windows? + _, @task_name = create_task(nil, nil, [ manifest_triggers[0] ]) + # find the task by name and examine its properties through COM + service = WIN32OLE.new('Schedule.Service') + service.connect() + @task_definition = service + .GetFolder(subject::ROOT_FOLDER) + .GetTask(@task_name) + .Definition end - it 'should be V2 compatible' do - expect(@task_definition.Settings.Compatibility).to eq(subject::TASK_COMPATIBILITY::TASK_COMPATIBILITY_V2) + after(:all) do + if Puppet.features.microsoft_windows? + subject.delete(@task_name) + end end - it 'should have a single trigger' do - expect(@task_definition.Triggers.count).to eq(1) - end + context 'given a test task fixture' do + it 'should be enabled by default' do + expect(@task_definition.Settings.Enabled).to eq(true) + end + + it 'should be V2 compatible' do + expect(@task_definition.Settings.Compatibility).to eq(subject::TASK_COMPATIBILITY::TASK_COMPATIBILITY_V2) + end + + it 'should have a single trigger' do + expect(@task_definition.Triggers.count).to eq(1) + end + + it 'should have a trigger of type TimeTrigger' do + expect(@task_definition.Triggers.Item(1).Type).to eq(ST::Trigger::V2::Type::TASK_TRIGGER_TIME) + end + + it 'should have a single action' do + expect(@task_definition.Actions.Count).to eq(1) + end - it 'should have a trigger of type TimeTrigger' do - expect(@task_definition.Triggers.Item(1).Type).to eq(ST::Trigger::V2::Type::TASK_TRIGGER_TIME) + it 'should have an action of type Execution' do + expect(@task_definition.Actions.Item(1).Type).to eq(subject::TASK_ACTION_TYPE::TASK_ACTION_EXEC) + end + + it 'should have the specified action path' do + expect(@task_definition.Actions.Item(1).Path).to eq('cmd.exe') + end + + it 'should have the specified action arguments' do + expect(@task_definition.Actions.Item(1).Arguments).to eq('/c exit 0') + end end + end - it 'should have a single action' do - expect(@task_definition.Actions.Count).to eq(1) + context 'in a subfolder' do + before :each do + skip('Not on Windows platform') unless Puppet.features.microsoft_windows? end - it 'should have an action of type Execution' do - expect(@task_definition.Actions.Item(1).Type).to eq(subject::TASK_ACTION_TYPE::TASK_ACTION_EXEC) + before(:all) do + skip('Not on Windows platform') unless Puppet.features.microsoft_windows? + task_path = SecureRandom.uuid.to_s + '\puppet_task_' + SecureRandom.uuid.to_s + _, @task_name = create_task(task_path, nil, [ manifest_triggers[0] ]) end - it 'should have the specified action path' do - expect(@task_definition.Actions.Item(1).Path).to eq('cmd.exe') + after(:all) do + if Puppet.features.microsoft_windows? + subject.delete(@task_name) + end end - it 'should have the specified action arguments' do - expect(@task_definition.Actions.Item(1).Arguments).to eq('/c exit 0') + context 'given a test task fixture' do + it 'should create a folder and place the ' do + ps_cmd = "(Get-ScheduledTask -TaskPath \\#{@task_name.partition('\\')[0]}\\).TaskName" + expect(@task_name.partition('\\')[2]).to be_same_as_powershell_command(ps_cmd) + end end end + end describe 'modify a task' do @@ -204,7 +233,7 @@ def create_task(task_name = nil, task_compatiblity = nil, triggers = []) end after(:each) do - skip('Not on Windows platform') unless Puppet.features.microsoft_windows? + skip('Not on Windows platform') unless Puppet.features.microsoft_windows? subject.delete(@task_name) end @@ -259,7 +288,7 @@ def create_task(task_name = nil, task_compatiblity = nil, triggers = []) context "should be able to create trigger" do before :each do skip('Not on Windows platform') unless Puppet.features.microsoft_windows? - end + end before(:all) do skip('Not on Windows platform') unless Puppet.features.microsoft_windows? 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 814e6732..f7e711ba 100644 --- a/spec/unit/puppet/provider/scheduled_task/win32_taskscheduler_spec.rb +++ b/spec/unit/puppet/provider/scheduled_task/win32_taskscheduler_spec.rb @@ -987,6 +987,40 @@ end end + describe '#validate_name' do + + context 'when the compatibility is 1' do + let(:resource) { Puppet::Type.type(:scheduled_task).new(:name => 'subfolder\Test Task', :command => 'C:\Windows\System32\notepad.exe') } + + it 'should raise an error if the compatibility is less than 2 or the provider is win32_taskscheduler' do + case task_provider + when :win32_taskscheduler + expect{resource.validate}.to raise_error( + Puppet::ResourceError, + /Use the taskscheduler_api2 provider instead./ + ) + when :taskscheduler_api2 + expect{resource.validate}.to raise_error( + Puppet::ResourceError, + /Specify a compatibility of 2 or higher or do not specify a subfolder path./ + ) + end + end + end + + context 'when compatibility is 2' do + before :each do + skip('Only check against taskscheduler_api2') unless task_provider == :taskscheduler_api2 + end + + let(:resource) { Puppet::Type.type(:scheduled_task).new(:name => 'subfolder\Test Task', :compatibility => 2, :command => 'C:\Windows\System32\notepad.exe') } + + it 'should not raise an error if the compatibility is >= 2 and the provider is taskscheduler_api2' do + expect{resource.validate}.not_to raise_error + end + end + end + describe '#flush' do let(:resource) do Puppet::Type.type(:scheduled_task).new(