Useful Little PowerShell Functions - Set-ScriptAsScheduledTask

Hello again! Development of this next function was a useful little reminder for me. Sometimes, when you stare into the void, the void stares back. Dealing directly with the Windows Task Scheduler API was much more depth than I wanted to go into when writing this, but ultimately, I decided it was the right call.

So what is this function all about? Well, it's pretty simple actually. You see, the basic concept is that you have a PowerShell script, and you want to create a scheduled task that will run that script either at boot or at next user logon. That's it. That's the whole story.

Now, yes I know that Windows PowerShell comes built in with a bunch of scheduled task cmdlets. But I found that each of those cmdlets abstracted some crucial bit of functionality away. Remember, our objective is to do something, and then check to make sure that thing worked. If it doesn't work, we want to throw an error.

Oh, speaking of errors, I think I'm changing my philosophy a bit on error handling within functions. I never liked referencing other functions inside a function I'm writing. Since I wrote my own logging functions, it felt weird to write debug logging into a function.

So I started thinking; "How can I write functions that handle large bits of functionality while also getting useful debug logging?" At first, I thought returning "1" for error and "0" for success was good enough, but when errors happen, "1" is not sufficiently descriptive.

Instead, I have decided that I'm going to use the "throw" instruction in PowerShell, and write custom errors. This still leaves the execution region of the script responsible for try/catching errors, but at least the errors will be more descriptive. Now I just need to figure out how to handle debug logging inside the functions in a way I'm happy with, and I'll be good.

Alright, so here's the code

function Set-ScriptAsScheduledTask
{
	[CmdletBinding()]
	param (
		[Parameter(Mandatory=$true)]
		[ValidateLength(5, 100)]
		[ValidateScript({
			if ($_ -match "\.ps1$")
			{
				$true
			}
			else
			{
				throw "ScriptName must end with '.ps1'"
			}
		})]
		[String]$ScriptName,

		[Parameter(Mandatory=$true)]
		[String]$ScriptPath,

		[Parameter(Mandatory=$true)]
		[ValidateSet("AtBoot", "AtLogon")]
		[String]$StartType
	)
	[String]$FullScriptPath = Join-Path -Path $ScriptPath -ChildPath $ScriptName #The full path of the script file
	[String]$ScheduledTaskName = $ScriptName.Replace(".ps1", "") #The name of the scheduled task to be created
	[String]$ScheduledTaskFolderDestination = "\"
	[String]$WindowsPowerShellExecutablePath = "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
	[String]$PowerShellExecutableArguments = "-ExecutionPolicy Bypass -File '$($FullScriptPath)'"

	#First we check if the function is being run as admin
	[Bool]$RunFromAdminContext = $false
	try
	{
		$RunFromAdminContext = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
	}
	catch
	{
		return $Error[0]
	}

	if ($RunFromAdminContext -eq $false)
	{
		throw "This function requires elevated privileges. Please ensure it is being run as an administrator."
	}


	#Now we need to validate that a file actually exists at the specified path with the specified name.
	[Bool]$ScriptFileExists = $false
	try
	{
		$ScriptFileExists = Test-Path -Path $FullScriptPath -PathType Leaf
	}
	catch
	{
		return $Error[0]
	}
	if ($ScriptFileExists -eq $false)
	{
		throw "The specified script file $($ScriptName) could not be found at $($ScriptPath)."
	}

	# Now we can interface with the Windows Task Scheduler API through Com
	$ScheduledTaskComObject = $null
	try
	{
		$ScheduledTaskComObject = New-Object -ComObject Schedule.Service -ErrorAction Stop
	}
	catch
	{
		return $Error[0]
	}
	if ($null -eq $ScheduledTaskComObject)
	{
		throw "Could not create Windows Task Scheduler API object."
	}
	# Attempt to connect to the Windows Task Scheduler API
	try
	{
		$ScheduledTaskComObject.Connect()
	}
	catch
	{
		return $Error[0]
	}

	# Create a Windows Task Scheduler task definition object
	$ScheduledTaskDefinitionObject = $null
	try
	{
		$ScheduledTaskDefinitionObject = $ScheduledTaskComObject.NewTask(0)
	}
	catch
	{
		return $Error[0]
	}
	if ($null -eq $ScheduledTaskDefinitionObject)
	{
		throw "Could not create Windows Scheduled Task definition object."
	}

	# Create a Windows Task Scheduler action object
	$ScheduledTaskActionObject = $null
	try
	{
		$ScheduledTaskActionObject = $ScheduledTaskDefinitionObject.Actions.Create(0)
	}
	catch
	{
		return $Error[0]
	}
	if ($null -eq $ScheduledTaskActionObject)
	{
		throw "Could not create Windows Scheduled Task action object."
	}
	try
	{
		$ScheduledTaskActionObject.Path = $WindowsPowerShellExecutablePath
		$ScheduledTaskActionObject.Arguments = $PowerShellExecutableArguments
	}
	catch
	{
		return $Error[0]
	}

	# Determine the trigger type based on StartType and rename it
	$ScheduledTaskTriggerType = switch ($StartType)
	{
		"AtBoot"
		{
			8
		} # TASK_TRIGGER_BOOT
		"AtLogon"
		{
			9
		} # TASK_TRIGGER_LOGON
		Default
		{
			throw "Specified value of $($StartType) is invalid."
		}
	}
	try
	{
		$ScheduledTaskDefinitionObject.Triggers.Create($ScheduledTaskTriggerType) | Out-Null
		$ScheduledTaskDefinitionObject.Settings.Enabled = $true
		$ScheduledTaskDefinitionObject.Settings.StartWhenAvailable = $true
	}
	catch
	{
		return $Error[0]
	}

	$ScheduledTaskFolderObject = $null
	try
	{
		$ScheduledTaskFolderObject = $ScheduledTaskComObject.GetFolder($ScheduledTaskFolderDestination)
	}
	catch
	{
		return $Error[0]
	}
	if ($null -eq $ScheduledTaskFolderObject)
	{
		throw "Windows Task Scheduler API was unable to get content from scheduled tasks path $($ScheduledTaskFolderDestination)"
	}

	#Now we register the scheduled task
	try
	{
		$ScheduledTaskFolderObject.RegisterTaskDefinition($ScheduledTaskName, $ScheduledTaskDefinitionObject, 6, "SYSTEM", $null, 5) | Out-Null
	}
	catch
	{
		return $Error[0]
	}

	$ScheduledTaskFolderObject = $null
	try
	{
		$ScheduledTaskFolderObject = $ScheduledTaskComObject.GetFolder($ScheduledTaskFolderDestination)
	}
	catch
	{
		return $Error[0]
	}
	
	if ($null -eq $ScheduledTaskFolderObject)
	{
		throw "Windows Task Scheduler API was unable to get content from scheduled tasks path $($ScheduledTaskFolderDestination)"
	}

	$RetrievedTask = $null
	try
	{
		$RetrievedTask = $ScheduledTaskFolderObject.GetTask($ScheduledTaskName)
	}
	catch
	{
		return $Error[0]
	}
	
	if ($null -eq $RetrievedTask)
	{
		throw "Could not find scheduled task with name $($ScheduledTaskName) in folder $($ScheduledTaskFolderDestination)"
	}

	$RetrievedTaskActions = $RetrievedTask.Definition.Actions
	$RetrievedTaskActionPath = ($RetrievedTaskActions | Select-Object -Property "Path").Path
	$RetrievedTaskActionArguments = ($RetrievedTaskActions | Select-Object -Property "Arguments").Arguments
	if (($RetrievedTaskActionPath -ne $WindowsPowerShellExecutablePath) -or ($RetrievedTaskActionArguments -ne $PowerShellExecutableArguments))
	{
		$ErrorMessage = "Retrieved scheduled task actions could not be validated.`nExpected action path: $($WindowsPowerShellExecutablePath)`nRetrieved action path: $($RetrievedTaskActionPath)`nExpected arguments: $($PowerShellExecutableArguments)`nRetrieved arguments: $($RetrievedTaskActionArguments)"
		throw $ErrorMessage
	}

	$RetrievedTaskTriggers = $RetrievedTask.Definition.Triggers
	$RetrievedTaskTriggerType = ($RetrievedTaskTriggers | Select-Object -Property "Type").Type
	if ($RetrievedTaskTriggerType -ne $ScheduledTaskTriggerType)
	{
		$ErrorMessage = "Retrieved scheduled task triggers could not be validated.`nExpected trigger type: $($ScheduledTaskTriggerType)`nRetrieved trigger type: $($RetrievedTaskTriggers)"
		throw $ErrorMessage
	}
	return
}        

So here's some new bits of functionality I've implemented that I think I'll carry forward in future functions.

Parameter validation

I've added some validation for parameters. I wanted to make sure that input data was valid before trying to go do things with it. So you'll notice that the "$ScriptName" parameter requires a minimum of 5 characters in length, AND requires the value to end in ".ps1". In effect, this means that you have a script with a minimum name length of 1 character and then the extension ".ps1".

Administrator runtime context

I have added a check at the very top of the function that ensures the function is being run from an administrator context. If it is not, an error is thrown.

Dealing with the Windows Task Scheduler API

So one of my requirements going in was to validate things were completed successfully before moving on. If I can't check that something actually worked, then I must assume it has failed. In this context, I found the built in scheduled tasks cmdlets completely inadequate.

There's a bunch of ways to skin this particular cat, but I discovered that the most direct way to deal with scheduled tasks in Windows is to use the API built into the operating system. It includes a bunch of methods that are useful when creating a scheduled task. It also allows me to directly compare the values I set with the values that are later retrieved.

More to come

This is a live development exercise. I am actively writing, rewriting, redesigning, rearchitecting, and then refactoring the code as I go. It's entirely likely that I'll even go back to previous articles and edit them to make them in line with any new development standards I come up with.

The next function I'm building will be useful really for anybody that does endpoint management on Windows systems. Stay tuned!


要查看或添加评论,请登录

William Ogle的更多文章

社区洞察

其他会员也浏览了