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 }
}