Public/Get-AzureLocalItsmConfig.ps1

function Get-AzureLocalItsmConfig {
    <#
    .SYNOPSIS
        Loads and validates the AzLocal.UpdateManagement ITSM connector config.
 
    .DESCRIPTION
        Reads a YAML (.yml / .yaml) or JSON (.json) configuration file and
        returns a strongly typed config object suitable for hand-off to
        New-AzureLocalIncident, Test-AzureLocalItsmConnection, and (in
        Phase 2) Sync-AzureLocalIncident.
 
        YAML parsing requires the 'powershell-yaml' module. If the input is
        YAML but the module is not available, an actionable error is thrown.
        JSON works on stock PowerShell 5.1+.
 
        Validation enforces:
          - schemaVersion = 1
          - secrets.source in (keyvault, envvar, mixed)
          - defaults.itsmTarget = ServiceNow (v0.7.4 supports SN only)
          - At least one trigger with raiseTicket: true
          - Severity values in 1..5
 
        See AzLocal.UpdateManagement/ITSM/ITSM-Connector-Plan.md Section 5
        and ITSM/ITSM-Config-Reference.md for the full schema.
 
    .EXAMPLE
        $cfg = Get-AzureLocalItsmConfig -Path ./.itsm/azurelocal-itsm.yml
        $cfg.Triggers['Failed'].Severity
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Path
    )

    if (-not (Test-Path -Path $Path -PathType Leaf)) {
        throw "ITSM config file not found: $Path"
    }

    $ext = [IO.Path]::GetExtension($Path).ToLowerInvariant()
    $raw = Get-Content -Path $Path -Raw

    switch ($ext) {
        '.json' {
            try {
                $data = $raw | ConvertFrom-Json -ErrorAction Stop
            }
            catch {
                throw "Failed to parse JSON config '$Path': $($_.Exception.Message)"
            }
            $config = ConvertTo-AzLocalItsmConfigHashtable -InputObject $data
        }
        { $_ -in '.yml','.yaml' } {
            if (-not (Get-Module -Name powershell-yaml -ListAvailable)) {
                throw "YAML config '$Path' requires the 'powershell-yaml' module. Install with: Install-Module powershell-yaml -Scope CurrentUser"
            }
            Import-Module powershell-yaml -ErrorAction Stop
            try {
                $config = ConvertFrom-Yaml -Yaml $raw -Ordered:$false
            }
            catch {
                throw "Failed to parse YAML config '$Path': $($_.Exception.Message)"
            }
            # powershell-yaml versions return assorted dictionary types
            # (Hashtable, OrderedDictionary, Dictionary[object,object]).
            # Normalise to a tree of case-insensitive @{} hashtables so
            # downstream lookups (ContainsKey, indexer) behave identically
            # whether the source was JSON or YAML.
            $config = ConvertTo-AzLocalItsmConfigHashtable -InputObject $config
        }
        default {
            throw "Unsupported ITSM config file extension '$ext'. Use .yml, .yaml, or .json."
        }
    }

    if (-not $config -or -not ($config -is [hashtable] -or $config -is [System.Collections.IDictionary])) {
        throw "ITSM config '$Path' did not parse to a dictionary."
    }

    Test-AzLocalItsmConfigShape -Config $config -SourcePath $Path

    # Surface a normalised object with case-stable property names.
    $secrets   = $config['secrets']
    $defaults  = $config['defaults']
    $triggers  = $config['triggers']
    $lifecycle = $config['lifecycle']
    $mirror    = $config['mirror']
    $storage   = $config['storage']

    $normalisedTriggers = @{}
    foreach ($key in $triggers.Keys) {
        $entry = $triggers[$key]
        if (-not $entry) { continue }
        $normalisedTriggers[$key] = @{
            RaiseTicket = [bool]($entry['raiseTicket'])
            Severity    = if ($entry.ContainsKey('severity')) { [int]$entry['severity'] } else { 3 }
            Category    = if ($entry.ContainsKey('category')) { [string]$entry['category'] } else { $null }
            MirrorTo    = if ($entry.ContainsKey('mirrorTo')) { @($entry['mirrorTo']) } else { $null }
        }
    }

    return [pscustomobject]@{
        SchemaVersion = [int]$config['schemaVersion']
        SourcePath    = (Resolve-Path -Path $Path).Path
        Secrets       = $secrets
        Defaults      = $defaults
        Triggers      = $normalisedTriggers
        Lifecycle     = $lifecycle
        Mirror        = $mirror
        Storage       = $storage
        Raw           = $config
    }
}

function ConvertTo-AzLocalItsmConfigHashtable {
    <#
    .SYNOPSIS
        Converts a ConvertFrom-Json pscustomobject tree into a hashtable tree
        so callers can use [hashtable]/.ContainsKey() uniformly with YAML.
    #>

    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory = $true)][AllowNull()][object]$InputObject
    )

    if ($null -eq $InputObject) { return $null }

    if ($InputObject -is [System.Collections.IDictionary]) {
        $ht = @{}
        foreach ($k in $InputObject.Keys) {
            $ht[[string]$k] = ConvertTo-AzLocalItsmConfigHashtable -InputObject $InputObject[$k]
        }
        return $ht
    }

    if ($InputObject -is [pscustomobject]) {
        $ht = @{}
        foreach ($prop in $InputObject.PSObject.Properties) {
            $ht[$prop.Name] = ConvertTo-AzLocalItsmConfigHashtable -InputObject $prop.Value
        }
        return $ht
    }

    if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
        $list = @()
        foreach ($item in $InputObject) {
            $list += ,(ConvertTo-AzLocalItsmConfigHashtable -InputObject $item)
        }
        return ,$list
    }

    return $InputObject
}

function Test-AzLocalItsmConfigShape {
    <#
    .SYNOPSIS
        Validates the shape of a parsed ITSM config hashtable. Throws on error.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][hashtable]$Config,
        [Parameter(Mandatory = $true)][string]$SourcePath
    )

    if (-not $Config.ContainsKey('schemaVersion')) {
        throw "ITSM config '$SourcePath' missing required field 'schemaVersion'."
    }
    if ([int]$Config['schemaVersion'] -ne 1) {
        throw "ITSM config '$SourcePath' schemaVersion is $($Config['schemaVersion']); this module supports schemaVersion: 1."
    }

    foreach ($required in 'secrets','defaults','triggers') {
        if (-not $Config.ContainsKey($required)) {
            throw "ITSM config '$SourcePath' missing required top-level section '$required'."
        }
    }

    $allowedSources = 'keyvault','envvar','mixed'
    $src = [string]$Config['secrets']['source']
    if ($src -notin $allowedSources) {
        throw "ITSM config '$SourcePath' secrets.source='$src' is invalid. Use one of: $($allowedSources -join ', ')."
    }
    if ($src -in 'keyvault','mixed' -and [string]::IsNullOrWhiteSpace([string]$Config['secrets']['keyvaultName'])) {
        throw "ITSM config '$SourcePath' secrets.source=$src but secrets.keyvaultName is not set."
    }

    $target = [string]$Config['defaults']['itsmTarget']
    if ($target -ne 'ServiceNow') {
        throw "ITSM config '$SourcePath' defaults.itsmTarget='$target' is not supported in v0.7.4. Only 'ServiceNow' is supported."
    }

    $hasAtLeastOneRaise = $false
    foreach ($key in $Config['triggers'].Keys) {
        $entry = $Config['triggers'][$key]
        if (-not $entry) { continue }
        if ($entry['raiseTicket']) { $hasAtLeastOneRaise = $true }
        if ($entry.ContainsKey('severity')) {
            $sev = [int]$entry['severity']
            if ($sev -lt 1 -or $sev -gt 5) {
                throw "ITSM config '$SourcePath' triggers.$key.severity=$sev is out of range. Use 1..5."
            }
        }
    }

    if (-not $hasAtLeastOneRaise) {
        Write-Warning "ITSM config '$SourcePath' has no triggers with raiseTicket=true. No tickets will be raised."
    }
}