Public/Get-AzLocalSideloadSettings.ps1

function Get-AzLocalSideloadSettings {
    <#
    .SYNOPSIS
        Reads and validates the authoritative sideload settings YAML.
    .DESCRIPTION
        Parses config/sideload-settings.yml, validates the supported schema and
        required path, reconciliation, copy-profile, task-identity, remoting, and
        reporting fields, then returns a normalized settings object. The file is
        read-only: this cmdlet never creates, overwrites, or migrates settings.
    .PARAMETER Path
        Path to sideload-settings.yml. Defaults to ./config/sideload-settings.yml.
    .OUTPUTS
        [PSCustomObject] containing the validated sideload settings.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$Path = (Join-Path -Path $PWD.Path -ChildPath 'config\sideload-settings.yml')
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "Get-AzLocalSideloadSettings: settings file not found at '$Path'. Run Copy-AzLocalPipelineExample to create it."
    }
    if (-not (Get-Module -Name powershell-yaml -ListAvailable)) {
        throw "Get-AzLocalSideloadSettings: powershell-yaml is required. Install with: Install-Module powershell-yaml -Scope CurrentUser"
    }

    Import-Module powershell-yaml -ErrorAction Stop
    $fullPath = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
    try {
        $rawConfig = ConvertFrom-Yaml -Yaml (Get-Content -LiteralPath $fullPath -Raw -ErrorAction Stop) -Ordered:$false
    }
    catch {
        throw "Get-AzLocalSideloadSettings: failed to parse '$fullPath': $($_.Exception.Message)"
    }
    $config = ConvertTo-AzLocalSideloadSettingsHashtable -InputObject $rawConfig
    if (-not $config -or -not $config.ContainsKey('schemaVersion')) {
        throw "Get-AzLocalSideloadSettings: '$fullPath' must declare schemaVersion."
    }
    if ([int]$config.schemaVersion -ne $script:SideloadSettingsSchemaCurrentVersion) {
        throw "Get-AzLocalSideloadSettings: '$fullPath' uses schemaVersion=$($config.schemaVersion); this module requires $($script:SideloadSettingsSchemaCurrentVersion). Upgrade AzLocal.UpdateManagement and regenerate the starter settings file."
    }

    foreach ($requiredSection in @('paths', 'planning', 'reconciliation', 'copy', 'identity', 'remoting', 'reporting')) {
        if (-not $config.ContainsKey($requiredSection) -or -not ($config[$requiredSection] -is [hashtable])) {
            throw "Get-AzLocalSideloadSettings: '$fullPath' is missing the '$requiredSection' section."
        }
    }
    foreach ($requiredPath in @('stateRoot', 'authMap', 'catalog', 'applySchedule')) {
        if (-not $config.paths.ContainsKey($requiredPath) -or [string]::IsNullOrWhiteSpace([string]$config.paths[$requiredPath])) {
            throw "Get-AzLocalSideloadSettings: paths.$requiredPath is required."
        }
    }

    $leadDays = [int]$config.planning.leadDays
    $heartbeatIntervalSeconds = [int]$config.reconciliation.heartbeatIntervalSeconds
    $heartbeatStaleMinutes = [int]$config.reconciliation.heartbeatStaleMinutes
    $noProgressMinutes = [int]$config.reconciliation.noProgressMinutes
    $maxConcurrentCopies = [int]$config.reconciliation.maxConcurrentCopies
    $maxConcurrentCopiesPerRunner = if ($config.reconciliation.ContainsKey('maxConcurrentCopiesPerRunner')) {
        [int]$config.reconciliation.maxConcurrentCopiesPerRunner
    }
    else {
        $maxConcurrentCopies
    }
    if ($leadDays -lt 0 -or $leadDays -gt 365) { throw 'Get-AzLocalSideloadSettings: planning.leadDays must be between 0 and 365.' }
    if ($heartbeatIntervalSeconds -lt 10 -or $heartbeatIntervalSeconds -gt 300) { throw 'Get-AzLocalSideloadSettings: reconciliation.heartbeatIntervalSeconds must be between 10 and 300.' }
    if ($heartbeatStaleMinutes -lt 1 -or $heartbeatStaleMinutes -gt 1440) { throw 'Get-AzLocalSideloadSettings: reconciliation.heartbeatStaleMinutes must be between 1 and 1440.' }
    if ($noProgressMinutes -lt 1 -or $noProgressMinutes -gt 1440) { throw 'Get-AzLocalSideloadSettings: reconciliation.noProgressMinutes must be between 1 and 1440.' }
    if ($maxConcurrentCopies -lt 1 -or $maxConcurrentCopies -gt 64) { throw 'Get-AzLocalSideloadSettings: reconciliation.maxConcurrentCopies must be between 1 and 64.' }
    if ($maxConcurrentCopiesPerRunner -lt 1 -or $maxConcurrentCopiesPerRunner -gt 64) { throw 'Get-AzLocalSideloadSettings: reconciliation.maxConcurrentCopiesPerRunner must be between 1 and 64.' }
    if ($maxConcurrentCopiesPerRunner -gt $maxConcurrentCopies) { throw 'Get-AzLocalSideloadSettings: reconciliation.maxConcurrentCopiesPerRunner cannot exceed reconciliation.maxConcurrentCopies.' }
    $config.reconciliation['maxConcurrentCopiesPerRunner'] = $maxConcurrentCopiesPerRunner

    $defaultProfileName = [string]$config.copy.defaultProfile
    if ([string]::IsNullOrWhiteSpace($defaultProfileName) -or -not $config.copy.profiles.ContainsKey($defaultProfileName)) {
        throw "Get-AzLocalSideloadSettings: copy.defaultProfile '$defaultProfileName' does not identify a configured copy profile."
    }
    $defaultProfile = $config.copy.profiles[$defaultProfileName]
    foreach ($profileField in @('retryCount', 'waitSeconds', 'interPacketGapMilliseconds', 'restartable', 'unbuffered')) {
        if (-not $defaultProfile.ContainsKey($profileField)) {
            throw "Get-AzLocalSideloadSettings: copy profile '$defaultProfileName' is missing '$profileField'."
        }
    }
    if ([int]$defaultProfile.retryCount -lt 0 -or [int]$defaultProfile.retryCount -gt 100) {
        throw "Get-AzLocalSideloadSettings: copy profile '$defaultProfileName' retryCount must be between 0 and 100."
    }
    if ([int]$defaultProfile.waitSeconds -lt 0 -or [int]$defaultProfile.waitSeconds -gt 3600) {
        throw "Get-AzLocalSideloadSettings: copy profile '$defaultProfileName' waitSeconds must be between 0 and 3600."
    }
    if ([int]$defaultProfile.interPacketGapMilliseconds -lt 0 -or [int]$defaultProfile.interPacketGapMilliseconds -gt 60000) {
        throw "Get-AzLocalSideloadSettings: copy profile '$defaultProfileName' interPacketGapMilliseconds must be between 0 and 60000."
    }
    if ([bool]$defaultProfile.restartable -and [bool]$defaultProfile.unbuffered) {
        throw "Get-AzLocalSideloadSettings: copy profile '$defaultProfileName' cannot enable both restartable and unbuffered modes."
    }

    if (-not $config.identity.ContainsKey('task') -or -not ($config.identity.task -is [hashtable])) {
        throw "Get-AzLocalSideloadSettings: identity.task is required."
    }
    $taskLogonType = [string]$config.identity.task.logonType
    if ($taskLogonType -notin @('ServiceAccount', 'Password')) {
        throw "Get-AzLocalSideloadSettings: identity.task.logonType must be ServiceAccount or Password for UNC access."
    }
    if ([bool]$config.enabled -and [string]::IsNullOrWhiteSpace([string]$config.identity.task.principalUserId)) {
        throw "Get-AzLocalSideloadSettings: identity.task.principalUserId is required when sideloading is enabled."
    }
    if ([bool]$config.enabled -and $taskLogonType -eq 'Password') {
        foreach ($secretField in @('passwordKeyVaultName', 'passwordSecretName')) {
            if ([string]::IsNullOrWhiteSpace([string]$config.identity.task[$secretField])) {
                throw "Get-AzLocalSideloadSettings: identity.task.$secretField is required when logonType is Password."
            }
        }
    }

    return [pscustomobject]@{
        SchemaVersion  = [int]$config.schemaVersion
        SourcePath     = $fullPath
        Enabled        = [bool]$config.enabled
        Paths          = $config.paths
        Planning       = $config.planning
        Reconciliation = $config.reconciliation
        Copy           = $config.copy
        Identity       = $config.identity
        Remoting       = $config.remoting
        Reporting      = $config.reporting
        Raw            = $config
    }
}