Runtime/Invoke-OSDStageServices.ps1

[CmdletBinding()]
param([string]$ConfigPath = (Join-Path $PSScriptRoot 'config.json'))

function Assert-OSDStageServiceOperation {
    param([Parameter(Mandatory)]$Operation)
    $name = [string]$Operation.Name
    if ([string]::IsNullOrWhiteSpace($name) -or $name.Length -gt 256 -or
        $name -ne $name.Trim() -or $name -match '["/\\*?\[\]\x00-\x1F]') {
        throw 'Use an exact service name of at most 256 characters, without wildcards, quotes, slashes or control characters.'
    }
    $startup = [string]$Operation.StartupType
    $state = [string]$Operation.State
    if (-not $startup -and -not $state) { throw 'Specify StartupType, State, or both.' }
    if ($startup -and $startup -notin @('Automatic','AutomaticDelayedStart','Manual','Disabled')) { throw 'Unsupported service startup type.' }
    if ($state -and $state -notin @('Running','Stopped')) { throw 'Unsupported service state.' }
    if ($startup -eq 'Disabled' -and $state -eq 'Running') { throw 'A disabled service cannot be requested Running.' }
    if ([int]$Operation.TimeoutSeconds -lt 1 -or [int]$Operation.TimeoutSeconds -gt 300) { throw 'TimeoutSeconds must be between 1 and 300.' }
}

function Assert-OSDStageServiceConfiguration {
    param([Parameter(Mandatory)]$Configuration)
    $id = [guid]::Empty
    if ([int]$Configuration.SchemaVersion -ne 2 -or
        -not [guid]::TryParse([string]$Configuration.DeploymentId, [ref]$id) -or
        [string]::IsNullOrWhiteSpace([string]$Configuration.ResultPath) -or
        @($Configuration.Operations).Count -eq 0) { throw 'Invalid Services configuration.' }
    $seen = @{}
    foreach ($operation in @($Configuration.Operations)) {
        Assert-OSDStageServiceOperation -Operation $operation
        if ($seen.ContainsKey([string]$operation.Name)) { throw 'Duplicate service operation.' }
        $seen[[string]$operation.Name] = $true
    }
}

function Get-OSDStageServiceState {
    param([Parameter(Mandatory)][string]$Name)
    $service = Get-Service -Name $Name -ErrorAction Stop
    try {
        if ($service.Name -ine $Name -or (([int]$service.ServiceType -band 48) -eq 0)) { throw 'Only exact Win32 service names are supported; driver services are excluded.' }
        $service.Refresh()
        $startup = [string]$service.StartType
        if ($startup -eq 'Automatic') {
            $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Services\' + $Name)
            try { if ($key -and [int]$key.GetValue('DelayedAutoStart', 0) -eq 1) { $startup = 'AutomaticDelayedStart' } }
            finally { if ($key) { $key.Dispose() } }
        }
        [pscustomobject]@{State=[string]$service.Status;StartupType=$startup}
    }
    finally { $service.Dispose() }
}

function Invoke-OSDStageServiceControl {
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][ValidateSet('config','start','stop')][string]$Action,
        [string]$StartupType,
        [int]$TimeoutSeconds = 30
    )
    # No shell interpretation, remote target, credentials or arbitrary sc.exe arguments.
    $arguments = '{0} "{1}"' -f $Action, $Name
    if ($Action -eq 'config') {
        $mode = @{Automatic='auto';AutomaticDelayedStart='delayed-auto';Manual='demand';Disabled='disabled'}[$StartupType]
        if (-not $mode) { throw 'Unsupported service startup type.' }
        $arguments += ' start= ' + $mode
    }
    $native = Join-Path $env:SystemRoot 'System32\sc.exe'
    if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) { $native = Join-Path $env:SystemRoot 'Sysnative\sc.exe' }
    $process = New-Object System.Diagnostics.Process
    try {
        $process.StartInfo.FileName = $native
        $process.StartInfo.Arguments = $arguments
        $process.StartInfo.UseShellExecute = $false
        $process.StartInfo.CreateNoWindow = $true
        $process.StartInfo.RedirectStandardOutput = $true
        $process.StartInfo.RedirectStandardError = $true
        $encoding = [Text.Encoding]::GetEncoding([Globalization.CultureInfo]::CurrentCulture.TextInfo.OEMCodePage)
        $process.StartInfo.StandardOutputEncoding = $encoding
        $process.StartInfo.StandardErrorEncoding = $encoding
        if (-not $process.Start()) { throw 'Unable to start service control utility.' }
        $stdout = $process.StandardOutput.ReadToEndAsync()
        $stderr = $process.StandardError.ReadToEndAsync()
        if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
            $process.Kill()
            $process.WaitForExit()
            throw 'Service control utility timed out; the requested change may already have taken effect.'
        }
        [pscustomobject]@{Command=('sc.exe ' + $arguments);ExitCode=$process.ExitCode;StandardOutput=$stdout.GetAwaiter().GetResult();StandardError=$stderr.GetAwaiter().GetResult()}
    }
    finally { $process.Dispose() }
}

function Wait-OSDStageServiceState {
    param([string]$Name, [string]$State, [int]$TimeoutSeconds)
    $service = Get-Service -Name $Name -ErrorAction Stop
    try { $service.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]$State, [TimeSpan]::FromSeconds($TimeoutSeconds)) }
    finally { $service.Dispose() }
}

function Invoke-OSDStageServiceOperation {
    param([Parameter(Mandatory)]$Operation)
    Assert-OSDStageServiceOperation -Operation $Operation
    $record = [ordered]@{
        Name=[string]$Operation.Name;StartupTypeRequested=[string]$Operation.StartupType;StateRequested=[string]$Operation.State
        Status='Failed';Outcome='Failed';Before=$null;After=$null
        ConfigurationCommand=$null;StateCommand=$null;Error=$null
    }
    try {
        $record.Before = Get-OSDStageServiceState -Name $Operation.Name
        $current = $record.Before
        if ($Operation.StartupType -and $current.StartupType -ne $Operation.StartupType) {
            $record.ConfigurationCommand = Invoke-OSDStageServiceControl -Name $Operation.Name -Action config -StartupType $Operation.StartupType -TimeoutSeconds $Operation.TimeoutSeconds
            if ($record.ConfigurationCommand.ExitCode -ne 0) { throw ('Service configuration failed with exit code {0}.' -f $record.ConfigurationCommand.ExitCode) }
            $current = Get-OSDStageServiceState -Name $Operation.Name
            if ($current.StartupType -ne $Operation.StartupType) { throw 'Requested startup type was not observed after service configuration.' }
        }
        if ($Operation.State -and $current.State -ne $Operation.State) {
            if ($Operation.State -eq 'Running' -and $current.StartupType -eq 'Disabled') { throw 'Service is disabled; explicitly request an enabled StartupType before starting it.' }
            $pending = if ($Operation.State -eq 'Running') { 'StartPending' } else { 'StopPending' }
            if ($current.State -ne $pending) {
                $action = if ($Operation.State -eq 'Running') { 'start' } else { 'stop' }
                $record.StateCommand = Invoke-OSDStageServiceControl -Name $Operation.Name -Action $action -TimeoutSeconds $Operation.TimeoutSeconds
                if ($record.StateCommand.ExitCode -ne 0) { throw ('Service state request failed with exit code {0}.' -f $record.StateCommand.ExitCode) }
            }
            Wait-OSDStageServiceState -Name $Operation.Name -State $Operation.State -TimeoutSeconds $Operation.TimeoutSeconds
        }
        $record.After = Get-OSDStageServiceState -Name $Operation.Name
        if ($Operation.StartupType -and $record.After.StartupType -ne $Operation.StartupType) { throw 'Requested startup type was not observed at the end of this operation.' }
        if ($Operation.State -and $record.After.State -ne $Operation.State) { throw 'Requested service state was not observed during this run.' }
        $record.Status = 'Succeeded'
        $record.Outcome = if ($record.ConfigurationCommand -or $record.StateCommand) { 'Applied' } elseif ($record.Before.State -ne $record.After.State) { 'PendingCompleted' } else { 'Unchanged' }
    }
    catch {
        $record.Error = $_.Exception.Message
        try { $record.After = Get-OSDStageServiceState -Name $Operation.Name } catch { }
    }
    [pscustomobject]$record
}

function Invoke-OSDStageServiceConfiguration {
    param([Parameter(Mandatory)]$Configuration)
    Assert-OSDStageServiceConfiguration -Configuration $Configuration
    $items = @(foreach ($operation in @($Configuration.Operations)) { Invoke-OSDStageServiceOperation -Operation $operation })
    $failed = @($items | Where-Object Status -eq 'Failed').Count
    [pscustomobject]@{
        SchemaVersion=2;DeploymentId=[string]$Configuration.DeploymentId
        Status=if ($failed) { 'Failed' } else { 'Succeeded' }
        OperationCount=$items.Count;SuccessfulOperationCount=($items.Count-$failed);FailedOperationCount=$failed
        RestartNeeded=$false;RebootRequested=$false;Items=$items
        Error=if ($failed) { "$failed service operation(s) failed." } else { $null }
    }
}

if ($MyInvocation.InvocationName -eq '.') { return }
$ErrorActionPreference = 'Stop'
try {
    if (Test-Path -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT') { throw 'Service runtime requires full Windows.' }
    $configuration = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
    $record = Invoke-OSDStageServiceConfiguration -Configuration $configuration
    $resultPath = [Environment]::ExpandEnvironmentVariables([string]$configuration.ResultPath)
    New-Item -ItemType Directory -Path (Split-Path $resultPath -Parent) -Force | Out-Null
    $record | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $resultPath -Encoding UTF8
    if ($record.Status -eq 'Failed') { exit 1 }
    exit 0
}
catch { Write-Error $_.Exception.Message -ErrorAction Continue; exit 1 }