src/pipeline/Invoke-ScoutDeclarativeCollector.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Run one declarative collector definition for the Processing or Reporting task (AB#5657/AB#5659). .DESCRIPTION The one interpreter every converted `.psd1` collector definition shares. It contains no collector-specific knowledge: it does not know what a "retirement lookup" or a "tag" is, it just filters `$Resources`, runs the definition's own `Preamble` once per matched resource (verbatim source lifted from the original collector -- see `docs/design/decisions/declarative-collectors.md`), evaluates each `Fields` entry's expression in the scope that preamble just populated, and exports the result the same way `Invoke-ScoutCollector` would for a hand-written `.ps1`. PROCESSING builds one script per matched resource: <Preamble, verbatim> foreach ($<AdditionalRowLoop.Variable> in <AdditionalRowLoop.Source>) { # 0 or more, nested <AdditionalRowLoop.Preamble, verbatim> foreach ($<TagLoop.Variable> in <TagLoop.Source>) { # omitted when TagLoop is $null @{ '<Field.Name>' = <Field.Expression>; ... } if ($ResUCount -eq 1) { $ResUCount = 0 } } } run with `RowLoopVariable`, `SUB`, `Retirements` and `Unsupported` pre-bound via `ScriptBlock.InvokeWithContext` -- the same technique that keeps the audit's AST extraction (AB#5658) safe, applied here to execution instead of parsing. One execution per RESOURCE, not per row, matches the original collectors' own granularity; running Preamble once per emitted row instead would still be correct (it is a pure function of the resource) but wastefully re-derives the same locals for every tag. REPORTING rebuilds the `$Exc` column list, conditional-formatting rules and Excel style from the definition's `Export` section and calls `Export-Excel` exactly as the original collector's Reporting branch did. .PARAMETER Definition A definition object from `Get-ScoutCollectorDefinition`. .PARAMETER Context The same context hashtable `Invoke-ScoutCollector` accepts: Resources, Subscriptions, InTag, Retirements, Unsupported, Task, File, SmaResources, TableStyle. .OUTPUTS Processing: an array of row hashtables. Reporting: nothing (writes to Context.File). .NOTES Tracks ADO AB#5657/AB#5659/AB#5656 (Feature AB#5656, Epic AB#5638). This is the live collector path. `Invoke-ScoutCollector` routes every catalog entry here, so a defect in this interpreter is a defect in a customer's report. Golden row and workbook contracts preserve the behavior independently of the retired source scripts. #> function Invoke-ScoutDeclarativeCollector { [CmdletBinding()] Param( [Parameter(Mandatory, Position = 0)] [PSCustomObject]$Definition, [Parameter(Mandatory, Position = 1)] [hashtable]$Context ) if ($Context['Task'] -eq 'Processing') { return Invoke-ScoutDeclarativeProcessing -Definition $Definition -Context $Context } return Invoke-ScoutDeclarativeReporting -Definition $Definition -Context $Context } function Build-ScoutDeclarativeRowScript { <# Compose the per-resource script text described in the function help above. #> param([PSCustomObject]$Definition) # In 88 lifted collectors, the retirement fold declares `$Retire` but then reads the # surrounding collection as `$Retired.ServiceID`. That happened to work for one item with # StrictMode off, but fails as soon as the fixture exercises the documented many-retirement # case. The per-item variable is the only correct binding; repair this known mechanical # legacy typo during compilation rather than weaken StrictMode or encode it 88 times. $PreambleText = [string]$Definition.Preamble -replace '\$Retired\.ServiceID', '$Retire.ServiceID' # Expressions are emitted UNWRAPPED, exactly as the original collector's own `$obj = @{ ... }` # hashtable literal wrote them. Two separate reasons, each of which broke a real collector when # the first draft wrapped them in `( ... )`: # # * A field's source text is not always an EXPRESSION. `Containers/ARO.ps1` and # `Containers/ContainerRegistries.ps1` (among others) write a field as a multi-line # `if (...) { ... } else { ... }` STATEMENT. A hashtable literal accepts a statement as a # value; `( ... )` accepts only a pipeline, so `('Name' = (if (...) ...))` is a PARSE error # -- "The term 'if' is not recognized as a name of a cmdlet". Every field of every such # collector was unreachable. # * `$( ... )` is not a safe substitute either: a subexpression collects the output STREAM, # so `$(@(1))` is the scalar 1 and `$(@())` is `$null`, where the original produced a # one-element array and an empty array respectively. Silently changing the shape of an # array-valued field ('Zone' and friends) is exactly the class of difference the # equivalence proof exists to catch. # # Not wrapping at all is therefore both the compatible AND the faithful choice: the interpreter # reproduces the original hashtable literal, character for character, per field. $FieldLines = (@($Definition.Fields) | ForEach-Object { $QuotedName = $_.Name -replace "'", "''" " '$QuotedName' = $($_.Expression)" }) -join "`n" # Each extra loop may carry its OWN preamble -- the statements the original collector runs once # per item of the fanned-out collection, before it starts emitting rows for that item. Without # this, `Security/Vault.ps1` (which derives its three permission strings per access policy) and # `Networking/NATGateway.ps1` and `Networking/VirtualNetwork.ps1` (per subnet) silently produced # $null for every field computed inside the loop, where the original produced ''. The row-level # Preamble cannot cover it: those statements depend on the loop variable, which does not exist # yet when the row preamble runs. # The tag loop is the LAST entry of the loop nest, not a fixed frame around the row -- 25 # collectors have no tag loop at all and RouteTables calls its variable $TagKey, so both the # existence and the naming come from the definition. $Nest = [System.Collections.Generic.List[object]]::new() foreach ($Loop in @($Definition.AdditionalRowLoops)) { $Nest.Add($Loop) } if ($Definition.TagLoop) { $Nest.Add($Definition.TagLoop) } $OpenLoops = '' $CloseLoops = '' $LoopIndex = 0 foreach ($Loop in $Nest) { $LoopIndex++ $EmitNullWhenEmpty = if ($Loop -is [System.Collections.IDictionary]) { $Loop.Contains('EmitNullWhenEmpty') -and [bool]$Loop['EmitNullWhenEmpty'] } else { (@($Loop.PSObject.Properties.Name) -contains 'EmitNullWhenEmpty') -and [bool]$Loop.EmitNullWhenEmpty } if ($EmitNullWhenEmpty) { # A conditional imperative branch can emit a row even when an associated collection # is empty. Preserve that topology generically by materialising one null loop item; # do it before foreach because a subexpression drops a bare null from its output # stream. The generated variable is unique per nesting level and never visible to # collector fields. $LoopValues = "`$__scoutLoopValues$LoopIndex" $OpenLoops += "$LoopValues = @($($Loop.Source))`n" $OpenLoops += "if (@($LoopValues).Count -eq 0) { $LoopValues = @(`$null) }`n" $OpenLoops += "foreach (`$$($Loop.Variable) in $LoopValues) {`n" } else { $OpenLoops += "foreach (`$$($Loop.Variable) in $($Loop.Source)) {`n" } # Legacy collectors use the string sentinel '0' when a resource has no tags, then # read `$Tag.Name` / `$Tag.Value`. With StrictMode that sentinel has no such members. # Preserve the legacy row shape explicitly: an untagged row has empty tag cells, rather # than weakening StrictMode for every lifted definition. Do this only for definitions # that actually use the property form; collectors such as RouteTables intentionally use # their tag-loop value directly. $UsesTagNameOrValue = $false if ($Definition.TagLoop -and $Loop.Variable -eq $Definition.TagLoop.Variable) { $TagMemberPattern = '\$' + [regex]::Escape([string]$Loop.Variable) + '\.(Name|Value)\b' $UsesTagNameOrValue = @($Definition.Fields | Where-Object { [regex]::IsMatch([string]$_.Expression, $TagMemberPattern) }).Count -gt 0 } if ($UsesTagNameOrValue) { $OpenLoops += "if (`$null -eq `$$($Loop.Variable) -or `$$($Loop.Variable).PSObject.Properties.Match('Name').Count -eq 0 -or `$$($Loop.Variable).PSObject.Properties.Match('Value').Count -eq 0) { `$$($Loop.Variable) = [pscustomobject]@{ Name = `$null; Value = `$null } }`n" } # Get-ScoutCollectorDefinition normalises every entry to Variable/Source/Preamble, so the # key is always present here even when the original loop body had no setup statements. if (-not [string]::IsNullOrWhiteSpace($Loop.Preamble)) { $OpenLoops += "$($Loop.Preamble)`n" } $CloseLoops = "}`n" + $CloseLoops } # A deliberately absent subscription is one of the canonical fixture cases. The original # collectors rendered an empty Subscription cell because they ran without StrictMode; make # that contract explicit for the conventional `$sub*` lookup locals without loosening the # interpreter. Other preamble locals are intentionally not papered over here: those require # a collector-specific definition/fixture correction. $SubscriptionFallbacks = [System.Collections.Generic.List[string]]::new() $DeclaredSubscriptions = [regex]::Matches($PreambleText, '\$(sub\w*)\s*=') | ForEach-Object { $_.Groups[1].Value } | Select-Object -Unique foreach ($VariableName in $DeclaredSubscriptions) { if ([regex]::IsMatch(($Definition.Fields.Expression -join "`n"), '\$' + [regex]::Escape($VariableName) + '\.Name\b')) { $SubscriptionFallbacks.Add("if (-not (Test-Path -LiteralPath 'variable:$VariableName') -or `$null -eq `$$VariableName) { `$$VariableName = [pscustomobject]@{ Name = `$null } }") } } # --- StrictMode 1.0 for the row scope ONLY (AB#6839) ----------------------------------------- # # THE DEFECT. Under `Set-StrictMode -Version Latest`, reading a property an object does not # carry throws PropertyNotFoundException. A collector preamble reads its service's fields # straight off the payload -- `$data.virtualNetworkType`, `$data.integrationAccount.id` -- and # Azure omits an optional property whenever it has no value: a Data Box job that was never # cancelled has no `cancellationReason`, a Logic App with no integration account has no # `integrationAccount`. The row script is ONE statement, so the throw unwound the whole # collector and its ENTIRE worksheet was lost for that run. Not one row -- all of them. # # It was estate-wide, not new: `Integration/APIM` (shipped since v1) failed on # `virtualNetworkType` exactly as the newly added `Integration/LogicApps` failed on # `integrationAccount`. And no test could catch it, because # `scripts/New-ScoutCollectorFixture.ps1` derives each collector's estate FROM THAT COLLECTOR'S # OWN EXPRESSIONS -- every path a collector reads is present in its fixture by construction. # # WHY 1.0 AND NOT 'OFF'. Version 1.0 still errors on an UNINITIALISED VARIABLE, which is the # protection that actually matters here and the one AB#5671/5672 bought: a mistyped variable # name in a lifted preamble must not silently read $null. What 1.0 drops is precisely the # property check, which is a statement about the DATA rather than about the code, and which no # fixture can exercise. Trading it away is what makes a collector survive real Azure. # # WHY HERE AND NOT IN 236 MANIFESTS. Routing every property read through Get-AZSCSafeProperty # would touch every definition, churn every golden record, and leave the estate with two # conventions and no way to tell which one a given file follows. One line in the interpreter # fixes every collector, present and future, identically. # # THE COST, STATED. A genuine typo in a field expression (`$data.provisoningState`) now yields a # blank column instead of a loud failure. That is a real loss and it is the reason # `Export.Columns` is checked against declared field NAMES by the structural gate, and the # reason AB#6840's live row-count verification matters. # # SCOPE. `Set-StrictMode` applies to the current scope and its children and is undone when the # scope exits, so this relaxes nothing outside the row script -- verified, not assumed. $StrictPrologue = "Set-StrictMode -Version 1.0" $Body = @" $StrictPrologue $PreambleText $($SubscriptionFallbacks -join "`n") $OpenLoops @{ $FieldLines } if (`$ResUCount -eq 1) { `$ResUCount = 0 } $CloseLoops "@ if ([string]::IsNullOrWhiteSpace($Definition.RowCondition)) { return $Body } # The RowCondition is evaluated by the same relaxed scope: it reads the payload too. return "$StrictPrologue`nif ($($Definition.RowCondition)) {`n$Body`n}" } function Invoke-ScoutDeclarativeProcessing { [CmdletBinding()] Param( [Parameter(Mandatory)] [PSCustomObject]$Definition, [Parameter(Mandatory)] [hashtable]$Context ) # This interpreter is production engine code, not lifted collector source. Its inputs are # schema-normalised by Get-ScoutCollectorDefinition and the context is a hashtable, so keep # the module's StrictMode contract while it evaluates a definition. $ErrorActionPreference = 'Continue' $Resources = $Context['Resources'] $Sub = $Context['Subscriptions'] $Retirements = $Context['Retirements'] $Unsupported = $Context['Unsupported'] # ONE clock for the whole collector, bound into every row scope as $ScoutRunTime (AB#6741). # # A definition that needs "now" -- Management/Backup computing days since the last backup -- used # to call `get-date` inside its own preamble. Two consequences, both real: # # * the committed golden record for that collector changed VALUE every calendar day, so # `tests/DeclarativeCollectorGolden.Tests.ps1` failed on any day but the one it was recorded # on -- and it was failing when this work started; # * within a single production run each row read a slightly different instant, which is the # same class of non-determinism AB#5629 was about. # # A caller (the golden recorder and its test) may pin it through Context['RunTime']. Production # passes nothing and gets the run's start time, once. $ScoutRunTime = if ($Context.Contains('RunTime') -and $null -ne $Context['RunTime']) { [datetime]$Context['RunTime'] } else { Get-Date } # ROW ORDER IS THE SHEET'S ORDER, and how a multi-type collector builds its resource set decides # it. There are two shapes in the estate and they are NOT interchangeable: # # 'Grouped' -- one filtered pass per type, appended: # $RedisCache = $Resources | Where-Object { $_.TYPE -eq 'microsoft.cache/redis' } # $RedisCache += $Resources | Where-Object { $_.TYPE -eq 'microsoft.cache/redisenterprise' } # Every redis row precedes every redisenterprise row however the two interleave # in $Resources. # 'SinglePass' -- ONE pass whose condition admits several types: # $arcSites = $Resources | Where-Object { $_.TYPE -in @('microsoft.azurestackhci/sites', ...) } # Rows come out in $Resources order, types interleaved. # # `Hybrid/ArcSites.ps1` is the second shape, and interpreting it as the first reordered its # worksheet -- caught by the equivalence proof, which is why the mode is declared per collector # instead of assumed. For a single-type collector the two are identical. $Matched = if ($Definition.RowSource) { # A schema-validated projection lets any envelope fan out without service-specific # interpreter logic. Its input is limited to values the imperative Processing branch had. $SourceVariables = [System.Collections.Generic.List[psvariable]]::new() $SourceVariables.Add([psvariable]::new('Resources', $Resources)) $SourceVariables.Add([psvariable]::new('SUB', $Sub)) $SourceVariables.Add([psvariable]::new('Retirements', $Retirements)) $SourceVariables.Add([psvariable]::new('Unsupported', $Unsupported)) $RowSourceBlock = [scriptblock]::Create($Definition.RowSource.Expression) @($RowSourceBlock.InvokeWithContext($null, $SourceVariables)) } elseif ($Definition.ResourceTypeMatching -eq 'SinglePass') { @($Resources | Where-Object { @($Definition.ResourceTypes) -contains $_.TYPE }) } else { @(foreach ($Type in @($Definition.ResourceTypes)) { $Resources | Where-Object { $_.TYPE -eq $Type } }) } if ($Definition.AdditionalFilter) { # The filter may need locals the Processing branch set up before it -- `AI/AppliedAIServices.ps1` # tests `$appliedAIKinds -contains $_.KIND` against an array literal defined two lines earlier. # FilterPreamble carries those statements verbatim, prepended INSIDE the Where-Object block: # assignments emit nothing, so the block's only output is still the condition itself. $FilterText = if ([string]::IsNullOrWhiteSpace($Definition.FilterPreamble)) { $Definition.AdditionalFilter } else { "$($Definition.FilterPreamble)`n$($Definition.AdditionalFilter)" } # Same StrictMode 1.0 scope as the row script (AB#6839, reasoned at length in # Build-ScoutDeclarativeRowScript). The filter reads the payload too -- `$_.KIND` is $null # on most resource types and absent on some -- and a filter that throws loses the whole # collector before a single row is built. $ExtraFilter = [scriptblock]::Create("Set-StrictMode -Version 1.0`n$FilterText") $Matched = @($Matched | Where-Object $ExtraFilter) } if (@($Matched).Count -eq 0) { return @() } # ONCE-PER-COLLECTOR SETUP (AB#5659). The 15 collectors the audit called 'CrossResourceJoin' # hoist one or more secondary `$X = $Resources | Where-Object { $_.TYPE -eq '<other type>' }` # passes above their row loop and correlate against them per resource. Those statements are # lifted verbatim into SetupPreamble and run HERE -- once -- rather than inside the row script, # so a 5,000-NIC estate does not re-filter all 5,000 public IPs 5,000 times. # # The declared names are recovered by appending a `Get-Variable` per name to the lifted source # and reading the PSVariable objects back out of the invocation's output stream: there is no # other way to see the scope InvokeWithContext created. A name the preamble never assigned # comes back missing and THROWS -- it does not bind $null and carry on, because a join variable # that quietly became $null is a whole column of blanks in a shipped report. $SetupBindings = [System.Collections.Generic.List[psvariable]]::new() if (-not [string]::IsNullOrWhiteSpace($Definition.SetupPreamble)) { # -ErrorAction Ignore, not SilentlyContinue: SilentlyContinue still appends to $Error, and # Invoke-ScoutProcessing's AB#402 error detection reads $Error. $Harvest = (@($Definition.SetupVariables) | ForEach-Object { "Get-Variable -Name '$($_ -replace "'", "''")' -ErrorAction Ignore" }) -join "`n" # StrictMode 1.0 here too (AB#6839): a SetupPreamble is a second pass over $Resources for a # different type, and it reads that type's payload exactly as the row preamble does. $SetupScript = [scriptblock]::Create("Set-StrictMode -Version 1.0`n$($Definition.SetupPreamble)`n$Harvest") $SetupContext = [System.Collections.Generic.List[psvariable]]::new() $SetupContext.Add([psvariable]::new('Resources', $Resources)) $SetupContext.Add([psvariable]::new('SUB', $Sub)) $SetupContext.Add([psvariable]::new('Retirements', $Retirements)) $SetupContext.Add([psvariable]::new('Unsupported', $Unsupported)) $SetupContext.Add([psvariable]::new('ScoutRunTime', $ScoutRunTime)) $Produced = @($SetupScript.InvokeWithContext($null, $SetupContext)) $ByName = @{} foreach ($Item in $Produced) { # The lifted source may legitimately emit its own output (a bare expression statement), # so select the PSVariables out rather than assuming the stream is only the harvest. # # Tested with `-is` and used AS IS -- deliberately NOT unwrapped first. Items returned # through the output stream are PSObject-wrapped, but `.BaseObject` is not reachable by # plain member access on a wrapped object: member lookup resolves against the BASE type, # PSVariable has no BaseObject; reading it would be an invalid member access. Every # harvested variable was silently discarded that way, which is worth spelling out # because the symptom was not a blank column -- the declared-name check below turned it # into a throw naming all 13 collectors at once, which is the only reason it took # minutes to find rather than a release. if ($Item -is [psvariable]) { $ByName[$Item.Name] = $Item } } $MissingSetup = @(@($Definition.SetupVariables) | Where-Object { -not $ByName.ContainsKey($_) }) if (@($MissingSetup).Count -gt 0) { throw "Collector definition '$($Definition.Path)' declares SetupVariables its SetupPreamble never assigned: $($MissingSetup -join ', ')." } foreach ($Name in @($Definition.SetupVariables)) { $SetupBindings.Add($ByName[$Name]) } } $RowScriptText = Build-ScoutDeclarativeRowScript -Definition $Definition $RowScriptBlock = [scriptblock]::Create($RowScriptText) $Rows = foreach ($Resource in $Matched) { $Variables = [System.Collections.Generic.List[psvariable]]::new() $Variables.Add([psvariable]::new($Definition.RowLoopVariable, $Resource)) $Variables.Add([psvariable]::new('SUB', $Sub)) $Variables.Add([psvariable]::new('Retirements', $Retirements)) $Variables.Add([psvariable]::new('Unsupported', $Unsupported)) # Bound unconditionally, matching the original collectors' own scope: Networking/NetworkWatchers # does its three sub-resource joins INSIDE the row loop rather than hoisting them, and lifting # those statements without $Resources in scope produced three empty columns. $Variables.Add([psvariable]::new('Resources', $Resources)) $Variables.Add([psvariable]::new('ScoutRunTime', $ScoutRunTime)) foreach ($Binding in $SetupBindings) { $Variables.Add($Binding) } try { $RowScriptBlock.InvokeWithContext($null, $Variables) } catch [System.Management.Automation.MethodInvocationException] { # InvokeWithContext is a .NET method call, so SOME of what the row script throws comes # back with an extra layer: `Exception calling "InvokeWithContext" with "2" # argument(s): "<the real message>"`. The imperative collector -- same failure, same # data -- reports the inner message on its own, and that text is what reaches the # customer's run log through Invoke-ScoutCollector's warning. Leaving the layer on # would change the wording of every contained-collector failure on the release that # switches paths, for no reason connected to what actually failed. # # Unwrap ONE layer, and only when the inner exception is a PowerShell RuntimeException # -- measured, that is exactly the case where the layer was added. The two shapes: # # [datetime]$null -> MethodInvocationException("...InvokeWithContext...") # -> RuntimeException("Cannot convert null...") <- relayed # 'x'.Substring(9) -> MethodInvocationException("Exception calling "Substring"...") # -> ArgumentOutOfRangeException(...) <- NOT relayed # # In the second, InvokeWithContext adds nothing: the MethodInvocationException IS the # script's own error and the imperative path reports it verbatim, so unwrapping there # would strip a layer the imperative path keeps -- swapping one message difference for # another. Typed rather than matched on the word "InvokeWithContext", which is a # localisable string. $Inner = $_.Exception.InnerException if ($Inner -is [System.Management.Automation.RuntimeException]) { throw $Inner } throw } } @($Rows) } function Invoke-ScoutDeclarativeReporting { [CmdletBinding()] Param( [Parameter(Mandatory)] [PSCustomObject]$Definition, [Parameter(Mandatory)] [hashtable]$Context ) # Reporting consumes the same schema-normalised definition contract as Processing. Do not # weaken StrictMode here: missing export metadata is a definition defect and must fail loudly. $ErrorActionPreference = 'Continue' $SmaResources = $Context['SmaResources'] if (-not $SmaResources -or @($SmaResources).Count -eq 0) { return } $InTag = $Context['InTag'] $File = $Context['File'] $TableStyle = $Context['TableStyle'] $Export = $Definition.Export # Some legacy collectors intentionally emit detail rows without Resource U. Reporting # must treat those as a zero contribution rather than dereferencing a missing member # under StrictMode. $RowUnits = @($SmaResources | ForEach-Object { Get-AZSCSafeProperty -InputObject $_ -Path 'Resource U' } | Measure-Object -Sum).Sum $TableName = "$($Export.TableNamePrefix)$RowUnits" $Style = New-ExcelStyle -HorizontalAlignment Center -AutoSize -NumberFormat $Export.NumberFormat $ConditionalText = @(foreach ($Line in @($Export.ConditionalText)) { & ([scriptblock]::Create($Line)) }) # Column order is the sheet's contract. The original collectors build $Exc by calling # $Exc.Add(...) in source order with the two Tag columns added inside `if ($InTag)` -- which # is very often NOT at the end (all 13 Databases collectors add 'Resource U' afterwards). So # the tag block is INSERTED at the recorded position, not appended. $Columns = [System.Collections.Generic.List[string]]::new() foreach ($Col in @($Export.Columns)) { $Columns.Add($Col) } if ($InTag) { $InsertAt = $Columns.Count if (-not [string]::IsNullOrWhiteSpace($Export.TagColumnsBefore)) { $Found = $Columns.IndexOf($Export.TagColumnsBefore) if ($Found -ge 0) { $InsertAt = $Found } } foreach ($Col in @($Export.TagColumns)) { $Columns.Insert($InsertAt, $Col) $InsertAt++ } } $ExportParams = @{ Path = $File WorksheetName = $Export.WorksheetName AutoSize = $true MaxAutoSizeRows = 100 TableName = $TableName TableStyle = $TableStyle Style = $Style } if (@($ConditionalText).Count -gt 0) { $ExportParams['ConditionalText'] = $ConditionalText } try { $SmaResources | ForEach-Object { [PSCustomObject]$_ } | Select-Object $Columns | Export-Excel @ExportParams -ErrorAction Stop } catch { Write-Warning "Failed to export $($Definition.Category)/$($Definition.Name) to Excel. Is the file open? Error: $_" } } |