Private/Resolve-AzLocalExcludedSubscriptionId.ps1
|
function Resolve-AzLocalExcludedSubscriptionId { ######################################## <# .SYNOPSIS Parses an optional "excluded subscriptions" CSV and returns the validated set of Azure subscription IDs to exclude from Azure Resource Graph queries. .DESCRIPTION v0.9.1 single-source-of-truth parser for the optional subscription exclusion list. Given the path to a CSV file, this reads ONLY the "Subscription IDs" column (header matching is tolerant of casing, spacing and common variants such as 'SubscriptionId' / 'Subscription ID'), validates every value as a GUID, lowercases and de-duplicates the valid IDs, and records any invalid rows so the caller can warn. The file format is a normal CSV with the documented headers: Subscription IDs,Subscription Name,Comment / Notes Lines that are blank or begin with '#' (after optional leading whitespace) are treated as comments and ignored, so the shipped skeleton can carry operator guidance above the header row. Only the "Subscription IDs" column is consumed; the other columns are purely for human readability. A header-only file (no data rows) is VALID and returns an empty SubscriptionIds set - the caller is expected to surface a warning rather than fail, because an empty list simply means "exclude nothing". This helper performs NO Azure calls and has no side effects - it is a pure parse/validate function over a local file, so it is cheap and trivially unit-testable. .PARAMETER Path Path to the exclusion CSV file. Must exist; a missing file throws a terminating error (callers that want a soft "no file = no exclusions" behaviour should Test-Path first). .OUTPUTS [PSCustomObject] with: - Path : the resolved input path - Column : the header name that was matched as the subscription-id column (or $null if none / empty) - SubscriptionIds : [string[]] valid, lowercased, de-duplicated GUIDs - Skipped : [PSCustomObject[]] of { Value; Reason } for every non-empty row value that failed GUID validation - RowCount : count of data rows parsed (excluding header) .NOTES Author : AzLocal.UpdateManagement Version: 0.9.1 #> ######################################## [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [string]$Path ) Set-StrictMode -Version Latest if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Resolve-AzLocalExcludedSubscriptionId: file not found: '$Path'." } $validList = [System.Collections.Generic.List[string]]::new() $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $skipped = [System.Collections.Generic.List[object]]::new() # Read every line, then drop blank lines and '#' comment lines so the # shipped skeleton can carry guidance text above the header row. $rawLines = @(Get-Content -LiteralPath $Path -ErrorAction Stop) $dataLines = @($rawLines | Where-Object { $_ -ne $null -and $_.Trim().Length -gt 0 -and ($_.TrimStart() -notmatch '^#') }) if ($dataLines.Count -eq 0) { # No header at all (file empty or entirely comments/blank). return [PSCustomObject]@{ Path = $Path Column = $null SubscriptionIds = $validList.ToArray() Skipped = $skipped.ToArray() RowCount = 0 } } # Identify the subscription-id column from the header line. Normalisation # strips every non-alphanumeric char and lowercases, so 'Subscription IDs', # 'SubscriptionId' and 'Subscription ID' all map to the same token. $headerCells = @(($dataLines[0] -split ',') | ForEach-Object { $_.Trim().Trim('"').Trim() }) $targetHeader = $headerCells | Where-Object { ($_ -replace '[^a-zA-Z0-9]', '').ToLower() -in @('subscriptionid', 'subscriptionids') } | Select-Object -First 1 if (-not $targetHeader) { throw "Resolve-AzLocalExcludedSubscriptionId: '$Path' does not contain a 'Subscription IDs' column. Expected header: 'Subscription IDs,Subscription Name,Comment / Notes'." } $normTarget = ($targetHeader -replace '[^a-zA-Z0-9]', '').ToLower() $records = @($dataLines | ConvertFrom-Csv) foreach ($rec in $records) { # Find the subscription-id property on this record by normalised name # so trailing spaces in the header do not break the lookup. $prop = $rec.PSObject.Properties | Where-Object { ($_.Name -replace '[^a-zA-Z0-9]', '').ToLower() -eq $normTarget } | Select-Object -First 1 if (-not $prop) { continue } $value = [string]$prop.Value if ([string]::IsNullOrWhiteSpace($value)) { continue } $trimmed = $value.Trim() $parsed = [guid]::Empty if ([guid]::TryParse($trimmed, [ref]$parsed)) { $normalized = $parsed.ToString().ToLower() if ($seen.Add($normalized)) { $validList.Add($normalized) } } else { $skipped.Add([PSCustomObject]@{ Value = $trimmed Reason = 'NotAGuid' }) } } return [PSCustomObject]@{ Path = $Path Column = $targetHeader SubscriptionIds = $validList.ToArray() Skipped = $skipped.ToArray() RowCount = $records.Count } } |