Repository: ansible/ansible
Pull request: Allow win_scheduled_task to support adding and removing task paths
##### SUMMARY
Fixes #23471 by allowing tasks to be created in non-existent task paths when state is present, creating the task path in the process, and will attempt to remove the task path when state is absent.  Child tasks and folders must not exist in order for the task path to be removed under the latter scenario.

Path creation is accomplished through Register-ScheduledTask, which does this as a default behavior, while checking path existence and removal is performed through a separate utility function.  Code has been written to support check_mode as well.

<!--- Describe the change, including rationale and design decisions -->

<!---
If you are fixing an existing issue, please include "Fixes #nnn" in your
commit message and your description; but you should still explain what
the change does.
-->

##### ISSUE TYPE
 - Bugfix Pull Request

##### COMPONENT NAME
lib/ansible/modules/windows/win_scheduled_task.ps1

##### ANSIBLE VERSION
```
ansible 2.4.0
```


##### ADDITIONAL INFORMATION
<!---
Include additional information to help people understand the change here.
For bugs that don't have a linked bug report, a step-by-step reproduction
of the problem is helpful.
  -->

<!--- Paste verbatim command output below, e.g. before and after your change -->
```
# Create a scheduled task at a non-existent task path
  - name: Create task in new path
    win_scheduled_task:
      name: testtask
      description: A test of functionality
      executable: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
      arguments: -ExecutionPolicy Unrestricted -NonInteractive -File C:\TestDir\Test.ps1
      time: "9:45pm"
      path: \newpath\
      frequency: once
      state: present
      enabled: yes
      user: SYSTEM

# Remove the scheduled task and its parent task path
  - name: Remove task in new path
    win_scheduled_task:
      name: testtask
      path: \newpath\
      state: absent
```


Problem statement:
win_scheduled_task: Adding tasks in non-existing path fails
<!---
Verify first that your issue/request is not already reported on GitHub.
Also test if the latest release, and master branch are affected too.
-->

##### ISSUE TYPE
<!--- Pick one below and delete the rest: -->
 - Bug Report

##### COMPONENT NAME
<!--- Name of the module/plugin/task/feature -->
win_scheduled_task

##### ANSIBLE VERSION
<!--- Paste verbatim output from “ansible --version” between quotes below -->
v2.3

##### SUMMARY
<!--- Explain the problem briefly -->
Adding scheduled tasks in a different (non-existing) path is not possible. And the module does not allow to manage paths. A solution would be to have the module create the non-existing path, and remove it when the last entry was removed from it.

Hints from issue discussion:
cc @petemounce
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Commit message: added integration tests for path creation and removal

Patch to review:
diff --git a/lib/ansible/modules/windows/win_scheduled_task.ps1 b/lib/ansible/modules/windows/win_scheduled_task.ps1
index d949561492254c..091cbdc04acffb 100644
--- a/lib/ansible/modules/windows/win_scheduled_task.ps1
+++ b/lib/ansible/modules/windows/win_scheduled_task.ps1
@@ -26,13 +26,56 @@ $result = @{
     changed = $false
 }
 
+function Invoke-TaskPathCheck {
+    [CmdletBinding(SupportsShouldProcess=$true)]
+    param($Path, [Switch]$Remove)
+
+    $pathResults = @{
+        PathExists = $null
+        RemovedPath = $false
+    }
+    $removedPath = $false
+
+    if ($path -ne "\") {
+        $trimmedPath = $Path.TrimEnd("\")
+    }
+    else {
+        $trimmedPath = $Path
+    }
+
+    $scheduleObject = New-Object -ComObject Schedule.Service
+    $scheduleObject.Connect()
+
+    try {
+        $targetFolder = $scheduleObject.GetFolder($trimmedPath)
+        $pathResults.PathExists = $true
+    }
+    catch {
+        $pathResults.PathExists = $false
+    }
+
+    if ($Remove -and $pathResults.PathExists) {
+        $childFolders = $targetFolder.GetFolders($null)
+        $childTasks = $targetFolder.GetTasks($null)
+
+        if ($childFolders.Count -eq 0 -and $childTasks.Count -eq 0) {
+            if ($PSCmdlet.ShouldProcess($trimmedPath, "Remove task path")) {
+                $rootFolder = $scheduleObject.GetFolder("\")
+                $rootFolder.DeleteFolder($trimmedPath, $null)
+            }
+            $pathResults.RemovedPath = $true
+        }
+    }
+
+    return $pathResults
+}
+
 $params = Parse-Args $args -supports_check_mode $true
 $check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false
 
 $arguments = Get-AnsibleParam -obj $params -name "arguments" -type "str" -aliases "argument"
 $description = Get-AnsibleParam -obj $params -name "description" -type "str" -default "No description."
 $enabled = Get-AnsibleParam -obj $params -name "enabled" -type "bool" -default $true
-# TODO: We do not create the TaskPath if missing
 $path = Get-AnsibleParam -obj $params -name "path" -type "str" -default '\'
 
 # Required vars
@@ -53,7 +96,7 @@ $days_of_week = Get-AnsibleParam -obj $params -name "days_of_week" -type "str" -
 
 
 try {
-    $task = Get-ScheduledTask -TaskPath "$path" | Where-Object {$_.TaskName -eq "$name"}
+    $task = Get-ScheduledTask | Where-Object {$_.TaskName -eq $name -and $_.TaskPath -eq $path}
 
     # Correlate task state to enable variable, used to calculate if state needs to be changed
     $taskState = if ($task) { $task.State } else { $null }
@@ -73,9 +116,18 @@ try {
         $exists = $true
     }
     elseif ( ($measure.count -eq 0) -and ($state -eq "absent") ){
-        # Nothing to do
+        # Nothing to do for the task
         $result.exists = $false
         $result.msg = "Task does not exist"
+
+        # Remove task path if it exists
+        $pathResults = Invoke-TaskPathCheck -Path $path -Remove -WhatIf:$check_mode
+
+        if ($pathResults.RemovedPath) {
+            $result.msg += " and task path $path removed"
+            $result.changed = $true
+        }
+
         Exit-Json $result
     }
     elseif ($measure.count -eq 0){
@@ -107,6 +159,14 @@ try {
         Unregister-ScheduledTask -TaskName $name -Confirm:$false -WhatIf:$check_mode
         $result.changed = $true
         $result.msg = "Deleted task $name"
+
+        # Remove task path if it exists
+        $pathResults = Invoke-TaskPathCheck -Path $path -Remove -WhatIf:$check_mode
+
+        if ($pathResults.RemovedPath) {
+            $result.msg += " and task path $path removed"
+        }
+
         Exit-Json $result
     }
     elseif ( ($state -eq "absent") -and (-not $exists) ) {
@@ -131,25 +191,41 @@ try {
     }
 
     if ( ($state -eq "present") -and (-not $exists) ){
+        # Check task path prior to registering
+        $pathResults = Invoke-TaskPathCheck -Path $path
+
         if (-not $check_mode) {
             Register-ScheduledTask -Action $action -Trigger $trigger -TaskName $name -Description $description -TaskPath $path -Settings $settings -Principal $principal
-#            $task = Get-ScheduledTask -TaskName $name
         }
+
         $result.changed = $true
         $result.msg = "Added new task $name"
+
+        if (!$pathResults.PathExists) {
+            $result.msg += " and task path $path created"
+        }
     }
     elseif( ($state -eq "present") -and ($exists) ) {
+        # Check task path prior to registering
+        $pathResults = Invoke-TaskPathCheck -Path $path
+
         if ($task.Description -eq $description -and $task.TaskName -eq $name -and $task.TaskPath -eq $path -and $task.Actions.Execute -eq $executable -and $taskState -eq $enabled -and $task.Principal.UserId -eq $user) {
             # No change in the task
             $result.msg = "No change in task $name"
         }
         else {
             Unregister-ScheduledTask -TaskName $name -Confirm:$false -WhatIf:$check_mode
+
             if (-not $check_mode) {
+                $oldPathResults = Invoke-TaskPathCheck -Path $task.TaskPath -Remove
                 Register-ScheduledTask -Action $action -Trigger $trigger -TaskName $name -Description $description -TaskPath $path -Settings $settings -Principal $principal
             }
             $result.changed = $true
             $result.msg = "Updated task $name"
+
+            if (!$pathResults.PathExists) {
+                $result.msg += " and task path $path created"
+            }
         }
     }
 
diff --git a/lib/ansible/modules/windows/win_scheduled_task.py b/lib/ansible/modules/windows/win_scheduled_task.py
index 194cebb0fd7f16..82c9038bda8f49 100644
--- a/lib/ansible/modules/windows/win_scheduled_task.py
+++ b/lib/ansible/modules/windows/win_scheduled_task.py
@@ -81,7 +81,8 @@
       - Days of the week to run a weekly task, not idempotent
   path:
     description:
-      - Task folder in which this task will be stored
+      - Task folder in which this task will be stored - creates a non-existent path when C(state) is C(present),
+        and removes an empty path when C(state) is C(absent)
     default: '\'
 '''
 
@@ -92,7 +93,7 @@
     description: open command prompt
     executable: cmd
     arguments: -opt1 -opt2
-    path: example
+    path: \example
     time: 9am
     frequency: daily
     state: present
diff --git a/test/integration/targets/win_scheduled_task/tasks/tests.yml b/test/integration/targets/win_scheduled_task/tasks/tests.yml
index af28c04f57b789..15e1cfd1ba516a 100644
--- a/test/integration/targets/win_scheduled_task/tasks/tests.yml
+++ b/test/integration/targets/win_scheduled_task/tasks/tests.yml
@@ -123,3 +123,91 @@
     that:
     - remove_scheduled_task_again.changed == false
     - remove_scheduled_task_again.exists == false
+
+
+# Test scheduled task path creation and removal
+
+- name: Remove potentially leftover new path task 1
+  win_scheduled_task: &wstp1_absent
+    name: Ansible Test New Path 1
+    path: \non_existent_path\
+    state: absent
+
+- name: Remove potentially leftover new path task 2
+  win_scheduled_task: &wstp2_absent
+    name: Ansible Test New Path 2
+    path: \non_existent_path\
+    state: absent
+
+
+- name: Add scheduled task new path 1
+  win_scheduled_task: &wstp1_present
+    name: Ansible Test New Path 1
+    description: A test of functionality
+    executable: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
+    arguments: -ExecutionPolicy Unrestricted -NonInteractive -File C:\TestDir\Test.ps1
+    time: "9:45pm"
+    path: \non_existent_path\
+    frequency: once
+    state: present
+    enabled: yes
+    user: SYSTEM
+  register: add_scheduled_task_new_path_1
+
+- name: Test add_scheduled_task_new_path_1
+  assert:
+    that:
+    - add_scheduled_task_new_path_1.msg == 'Added new task Ansible Test New Path 1 and task path \\non_existent_path\\ created'
+
+
+- name: Add scheduled task new path 2
+  win_scheduled_task: &wstp2_present
+    <<: *wstp1_present
+    name: Ansible Test New Path 2
+  register: add_scheduled_task_new_path_2
+
+- name: Test add_scheduled_task_new_path_2 (normal mode)
+  assert:
+    that:
+    - add_scheduled_task_new_path_2.msg == 'Added new task Ansible Test New Path 2'
+  when: not in_check_mode
+
+- name: Test add_scheduled_task_new_path_2 (check-mode)
+  assert:
+    that:
+    - add_scheduled_task_new_path_2.msg == 'Added new task Ansible Test New Path 2 and task path \\non_existent_path\\ created'
+  when: in_check_mode
+
+
+- name: Remove scheduled task new path 2
+  win_scheduled_task: *wstp2_absent
+  register: remove_scheduled_task_new_path_2
+
+- name: Test remove_scheduled_task_new_path_2 (normal mode)
+  assert:
+    that:
+    - remove_scheduled_task_new_path_2.msg == 'Deleted task Ansible Test New Path 2'
+  when: not in_check_mode
+
+- name: Test remove_scheduled_task_new_path_2 (check-mode)
+  assert:
+    that:
+    - remove_scheduled_task_new_path_2.msg == 'Task does not exist'
+  when: in_check_mode
+
+
+- name: Remove scheduled task new path 1
+  win_scheduled_task: *wstp1_absent
+  register: remove_scheduled_task_new_path_1
+
+- name: Test remove_scheduled_task_new_path_1 (normal mode)
+  assert:
+    that:
+    - remove_scheduled_task_new_path_1.msg == 'Deleted task Ansible Test New Path 1 and task path \\non_existent_path\\ removed'
+  when: not in_check_mode
+
+- name: Test remove_scheduled_task_new_path_1 (check-mode)
+  assert:
+    that:
+    - remove_scheduled_task_new_path_1.msg == 'Task does not exist'
+  when: in_check_mode


Code review comments:
diff_hunk: @@ -73,9 +116,18 @@ try {
         $exists = $true
     }
     elseif ( ($measure.count -eq 0) -and ($state -eq "absent") ){
-        # Nothing to do
+        # Nothing to do for the task
         $result.exists = $false
         $result.msg = "Task does not exist"
+
+        # Remove task path if it exists
path: lib/ansible/modules/windows/win_scheduled_task.ps1
text: @user1:
I would not do it like this. Since we are managing tasks, I would only remove the TaskPath when the task was actually removed (and we are already making changes). If there was no task te remove, I would leave the TaskPath as-is, because it's unlikely we created the TaskPath anyhow. (And we don't want to mess with someone else's stuff)

And so maybe an option to disable this behavior is at order too.

@author:
Sure, that makes sense - I'll commit that change in a moment.

@author:
Fixed!

https://github.com/ansible/ansible/pull/24025/commits/b1804fb7355a62dad1d7042ac61e74121f09cede