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 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, [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 } 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") $LogonType = if ($WellKnownServiceAccounts -contains $ServiceAccountUser.Trim() -or $ServiceAccountUser.Trim().EndsWith('$')) { "ServiceAccount" } else { "S4U" } $Principal = New-ScheduledTaskPrincipal -UserId $ServiceAccountUser -LogonType $LogonType -RunLevel Highest $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. Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Principal $Principal -Settings $Settings -Force |