Assets/New-OpsJob.ps1
|
<# .SYNOPSIS Creates or Updates an Ops Job (Scheduled Task). .DESCRIPTION Registers a Windows Scheduled Task that executes the JobRunner. If the task exists, it updates it (Idempotent). All job settings are handed to JobRunner.ps1 as a single Base64-encoded JSON payload. Building a command line by string concatenation loses hashtable arguments, breaks on values containing spaces or quotes, mis-binds values that start with '-', and lets a crafted job name inject extra parameters. .PARAMETER JobName Name of the job. .PARAMETER ScriptPath Path to the script to execute. .PARAMETER ScheduleTime Time to run the job (e.g., "03:00"). .PARAMETER ServiceAccountUser User to run the task as. Defaults to SYSTEM if not provided. .PARAMETER ServiceAccountCredential Credential of the account to run the task as, registered with the Password logon type. Required for workloads that authenticate to another machine (SharePoint's SQL databases, file shares, remote WMI): the S4U logon type used otherwise produces a token without network credentials, so those connections fail even though the task itself runs under the right user. .PARAMETER Interval Repetition interval (e.g., "01:00:00" to repeat hourly). .PARAMETER ScriptArguments Arguments for the script. Hashtable for named PowerShell parameters, array for positional / Node.js arguments. .PARAMETER EmailRecipients Emails for alerts. .PARAMETER AlertWebhookUrl Webhook URL for alerts. .PARAMETER RequiredSecrets Secrets to inject. #> param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$JobName, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$ScriptPath, [Parameter(Mandatory = $true)] [datetime]$ScheduleTime, [string]$ServiceAccountUser, [System.Management.Automation.PSCredential]$ServiceAccountCredential, [timespan]$Interval, [object]$ScriptArguments, [string[]]$EmailRecipients = @(), [string]$AlertWebhookUrl, [string[]]$RequiredSecrets = @() ) $ErrorActionPreference = "Stop" # Task names may not contain path separators or the characters Task Scheduler # reserves; reject them rather than producing a task nobody can address. if ($JobName -match '[\\/:*?"<>|]') { throw "JobName '$JobName' contains characters that are invalid in a scheduled task name (\ / : * ? "" < > |)." } # The Ops root defaults to C:\Ops. OPS_ROOT overrides it for development and # testing, matching the dashboard server's existing convention. $OpsRoot = if ([string]::IsNullOrWhiteSpace($env:OPS_ROOT)) { "C:\Ops" } else { $env:OPS_ROOT } $RunnerPath = Join-Path (Join-Path $OpsRoot "Bin") "JobRunner.ps1" if (-not (Test-Path -Path $RunnerPath)) { throw "JobRunner not found at '$RunnerPath'. Run Initialize-OrchestratorEnvironment first." } # Normalise ScriptArguments: preserve a hashtable as a hashtable (named # parameters) and anything else as an array (positional arguments). $NormalisedArgs = @() if ($null -ne $ScriptArguments) { if ($ScriptArguments -is [System.Collections.IDictionary]) { $Ordered = [ordered]@{} foreach ($Key in $ScriptArguments.Keys) { $Ordered[[string]$Key] = $ScriptArguments[$Key] } $NormalisedArgs = $Ordered } else { $NormalisedArgs = @($ScriptArguments) } } $Payload = @{ JobName = $JobName ScriptPath = $ScriptPath ScriptArguments = $NormalisedArgs EmailRecipients = @($EmailRecipients) AlertWebhookUrl = $AlertWebhookUrl RequiredSecrets = @($RequiredSecrets) } | ConvertTo-Json -Depth 10 -Compress # Base64 of UTF-8 JSON: a single argv token with no quoting or escaping needed. $PayloadBase64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Payload)) $RunnerArgs = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$RunnerPath`" -PayloadBase64 $PayloadBase64" $Action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $RunnerArgs # A daily trigger has no repetition; only a one-time trigger accepts an interval, # and an interval without a duration is rejected by the Task Scheduler service. if ($PSBoundParameters.ContainsKey('Interval') -and $Interval -gt [timespan]::Zero) { $Trigger = New-ScheduledTaskTrigger -Once -At $ScheduleTime -RepetitionInterval $Interval -RepetitionDuration ([timespan]::MaxValue) } else { $Trigger = New-ScheduledTaskTrigger -Daily -At $ScheduleTime } # A credential names the account itself; a separate -ServiceAccountUser that # disagrees with it would silently be ignored by Register-ScheduledTask. if ($ServiceAccountCredential) { if (-not [string]::IsNullOrWhiteSpace($ServiceAccountUser) -and $ServiceAccountUser.Trim() -ne $ServiceAccountCredential.UserName) { throw "-ServiceAccountUser ('$ServiceAccountUser') and -ServiceAccountCredential ('$($ServiceAccountCredential.UserName)') name different accounts. Pass only the credential." } $ServiceAccountUser = $ServiceAccountCredential.UserName } if ([string]::IsNullOrWhiteSpace($ServiceAccountUser)) { $ServiceAccountUser = "SYSTEM" } # LogonType ServiceAccount is only valid for the built-in service accounts and # group managed service accounts. A normal user account needs S4U (run whether # logged on or not, without storing a password). $WellKnownServiceAccounts = @("SYSTEM", "NT AUTHORITY\SYSTEM", "LOCAL SERVICE", "NT AUTHORITY\LOCAL SERVICE", "NETWORK SERVICE", "NT AUTHORITY\NETWORK SERVICE") $IsWellKnown = ($WellKnownServiceAccounts -contains $ServiceAccountUser.Trim() -or $ServiceAccountUser.Trim().EndsWith('$')) if ($ServiceAccountCredential -and $IsWellKnown) { throw "'$ServiceAccountUser' is a built-in service account and cannot be registered with a password. Pass -ServiceAccountUser instead of -ServiceAccountCredential." } $Settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable $TaskName = "OpsJob-$JobName" $ExistingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($ExistingTask) { Write-Host "Updating existing OpsJob: $JobName" } else { Write-Host "Registering new OpsJob: $JobName" } # Register with -Force both creates and replaces. Set-ScheduledTask cannot change # a task's principal logon type, so it would silently keep the old identity. $RegisterParams = @{ TaskName = $TaskName Action = $Action Trigger = $Trigger Settings = $Settings Force = $true } if ($ServiceAccountCredential) { # -User/-Password and -Principal are mutually exclusive parameter sets, so the # password path sets the identity directly instead of via a principal object. $RegisterParams["User"] = $ServiceAccountUser $RegisterParams["Password"] = $ServiceAccountCredential.GetNetworkCredential().Password $RegisterParams["RunLevel"] = "Highest" } else { $LogonType = if ($IsWellKnown) { "ServiceAccount" } else { "S4U" } $RegisterParams["Principal"] = New-ScheduledTaskPrincipal -UserId $ServiceAccountUser -LogonType $LogonType -RunLevel Highest } try { Register-ScheduledTask @RegisterParams } finally { # Do not leave the plaintext password sitting in the splat table for the rest # of the session. if ($RegisterParams.ContainsKey("Password")) { $RegisterParams["Password"] = $null } } |