src/pipeline/Get-ScoutCollectorDefinition.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Load and structurally validate a declarative collector definition (AB#5657/AB#5661). .DESCRIPTION Loads a `.psd1` collector definition with `Import-PowerShellDataFile` -- restricted-language parsing, so a definition that tried to smuggle a function call or a live variable reference back in fails to load rather than silently running as code. See `docs/design/decisions/declarative-collectors.md` for the schema this validates against. Used by both `Invoke-ScoutDeclarativeCollector` (to run one) and `tests/CollectorDefinitionSchema.Tests.ps1` (to validate every one in CI), so the two can never drift on what "a valid definition" means. .PARAMETER Path Path to the `.psd1` file. .OUTPUTS The loaded definition (PSCustomObject) with every optional key defaulted, so callers never need `$Definition.Foo ?? <default>` scattered through their own code. .NOTES Tracks ADO AB#5657 (Feature AB#5656, Epic AB#5638). #> function Get-ScoutCollectorDefinition { [CmdletBinding()] [OutputType([PSCustomObject])] Param( [Parameter(Mandatory, Position = 0)] [string]$Path ) if (-not (Test-Path -LiteralPath $Path)) { throw "Collector definition not found: $Path" } $Raw = Import-PowerShellDataFile -Path $Path $Errors = [System.Collections.Generic.List[string]]::new() $Warnings = [System.Collections.Generic.List[string]]::new() if (-not $Raw.ContainsKey('ResourceTypes') -or @($Raw.ResourceTypes).Count -eq 0) { $Errors.Add('ResourceTypes must be a non-empty array of Azure resource type strings.') } else { foreach ($Type in @($Raw.ResourceTypes)) { if ($Type -isnot [string] -or [string]::IsNullOrWhiteSpace($Type)) { $Errors.Add("ResourceTypes contains a non-string or empty entry: '$Type'.") } } } if (-not $Raw.ContainsKey('RowLoopVariable') -or [string]::IsNullOrWhiteSpace($Raw.RowLoopVariable)) { $Errors.Add('RowLoopVariable must name the variable field expressions use for the current resource (e.g. "1").') } if (-not $Raw.ContainsKey('Fields') -or @($Raw.Fields).Count -eq 0) { $Errors.Add('Fields must be a non-empty array of { Name; Expression } entries.') } else { $SeenNames = [System.Collections.Generic.HashSet[string]]::new() foreach ($Field in @($Raw.Fields)) { if ($Field -isnot [System.Collections.IDictionary]) { $Errors.Add('Every Fields entry must be a hashtable with Name and Expression.') continue } if (-not $Field.Contains('Name') -or [string]::IsNullOrWhiteSpace($Field.Name)) { $Errors.Add('A Fields entry is missing a non-empty Name.') } elseif (-not $SeenNames.Add($Field.Name)) { $Errors.Add("Fields declares '$($Field.Name)' more than once.") } if (-not $Field.Contains('Expression') -or [string]::IsNullOrWhiteSpace($Field.Expression)) { $Errors.Add("Field '$($Field.Name)' is missing a non-empty Expression.") } } } if (-not $Raw.ContainsKey('Export') -or $Raw.Export -isnot [System.Collections.IDictionary]) { $Errors.Add('Export must be a hashtable describing the Excel sheet.') } else { if ([string]::IsNullOrWhiteSpace($Raw.Export.WorksheetName)) { $Errors.Add('Export.WorksheetName must be a non-empty string.') } if (-not $Raw.Export.Contains('Columns') -or @($Raw.Export.Columns).Count -eq 0) { $Errors.Add('Export.Columns must be a non-empty ordered array of field names.') } if ($Raw.Export.Contains('Columns')) { # A WARNING, not a schema error: `Select-Object $Exc` in the original engine does not # require the property to exist either -- a name mismatch produces a silently blank # column rather than a failure. Two of the 13 converted Databases collectors have # exactly this pre-existing bug (Databases/RedisCache.ps1 exports 'Resource Group' # for a field processed as 'ResourceGroup'; Databases/SQLMI.ps1 exports # 'ActiveDirectoryOnlyAuthentication' for a field processed as # 'AzureADOnlyAuthentication' -- both columns have been blank in every shipped # release). Faithful conversion means keeping the bug, not silently fixing it here. $FieldNames = @(@($Raw.Fields) | Where-Object { $_ -is [System.Collections.IDictionary] -and $_.Contains('Name') } | ForEach-Object { $_.Name }) foreach ($Col in @($Raw.Export.Columns)) { if ($FieldNames -notcontains $Col) { $Warnings.Add("Export.Columns references '$Col', which is not a declared Field name -- this column will always be blank (pre-existing behaviour, not a conversion defect).") } } } # Unlike a Columns/Fields name mismatch, this one IS an error: a TagColumnsBefore naming a # column that does not exist would silently fall back to appending, reintroducing exactly # the column-reordering defect the key exists to prevent. if ($Raw.Export.Contains('TagColumnsBefore') -and -not [string]::IsNullOrWhiteSpace($Raw.Export.TagColumnsBefore)) { if (@($Raw.Export.Columns) -notcontains $Raw.Export.TagColumnsBefore) { $Errors.Add("Export.TagColumnsBefore is '$($Raw.Export.TagColumnsBefore)', which is not one of Export.Columns.") } } } if ($Raw.Contains('AdditionalRowLoops')) { foreach ($Loop in @($Raw.AdditionalRowLoops)) { if ($Loop -isnot [System.Collections.IDictionary] -or -not $Loop.Contains('Variable') -or -not $Loop.Contains('Source')) { $Errors.Add('Every AdditionalRowLoops entry must be a hashtable with Variable and Source.') } } } if (@($Errors).Count -gt 0) { throw "Collector definition '$Path' failed schema validation:`n - $($Errors -join "`n - ")" } [PSCustomObject]@{ Path = $Path ResourceTypes = @($Raw.ResourceTypes) AdditionalFilter = if ($Raw.Contains('AdditionalFilter')) { $Raw.AdditionalFilter } else { $null } RowLoopVariable = $Raw.RowLoopVariable Preamble = if ($Raw.Contains('Preamble')) { [string]$Raw.Preamble } else { '' } AdditionalRowLoops = @(if ($Raw.Contains('AdditionalRowLoops')) { @($Raw.AdditionalRowLoops) } else { @() }) Fields = @($Raw.Fields) Export = [PSCustomObject]@{ WorksheetName = $Raw.Export.WorksheetName TableNamePrefix = if ($Raw.Export.Contains('TableNamePrefix')) { $Raw.Export.TableNamePrefix } else { $Raw.Export.WorksheetName + '_' } Columns = @($Raw.Export.Columns) TagColumns = @(if ($Raw.Export.Contains('TagColumns')) { @($Raw.Export.TagColumns) } else { @('Tag Name', 'Tag Value') }) # Name of the Columns entry the tag block is inserted BEFORE when $InTag is set. # $null (or absent) means "append at the end", which is what the schema's first draft # always did -- and was wrong for all 13 Databases collectors, every one of which adds # 'Resource U' after its `if ($InTag)` block. TagColumnsBefore = if ($Raw.Export.Contains('TagColumnsBefore')) { $Raw.Export.TagColumnsBefore } else { $null } NumberFormat = if ($Raw.Export.Contains('NumberFormat')) { $Raw.Export.NumberFormat } else { '0' } ConditionalText = @(if ($Raw.Export.Contains('ConditionalText')) { @($Raw.Export.ConditionalText) } else { @() }) } SourceCollector = if ($Raw.Contains('SourceCollector')) { $Raw.SourceCollector } else { $null } SchemaWarnings = @($Warnings) } } |