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