modules/shared/ReportManifest.ps1
|
#Requires -Version 7.4 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:ReportArchitectureTiers = @('PureJson', 'EmbeddedSqlite', 'SidecarSqlite', 'PodeViewer') function Get-DefaultReportArchitectureConfig { [CmdletBinding()] param () return [pscustomobject]@{ default_tier = 'PureJson' headroom_factor = 1.25 thresholds = [pscustomobject]@{ findings = [pscustomobject]@{ embedded = 10000; sidecar = 100000; pode = 500000 } entities = [pscustomobject]@{ embedded = 10000; sidecar = 100000; pode = 500000 } edges = [pscustomobject]@{ embedded = 2500; pode = 50000 } } vendored_dependencies = @( [pscustomobject]@{ name = 'cytoscape'; placeholder = $true; verify_stub = 'Test-CytoscapePlaceholder'; applicable_tiers = @('EmbeddedSqlite', 'SidecarSqlite', 'PodeViewer') }, [pscustomobject]@{ name = 'dagre'; placeholder = $true; verify_stub = 'Test-DagrePlaceholder'; applicable_tiers = @('EmbeddedSqlite', 'SidecarSqlite', 'PodeViewer') }, [pscustomobject]@{ name = 'Pode'; placeholder = $true; verify_stub = 'Test-PodePlaceholder'; applicable_tiers = @('PodeViewer') }, [pscustomobject]@{ name = 'sqlite-wasm'; placeholder = $true; verify_stub = 'Test-SqliteWasmPlaceholder'; applicable_tiers = @('EmbeddedSqlite', 'SidecarSqlite', 'PodeViewer') } ) } } # Placeholder verifiers report Success=$false with Status='placeholder' so downstream # tier-selection code does NOT mistake an unbundled dep for a healthy one. They are # replaced by real verifiers in the matching dep PRs (#467 viewer, future SQLite tier). function Test-CytoscapePlaceholder { [CmdletBinding()] param() return [pscustomobject]@{ Success = $false; Status = 'placeholder'; Reason = 'cytoscape vendored dependency not yet bundled'; Warning = 'placeholder' } } function Test-DagrePlaceholder { [CmdletBinding()] param() return [pscustomobject]@{ Success = $false; Status = 'placeholder'; Reason = 'dagre vendored dependency not yet bundled'; Warning = 'placeholder' } } function Test-PodePlaceholder { [CmdletBinding()] param() return [pscustomobject]@{ Success = $false; Status = 'placeholder'; Reason = 'Pode vendored dependency not yet bundled'; Warning = 'placeholder' } } function Test-SqliteWasmPlaceholder { [CmdletBinding()] param() return [pscustomobject]@{ Success = $false; Status = 'placeholder'; Reason = 'sqlite-wasm vendored dependency not yet bundled'; Warning = 'placeholder' } } function Test-ReportArchitectureConfig { [CmdletBinding()] param([Parameter(Mandatory)][object] $Config) if (-not $Config) { throw 'ArchitectureConfig is null.' } if (-not $Config.PSObject.Properties['thresholds']) { throw 'ArchitectureConfig.thresholds missing.' } $t = $Config.thresholds foreach ($axis in 'findings','entities') { if (-not $t.PSObject.Properties[$axis]) { throw "ArchitectureConfig.thresholds.$axis missing." } $axisCfg = $t.$axis foreach ($lvl in 'embedded','sidecar','pode') { if (-not $axisCfg.PSObject.Properties[$lvl]) { throw "ArchitectureConfig.thresholds.$axis.$lvl missing." } $val = $axisCfg.$lvl if ($null -eq $val) { throw "ArchitectureConfig.thresholds.$axis.$lvl is null." } if ([int]$val -le 0) { throw "ArchitectureConfig.thresholds.$axis.$lvl must be > 0 (received $val)." } } if (-not ([int]$axisCfg.embedded -lt [int]$axisCfg.sidecar -and [int]$axisCfg.sidecar -lt [int]$axisCfg.pode)) { throw "ArchitectureConfig.thresholds.$axis must be strictly monotonic (embedded<sidecar<pode)." } } if (-not $t.PSObject.Properties['edges']) { throw 'ArchitectureConfig.thresholds.edges missing.' } foreach ($lvl in 'embedded','pode') { if (-not $t.edges.PSObject.Properties[$lvl]) { throw "ArchitectureConfig.thresholds.edges.$lvl missing." } if ([int]$t.edges.$lvl -le 0) { throw "ArchitectureConfig.thresholds.edges.$lvl must be > 0." } } if (-not ([int]$t.edges.embedded -lt [int]$t.edges.pode)) { throw 'ArchitectureConfig.thresholds.edges must satisfy embedded<pode.' } if ($Config.PSObject.Properties['default_tier'] -and -not [string]::IsNullOrWhiteSpace([string]$Config.default_tier)) { if ($Config.default_tier -notin $script:ReportArchitectureTiers) { throw "ArchitectureConfig.default_tier must be one of: $($script:ReportArchitectureTiers -join ', '). Received: $($Config.default_tier)." } } if ($Config.PSObject.Properties['headroom_factor'] -and $null -ne $Config.headroom_factor) { if ([double]$Config.headroom_factor -le 0) { throw "ArchitectureConfig.headroom_factor must be > 0." } } } function Get-ReportTierRank { [CmdletBinding()] param([Parameter(Mandatory)][string]$Tier) return [array]::IndexOf($script:ReportArchitectureTiers, $Tier) } function Select-ReportArchitecture { [CmdletBinding()] param( [int] $FindingCount = 0, [int] $EntityCount = 0, [int] $EdgeCount = 0, [double] $HeadroomFactor = 1.25, [object] $ArchitectureConfig ) if ($HeadroomFactor -le 0) { throw "HeadroomFactor must be a positive number (>0), received: $HeadroomFactor." } $cfg = if ($ArchitectureConfig) { $ArchitectureConfig } else { Get-DefaultReportArchitectureConfig } Test-ReportArchitectureConfig -Config $cfg # Honor manifest headroom_factor when caller did not bind -HeadroomFactor explicitly. if (-not $PSBoundParameters.ContainsKey('HeadroomFactor') -and $cfg.PSObject.Properties['headroom_factor'] -and $null -ne $cfg.headroom_factor -and [double]$cfg.headroom_factor -gt 0) { $HeadroomFactor = [double]$cfg.headroom_factor } $defaultTier = if ($cfg.PSObject.Properties['default_tier'] -and -not [string]::IsNullOrWhiteSpace([string]$cfg.default_tier)) { [string]$cfg.default_tier } else { 'PureJson' } $thresholds = $cfg.thresholds $rawFindings = [math]::Max(0, $FindingCount) $rawEntities = [math]::Max(0, $EntityCount) $rawEdges = [math]::Max(0, $EdgeCount) $adjustedFindings = [int][math]::Ceiling($rawFindings * $HeadroomFactor) $adjustedEntities = [int][math]::Ceiling($rawEntities * $HeadroomFactor) $adjustedEdges = [int][math]::Ceiling($rawEdges * $HeadroomFactor) $findingTier = if ($adjustedFindings -ge [int]$thresholds.findings.pode) { 'PodeViewer' } elseif ($adjustedFindings -ge [int]$thresholds.findings.sidecar) { 'SidecarSqlite' } elseif ($adjustedFindings -ge [int]$thresholds.findings.embedded) { 'EmbeddedSqlite' } else { 'PureJson' } $entityTier = if ($adjustedEntities -ge [int]$thresholds.entities.pode) { 'PodeViewer' } elseif ($adjustedEntities -ge [int]$thresholds.entities.sidecar) { 'SidecarSqlite' } elseif ($adjustedEntities -ge [int]$thresholds.entities.embedded) { 'EmbeddedSqlite' } else { 'PureJson' } $edgeTier = if ($adjustedEdges -ge [int]$thresholds.edges.pode) { 'PodeViewer' } elseif ($adjustedEdges -ge [int]$thresholds.edges.embedded) { 'EmbeddedSqlite' } else { 'PureJson' } # Apply manifest default_tier as a floor across all axes (manifest contract). $candidates = @($findingTier, $entityTier, $edgeTier, $defaultTier) $pickerTier = $candidates | Sort-Object { Get-ReportTierRank $_ } -Descending | Select-Object -First 1 $forcedTier = $env:AZURE_ANALYZER_FORCE_ARCH $forcedOverride = $false $tier = $pickerTier $reasoning = [System.Collections.Generic.List[string]]::new() $reasoning.Add("findings=>$findingTier") $reasoning.Add("entities=>$entityTier") $reasoning.Add("edges=>$edgeTier") if (-not [string]::IsNullOrWhiteSpace($forcedTier)) { if ($forcedTier -notin $script:ReportArchitectureTiers) { throw "AZURE_ANALYZER_FORCE_ARCH must be one of (case-sensitive): $($script:ReportArchitectureTiers -join ', '). Received: $forcedTier." } $forcedOverride = $true $tier = $forcedTier $reasoning.Add("forced=>$forcedTier") } return [pscustomobject]@{ Tier = $tier Reasoning = @($reasoning) Measurements = [pscustomobject]@{ Findings = $rawFindings Entities = $rawEntities Edges = $rawEdges } Headroom = [pscustomobject]@{ Factor = $HeadroomFactor Findings = $adjustedFindings Entities = $adjustedEntities Edges = $adjustedEdges } ForcedOverride = $forcedOverride } } function Get-ReportVerificationStubs { [CmdletBinding()] param( [object] $ArchitectureConfig ) $cfg = if ($ArchitectureConfig) { $ArchitectureConfig } else { Get-DefaultReportArchitectureConfig } $deps = @($cfg.vendored_dependencies) if (-not $deps -or $deps.Count -eq 0) { $deps = @((Get-DefaultReportArchitectureConfig).vendored_dependencies) } function Get-DependenciesForTier { param([string] $TierName) return @( $deps | Where-Object { $_ -and $_.PSObject.Properties['applicable_tiers'] -and (@($_.applicable_tiers) -contains $TierName) } | ForEach-Object { [string]$_.name } ) } # Tiers backed only by placeholder verifiers report Success=$false (Status='placeholder') # so the picker / orchestrator can detect the gap and fall back to PureJson rather than # silently shipping an unrenderable tier. Real verifiers replace these in follow-up PRs. return [pscustomobject]@{ PureJson = [pscustomobject]@{ Success = $true; Status = 'ready'; Errors = @(); Warnings = @(); Dependencies = @() } EmbeddedSqlite = [pscustomobject]@{ Success = $false; Status = 'placeholder'; Errors = @(); Warnings = @('placeholder verification stubs active'); Dependencies = @(Get-DependenciesForTier -TierName 'EmbeddedSqlite') } SidecarSqlite = [pscustomobject]@{ Success = $false; Status = 'placeholder'; Errors = @(); Warnings = @('placeholder verification stubs active'); Dependencies = @(Get-DependenciesForTier -TierName 'SidecarSqlite') } PodeViewer = [pscustomobject]@{ Success = $false; Status = 'placeholder'; Errors = @(); Warnings = @('placeholder verification stubs active'); Dependencies = @(Get-DependenciesForTier -TierName 'PodeViewer') } } } function New-ReportManifest { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $SelectedTier, [object] $Measurements = [pscustomobject]@{}, [double] $HeadroomFactor = 1.25, [string[]] $PickerReasoning = @(), [bool] $ForcedOverride = $false, [object] $VerificationResults = [pscustomobject]@{}, [object[]] $AutoUpgrades = @(), [object] $Timings = [pscustomobject]@{}, [object[]] $Features = @(), [object] $Policy = $null, [ValidateSet('Default', 'Auditor')] [string] $Profile = 'Default', [object[]] $Sections = @() ) $degradations = @( @($Features) | Where-Object { $_ -and $_.PSObject.Properties['renderingMode'] -and $_.PSObject.Properties['tier1Mode'] -and [string]$_.renderingMode -ne [string]$_.tier1Mode } ) $manifest = [pscustomobject]@{ SchemaVersion = '1.0' GeneratedAt = (Get-Date).ToUniversalTime().ToString('o') SelectedTier = $SelectedTier Measurements = if ($Measurements) { $Measurements } else { [pscustomobject]@{} } HeadroomFactor = $HeadroomFactor PickerReasoning = @($PickerReasoning) ForcedOverride = [bool]$ForcedOverride VerificationResults = if ($VerificationResults) { $VerificationResults } else { [pscustomobject]@{} } AutoUpgrades = @($AutoUpgrades) Timings = if ($Timings) { $Timings } else { [pscustomobject]@{} } Features = @($Features) Degradations = @($degradations) Policy = if ($Policy) { $Policy } else { $null } } if ($Profile -eq 'Auditor') { $sectionIds = @($Sections | ForEach-Object { if ($_.PSObject.Properties['Id']) { $_.Id } elseif ($_.PSObject.Properties['id']) { $_.id } }) $profileDegradations = @($degradations | Where-Object { $featureId = if ($_.PSObject.Properties['name']) { $_.name } else { $null } $featureId -and $sectionIds -contains $featureId }) $profileBlock = [pscustomobject]@{ Name = 'auditor' Sections = @($Sections) Degradations = @($profileDegradations) } $manifest | Add-Member -NotePropertyName 'Profile' -NotePropertyValue $profileBlock } $json = $manifest | ConvertTo-Json -Depth 30 # Atomic write: stage to a sibling temp file then move. Prevents truncated JSON on # interrupt and keeps any prior manifest readable until the new one is fully on disk. $dir = Split-Path -Parent $Path if (-not [string]::IsNullOrWhiteSpace($dir) -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $stagingPath = "$Path.tmp-$([guid]::NewGuid().ToString('N'))" try { Set-Content -Path $stagingPath -Value $json -Encoding UTF8 -NoNewline Move-Item -Path $stagingPath -Destination $Path -Force } catch { if (Test-Path $stagingPath) { Remove-Item -Path $stagingPath -Force -ErrorAction SilentlyContinue } throw } return $manifest } |