Modules/Outputs/Reports/20-OfficeOutputs.ps1
|
function ConvertTo-RangerCsvSafeText { <# .SYNOPSIS v1.6.0 (#210): sanitise a value for CSV output. - Prefix values starting with =/+/-/@ with a space to block formula injection. - Replace embedded newlines / tabs with a single space. - Escape embedded double quotes by doubling them. #> param([AllowNull()]$Value) $text = ConvertTo-RangerOfficeText -Value $Value if ([string]::IsNullOrEmpty($text)) { return '' } if ($text.Length -gt 0 -and $text[0] -in @('=', '+', '-', '@')) { $text = ' ' + $text } $text = $text -replace "[\r\n\t]+", ' ' if ($text.Contains(',') -or $text.Contains('"')) { $text = '"' + ($text -replace '"', '""') + '"' } return $text } function Write-RangerCsvFile { <# .SYNOPSIS v1.6.0 (#210): write an array of ordered dictionaries to CSV with formula-injection sanitisation. #> param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string[]]$Columns, [object[]]$Rows ) $lines = New-Object System.Collections.Generic.List[string] $lines.Add(($Columns -join ',')) foreach ($row in @($Rows)) { $cells = @($Columns | ForEach-Object { $col = $_ $v = if ($row -is [System.Collections.IDictionary] -and $row.Contains($col)) { $row[$col] } elseif ($row.PSObject -and $row.PSObject.Properties[$col]) { $row.$col } else { $null } ConvertTo-RangerCsvSafeText -Value $v }) $lines.Add($cells -join ',') } [System.IO.File]::WriteAllLines($Path, $lines, [System.Text.UTF8Encoding]::new($false)) } function Invoke-RangerPowerBiExport { <# .SYNOPSIS v1.6.0 (#210): export Ranger manifest data as a Power BI CSV + star-schema bundle. .DESCRIPTION Produces one flat CSV per entity type (nodes, volumes, storage-pools, health-checks, network-adapters) plus _relationships.json and _metadata.json. All string values are sanitised to prevent CSV formula injection and embedded-newline issues. #> param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest, [Parameter(Mandatory = $true)] [string]$OutputRoot ) if (-not (Test-Path -Path $OutputRoot)) { New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null } $summary = Get-RangerManifestSummary -Manifest $Manifest $clusterId = if ($summary.ClusterName) { [string]$summary.ClusterName } else { 'unknown-cluster' } $runTs = if ($Manifest.run.endTimeUtc) { [string]$Manifest.run.endTimeUtc } else { (Get-Date).ToUniversalTime().ToString('o') } # nodes.csv $nodeRows = @( @($Manifest.domains.clusterNode.nodes) | ForEach-Object { $n = $_ $short = if ($n.name) { [string]$n.name } elseif ($n.NodeName) { [string]$n.NodeName } else { '—' } [ordered]@{ NodeId = $short NodeName = $short NodeFqdn = if ($n.fqdn) { [string]$n.fqdn } else { '' } ClusterId = $clusterId Status = if ($n.state) { [string]$n.state } else { '' } Model = if ($n.model) { [string]$n.model } else { '' } CpuSockets = if ($null -ne $n.cpuSocketCount) { [string]$n.cpuSocketCount } else { '' } PhysicalCores = if ($null -ne $n.logicalProcessorCount) { [string]$n.logicalProcessorCount } else { '' } MemoryGiB = if ($null -ne $n.totalMemoryGiB) { [string][math]::Round([double]$n.totalMemoryGiB, 0) } else { '' } OsVersion = if ($n.osVersion) { [string]$n.osVersion } elseif ($n.osCaption) { [string]$n.osCaption } else { '' } ArcConnected = if ($n.arcConnected) { 'True' } else { 'False' } LastUpdated = $runTs } } ) Write-RangerCsvFile -Path (Join-Path $OutputRoot 'nodes.csv') ` -Columns @('NodeId','NodeName','NodeFqdn','ClusterId','Status','Model','CpuSockets','PhysicalCores','MemoryGiB','OsVersion','ArcConnected','LastUpdated') ` -Rows $nodeRows # storage-pools.csv $poolRows = @( @($Manifest.domains.storage.pools) | ForEach-Object { $p = $_ [ordered]@{ PoolId = if ($p.friendlyName) { [string]$p.friendlyName } elseif ($p.name) { [string]$p.name } else { '' } PoolName = if ($p.friendlyName) { [string]$p.friendlyName } elseif ($p.name) { [string]$p.name } else { '' } ClusterId = $clusterId SizeTiB = if ($null -ne $p.rawCapacityGiB) { [string][math]::Round([double]$p.rawCapacityGiB / 1024, 2) } else { '' } AllocatedTiB = if ($null -ne $p.usedUsableCapacityGiB) { [string][math]::Round([double]$p.usedUsableCapacityGiB / 1024, 2) } else { '' } FreeTiB = if ($null -ne $p.freeUsableCapacityGiB) { [string][math]::Round([double]$p.freeUsableCapacityGiB / 1024, 2) } else { '' } FaultDomainType = if ($p.faultDomainType) { [string]$p.faultDomainType } else { '' } Health = if ($p.healthStatus) { [string]$p.healthStatus } else { '' } LastUpdated = $runTs } } ) Write-RangerCsvFile -Path (Join-Path $OutputRoot 'storage-pools.csv') ` -Columns @('PoolId','PoolName','ClusterId','SizeTiB','AllocatedTiB','FreeTiB','FaultDomainType','Health','LastUpdated') ` -Rows $poolRows # volumes.csv $volRows = @( @($Manifest.domains.storage.virtualDisks) | ForEach-Object { $v = $_ [ordered]@{ VolumeId = if ($v.friendlyName) { [string]$v.friendlyName } elseif ($v.name) { [string]$v.name } else { '' } VolumeName = if ($v.friendlyName) { [string]$v.friendlyName } elseif ($v.name) { [string]$v.name } else { '' } PoolId = if ($v.storagePoolFriendlyName) { [string]$v.storagePoolFriendlyName } elseif ($v.poolName) { [string]$v.poolName } else { '' } SizeTiB = if ($null -ne $v.sizeGiB) { [string][math]::Round([double]$v.sizeGiB / 1024, 2) } else { '' } UsedTiB = if ($null -ne $v.usedGiB) { [string][math]::Round([double]$v.usedGiB / 1024, 2) } else { '' } FreePct = if ($null -ne $v.freePct) { [string]$v.freePct } else { '' } Resiliency = if ($v.resiliencySetting) { [string]$v.resiliencySetting } else { '' } Health = if ($v.healthStatus) { [string]$v.healthStatus } else { '' } LastUpdated = $runTs } } ) Write-RangerCsvFile -Path (Join-Path $OutputRoot 'volumes.csv') ` -Columns @('VolumeId','VolumeName','PoolId','SizeTiB','UsedTiB','FreePct','Resiliency','Health','LastUpdated') ` -Rows $volRows # health-checks.csv (sourced from findings) $checkRows = @( @($Manifest.findings) | ForEach-Object { $f = $_ [ordered]@{ CheckId = if ($f.id) { [string]$f.id } else { [string]$f.title } Domain = if ($f.domain) { [string]$f.domain } else { '' } CheckName = if ($f.title) { [string]$f.title } else { '' } Severity = if ($f.severity) { [string]$f.severity } else { '' } Status = if ($f.severity -eq 'good') { 'Healthy' } elseif ($f.severity -eq 'critical') { 'Critical' } elseif ($f.severity -eq 'warning') { 'Warning' } else { 'Informational' } NodeId = if ($f.affectedComponents) { [string](@($f.affectedComponents) -join '; ') } else { '' } Finding = if ($f.description) { [string]$f.description } else { '' } Remediation = if ($f.recommendation) { [string]$f.recommendation } else { '' } LastUpdated = $runTs } } ) Write-RangerCsvFile -Path (Join-Path $OutputRoot 'health-checks.csv') ` -Columns @('CheckId','Domain','CheckName','Severity','Status','NodeId','Finding','Remediation','LastUpdated') ` -Rows $checkRows # network-adapters.csv $adapterRows = @( @($Manifest.domains.networking.adapters) | ForEach-Object { $a = $_ [ordered]@{ AdapterId = if ($a.name) { ('{0}::{1}' -f ($a.node ?? ''), $a.name) } else { '' } NodeId = if ($a.node) { [string]$a.node } else { '' } AdapterName = if ($a.name) { [string]$a.name } else { '' } LinkSpeedGbps = if ($a.linkSpeedGbps) { [string]$a.linkSpeedGbps } elseif ($a.linkSpeed) { [string]$a.linkSpeed } else { '' } IntentName = if ($a.intentName) { [string]$a.intentName } else { '' } SubnetMask = if ($a.subnetMask) { [string]$a.subnetMask } else { '' } IpAddress = if ($a.ipAddress) { [string]$a.ipAddress } else { '' } VlanId = if ($a.vlanId) { [string]$a.vlanId } else { '' } LastUpdated = $runTs } } ) Write-RangerCsvFile -Path (Join-Path $OutputRoot 'network-adapters.csv') ` -Columns @('AdapterId','NodeId','AdapterName','LinkSpeedGbps','IntentName','SubnetMask','IpAddress','VlanId','LastUpdated') ` -Rows $adapterRows # v2.0.0: per-domain CSVs for the new collectors. $extRows = @( foreach ($nodeBlock in @($Manifest.domains.azureIntegration.arcExtensionsDetail.byNode)) { foreach ($e in @($nodeBlock.extensions)) { [ordered]@{ ExtensionId = ('{0}::{1}' -f ($nodeBlock.node ?? ''), ($e.name ?? '')) NodeId = [string]$nodeBlock.node Name = [string]($e.name ?? '') Type = [string]($e.type ?? '') Publisher = [string]($e.publisher ?? '') Version = [string]($e.typeHandlerVersion ?? '') ProvisioningState = [string]($e.provisioningState ?? '') AutoUpgrade = if ($e.enableAutomaticUpgrade) { 'True' } else { 'False' } LastUpdated = $runTs } } } ) if ($extRows.Count -gt 0) { Write-RangerCsvFile -Path (Join-Path $OutputRoot 'arc-extensions.csv') -Columns @('ExtensionId','NodeId','Name','Type','Publisher','Version','ProvisioningState','AutoUpgrade','LastUpdated') -Rows $extRows } $lnetRows = @( foreach ($ln in @($Manifest.domains.networking.logicalNetworks)) { foreach ($sn in @($ln.subnets)) { [ordered]@{ SubnetId = ('{0}::{1}' -f ($ln.name ?? ''), ($sn.name ?? '')) NetworkId = [string]$ln.name NetworkName = [string]$ln.name VmSwitch = [string]($ln.vmSwitchName ?? '') DhcpEnabled = if ($ln.dhcpEnabled) { 'True' } else { 'False' } SubnetName = [string]($sn.name ?? '') AddressPrefix = [string]($sn.addressPrefix ?? '') VlanId = [string]($sn.vlan ?? '') IpPools = [string](@($sn.ipPools).Count) ProvisioningState = [string]($ln.provisioningState ?? '') LastUpdated = $runTs } } } ) if ($lnetRows.Count -gt 0) { Write-RangerCsvFile -Path (Join-Path $OutputRoot 'logical-networks.csv') -Columns @('SubnetId','NetworkId','NetworkName','VmSwitch','DhcpEnabled','SubnetName','AddressPrefix','VlanId','IpPools','ProvisioningState','LastUpdated') -Rows $lnetRows } $spRows = @( foreach ($sp in @($Manifest.domains.storage.storagePaths)) { [ordered]@{ PathId = [string]$sp.name ClusterId = $clusterId Path = [string]($sp.path ?? '') AvailableGB = [string]($sp.availableSizeGB ?? '') FileSystem = [string]($sp.fileSystemType ?? '') ProvisioningState = [string]($sp.provisioningState ?? '') LastUpdated = $runTs } } ) if ($spRows.Count -gt 0) { Write-RangerCsvFile -Path (Join-Path $OutputRoot 'storage-paths.csv') -Columns @('PathId','ClusterId','Path','AvailableGB','FileSystem','ProvisioningState','LastUpdated') -Rows $spRows } $rbRows = @( foreach ($rb in @($Manifest.domains.azureIntegration.resourceBridgeDetail)) { [ordered]@{ BridgeId = [string]$rb.name ClusterId = $clusterId Status = [string]($rb.status ?? '') Version = [string]($rb.version ?? '') Distro = [string]($rb.distro ?? '') Provider = [string]($rb.infrastructureProvider ?? '') Location = [string]($rb.location ?? '') ProvisioningState = [string]($rb.provisioningState ?? '') LastUpdated = $runTs } } ) if ($rbRows.Count -gt 0) { Write-RangerCsvFile -Path (Join-Path $OutputRoot 'resource-bridges.csv') -Columns @('BridgeId','ClusterId','Status','Version','Distro','Provider','Location','ProvisioningState','LastUpdated') -Rows $rbRows } $clRows = @( foreach ($cl in @($Manifest.domains.azureIntegration.customLocationsDetail)) { [ordered]@{ CustomLocationId = [string]$cl.name ClusterId = $clusterId Namespace = [string]($cl.namespace ?? '') Location = [string]($cl.location ?? '') ProvisioningState = [string]($cl.provisioningState ?? '') LastUpdated = $runTs } } ) if ($clRows.Count -gt 0) { Write-RangerCsvFile -Path (Join-Path $OutputRoot 'custom-locations.csv') -Columns @('CustomLocationId','ClusterId','Namespace','Location','ProvisioningState','LastUpdated') -Rows $clRows } $gwRows = @( foreach ($gw in @($Manifest.domains.azureIntegration.arcGateways)) { [ordered]@{ GatewayId = [string]$gw.name ClusterId = $clusterId Endpoint = [string]($gw.gatewayEndpoint ?? '') AllowedFeatures = [string]((@($gw.allowedFeatures)) -join ';') AllowedResources = [string]($gw.allowedResources ?? '') ProvisioningState = [string]($gw.provisioningState ?? '') LastUpdated = $runTs } } ) if ($gwRows.Count -gt 0) { Write-RangerCsvFile -Path (Join-Path $OutputRoot 'arc-gateways.csv') -Columns @('GatewayId','ClusterId','Endpoint','AllowedFeatures','AllowedResources','ProvisioningState','LastUpdated') -Rows $gwRows } $imgRows = @( foreach ($img in (@($Manifest.domains.azureIntegration.marketplaceImages) + @($Manifest.domains.azureIntegration.galleryImages))) { if ($null -eq $img) { continue } [ordered]@{ ImageId = [string]$img.name ClusterId = $clusterId Type = [string]($img.imageType ?? '') OsType = [string]($img.osType ?? '') Publisher = [string]($img.publisher ?? '') Offer = [string]($img.offer ?? '') Sku = [string]($img.sku ?? '') Version = [string]($img.version ?? '') SizeGB = [string]($img.sizeGB ?? '') StoragePathRef = [string]($img.storagePathId ?? '') ProvisioningState = [string]($img.provisioningState ?? '') LastUpdated = $runTs } } ) if ($imgRows.Count -gt 0) { Write-RangerCsvFile -Path (Join-Path $OutputRoot 'images.csv') -Columns @('ImageId','ClusterId','Type','OsType','Publisher','Offer','Sku','Version','SizeGB','StoragePathRef','ProvisioningState','LastUpdated') -Rows $imgRows } $costRows = @( foreach ($n in @($Manifest.domains.azureIntegration.costLicensing.perNode)) { [ordered]@{ NodeId = [string]$n.node ClusterId = $clusterId PhysicalCores = [string]$n.physicalCores AhbEnabled = if ($n.ahbEnabled) { 'True' } else { 'False' } MonthlyCostUsd = [string][math]::Round([double]$n.monthlyCostUsd, 2) MonthlySavingUsd = [string][math]::Round([double]$n.monthlySavingUsd, 2) LastUpdated = $runTs } } ) if ($costRows.Count -gt 0) { Write-RangerCsvFile -Path (Join-Path $OutputRoot 'cost-licensing.csv') -Columns @('NodeId','ClusterId','PhysicalCores','AhbEnabled','MonthlyCostUsd','MonthlySavingUsd','LastUpdated') -Rows $costRows } # v2.2.0: WAF compliance CSVs — checklist, roadmap, gap-to-goal (#238/#241/#242). if (Get-Command -Name Invoke-RangerWafRuleEvaluation -ErrorAction SilentlyContinue) { try { $wafEval = Invoke-RangerWafRuleEvaluation -Manifest $Manifest } catch { $wafEval = $null } if ($wafEval) { $checklistRows = @(@($wafEval.ruleResults) | ForEach-Object { $rr = $_ $nextStep = if (-not $rr.pass -and $rr.remediation -and @($rr.remediation.steps).Count -gt 0) { [string]@($rr.remediation.steps)[0] } elseif (-not $rr.pass) { [string]$rr.recommendation } else { '' } [ordered]@{ RuleId = [string]$rr.id Pillar = [string]$rr.pillar Status = if ($rr.pass) { 'Pass' } else { 'Fail' } Weight = [string]$rr.weight Effort = if ($rr.estimatedEffort) { [string]$rr.estimatedEffort } else { '' } NextStep = $nextStep Severity = [string]$rr.severity ClusterId = $clusterId LastUpdated = $runTs } }) if ($checklistRows.Count -gt 0) { Write-RangerCsvFile -Path (Join-Path $OutputRoot 'waf-checklist.csv') -Columns @('RuleId','Pillar','Status','Weight','Effort','NextStep','Severity','ClusterId','LastUpdated') -Rows $checklistRows } $roadmapRows = @(@($wafEval.roadmap) | ForEach-Object { [ordered]@{ Bucket = [string]$_.bucket RuleId = [string]$_.id Pillar = [string]$_.pillar Severity = [string]$_.severity Weight = [string]$_.weight Effort = [string]$_.effort Impact = [string]$_.impact PriorityScore = [string]$_.priorityScore FirstStep = [string]$_.firstStep ClusterId = $clusterId LastUpdated = $runTs } }) if ($roadmapRows.Count -gt 0) { Write-RangerCsvFile -Path (Join-Path $OutputRoot 'waf-roadmap.csv') -Columns @('Bucket','RuleId','Pillar','Severity','Weight','Effort','Impact','PriorityScore','FirstStep','ClusterId','LastUpdated') -Rows $roadmapRows } if ($wafEval.gapToGoal -and @($wafEval.gapToGoal.fixPlan).Count -gt 0) { $gtgRows = @( foreach ($p in @($wafEval.gapToGoal.fixPlan)) { [ordered]@{ ClusterId = $clusterId RuleId = [string]$p.ruleId DeltaScore = [string]$p.deltaScore CumulativeScore = [string]$p.cumulativeScore Effort = [string]$p.effort CurrentScore = [string]$wafEval.gapToGoal.currentScore ProjectedScore = [string]$wafEval.gapToGoal.projectedScore TargetThreshold = [string]$wafEval.gapToGoal.targetThreshold LastUpdated = $runTs } } ) if ($gtgRows.Count -gt 0) { Write-RangerCsvFile -Path (Join-Path $OutputRoot 'waf-gap-to-goal.csv') -Columns @('ClusterId','RuleId','DeltaScore','CumulativeScore','Effort','CurrentScore','ProjectedScore','TargetThreshold','LastUpdated') -Rows $gtgRows } } } } # _relationships.json (star schema) $relationships = [ordered]@{ version = '1.0' tables = @('nodes','volumes','storage-pools','health-checks','network-adapters','arc-extensions','logical-networks','storage-paths','resource-bridges','custom-locations','arc-gateways','images','cost-licensing','waf-checklist','waf-roadmap','waf-gap-to-goal') relationships = @( [ordered]@{ from = 'volumes'; fromColumn = 'PoolId'; to = 'storage-pools'; toColumn = 'PoolId' } [ordered]@{ from = 'storage-pools'; fromColumn = 'ClusterId'; to = 'nodes'; toColumn = 'ClusterId' } [ordered]@{ from = 'health-checks'; fromColumn = 'NodeId'; to = 'nodes'; toColumn = 'NodeId' } [ordered]@{ from = 'network-adapters'; fromColumn = 'NodeId'; to = 'nodes'; toColumn = 'NodeId' } [ordered]@{ from = 'arc-extensions'; fromColumn = 'NodeId'; to = 'nodes'; toColumn = 'NodeId' } [ordered]@{ from = 'cost-licensing'; fromColumn = 'NodeId'; to = 'nodes'; toColumn = 'NodeId' } [ordered]@{ from = 'storage-paths'; fromColumn = 'ClusterId'; to = 'nodes'; toColumn = 'ClusterId' } [ordered]@{ from = 'resource-bridges'; fromColumn = 'ClusterId'; to = 'nodes'; toColumn = 'ClusterId' } [ordered]@{ from = 'custom-locations'; fromColumn = 'ClusterId'; to = 'nodes'; toColumn = 'ClusterId' } [ordered]@{ from = 'arc-gateways'; fromColumn = 'ClusterId'; to = 'nodes'; toColumn = 'ClusterId' } [ordered]@{ from = 'images'; fromColumn = 'ClusterId'; to = 'nodes'; toColumn = 'ClusterId' } [ordered]@{ from = 'waf-checklist'; fromColumn = 'ClusterId'; to = 'nodes'; toColumn = 'ClusterId' } [ordered]@{ from = 'waf-roadmap'; fromColumn = 'ClusterId'; to = 'nodes'; toColumn = 'ClusterId' } [ordered]@{ from = 'waf-gap-to-goal'; fromColumn = 'ClusterId'; to = 'nodes'; toColumn = 'ClusterId' } ) } ($relationships | ConvertTo-Json -Depth 10) | Set-Content -Path (Join-Path $OutputRoot '_relationships.json') -Encoding UTF8 # _metadata.json $metadata = [ordered]@{ runId = if ($Manifest.run.runId) { [string]$Manifest.run.runId } else { [guid]::NewGuid().ToString() } clusterName = $clusterId mode = [string]$Manifest.run.mode rangerVersion = [string]$Manifest.run.toolVersion generatedAt = $runTs } ($metadata | ConvertTo-Json -Depth 5) | Set-Content -Path (Join-Path $OutputRoot '_metadata.json') -Encoding UTF8 return $OutputRoot } function Resolve-RangerHeadlessBrowser { <# .SYNOPSIS v1.6.0 (#207): locate a headless-capable browser for PDF generation. .OUTPUTS Hashtable @{ Path; Name; Version } or $null when no browser was found. #> $candidates = @( @{ Name = 'msedge'; Probe = { (Get-Command -Name 'msedge' -ErrorAction SilentlyContinue).Source } } @{ Name = 'msedge'; Probe = { Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe' } } @{ Name = 'msedge'; Probe = { Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe' } } @{ Name = 'chrome'; Probe = { (Get-Command -Name 'chrome' -ErrorAction SilentlyContinue).Source } } @{ Name = 'chrome'; Probe = { Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe' } } @{ Name = 'chrome'; Probe = { Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe' } } @{ Name = 'chromium'; Probe = { (Get-Command -Name 'chromium' -ErrorAction SilentlyContinue).Source } } ) foreach ($c in $candidates) { try { $path = & $c.Probe if ([string]::IsNullOrWhiteSpace($path)) { continue } if (-not (Test-Path -Path $path -PathType Leaf)) { continue } $ver = $null try { $verOutput = & $path --version 2>$null if ($verOutput) { $ver = ($verOutput | Select-Object -First 1).ToString().Trim() } } catch { } return @{ Path = $path; Name = $c.Name; Version = $ver } } catch { } } return $null } function Invoke-RangerHeadlessPdf { <# .SYNOPSIS v1.6.0 (#207): render an HTML file to PDF via headless Edge / Chrome. .DESCRIPTION Invokes the resolved browser with --headless=new --print-to-pdf. Returns $true on success; $false when no browser is available or the output file is missing / zero-byte after rendering. #> param( [Parameter(Mandatory = $true)] [string]$HtmlPath, [Parameter(Mandatory = $true)] [string]$OutputPath, [int]$TimeoutSeconds = 60 ) $browser = Resolve-RangerHeadlessBrowser if (-not $browser) { Write-RangerLog -Level warn -Message 'PDF generation requires Microsoft Edge or Google Chrome. Neither was found. Install Edge (bundled with Windows 11/Server 2022) or add Chrome to PATH.' return $false } $fileUri = ([uri]::new($HtmlPath)).AbsoluteUri $argList = @( '--headless=new', '--disable-gpu', '--no-sandbox', "--print-to-pdf=$OutputPath", '--print-to-pdf-no-header', $fileUri ) try { $proc = Start-Process -FilePath $browser.Path -ArgumentList $argList -NoNewWindow -PassThru -RedirectStandardOutput ([System.IO.Path]::GetTempFileName()) -RedirectStandardError ([System.IO.Path]::GetTempFileName()) if (-not $proc.WaitForExit($TimeoutSeconds * 1000)) { try { $proc.Kill() } catch { } Write-RangerLog -Level warn -Message "Headless PDF render timed out after ${TimeoutSeconds}s — output may be incomplete." return $false } if ($proc.ExitCode -ne 0) { Write-RangerLog -Level warn -Message "Headless browser exited with code $($proc.ExitCode) while rendering PDF." return $false } } catch { Write-RangerLog -Level warn -Message "Headless PDF render threw: $($_.Exception.Message)" return $false } if (-not (Test-Path -Path $OutputPath -PathType Leaf)) { Write-RangerLog -Level warn -Message "Headless browser completed but PDF output is missing: $OutputPath" return $false } $size = (Get-Item -Path $OutputPath).Length if ($size -lt 512) { Write-RangerLog -Level warn -Message "Headless browser produced a suspiciously small PDF ($size bytes) — treating as failed." return $false } Write-RangerLog -Level info -Message "PDF rendered via $($browser.Name) ($($browser.Version)) — $OutputPath ($size bytes)." return $true } function ConvertTo-RangerXmlText { param( [AllowNull()] $Value ) if ($null -eq $Value) { return '' } return [System.Security.SecurityElement]::Escape([string]$Value) } function ConvertTo-RangerOfficeText { param( [AllowNull()] $Value ) if ($null -eq $Value) { return '' } if ($Value -is [bool]) { return $(if ($Value) { 'Yes' } else { 'No' }) } if ($Value -is [datetime]) { return $Value.ToString('u').TrimEnd('Z').Trim() } if ($Value -is [System.Collections.IDictionary]) { return ((@($Value.Keys | ForEach-Object { '{0}={1}' -f $_, (ConvertTo-RangerOfficeText -Value $Value[$_]) })) -join '; ') } if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { return ((@($Value | ForEach-Object { ConvertTo-RangerOfficeText -Value $_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) -join '; ') } return [string]$Value } function Get-RangerObjectValue { param( [AllowNull()] $InputObject, [Parameter(Mandatory = $true)] [string[]]$CandidateNames ) foreach ($name in $CandidateNames) { if ($null -eq $InputObject) { continue } if ($InputObject -is [System.Collections.IDictionary] -and $InputObject.Contains($name)) { return $InputObject[$name] } $property = $InputObject.PSObject.Properties[$name] if ($property) { return $property.Value } } return $null } function Split-RangerWrappedText { param( [AllowNull()] [string]$Text, [int]$Width = 96 ) if ([string]::IsNullOrEmpty($Text)) { return @('') } $lines = New-Object System.Collections.Generic.List[string] foreach ($rawLine in (($Text -replace "`r", '') -split "`n")) { if ($rawLine.Length -le $Width) { $lines.Add($rawLine) continue } $remaining = $rawLine.TrimEnd() while ($remaining.Length -gt $Width) { $window = $remaining.Substring(0, $Width) $breakIndex = $window.LastIndexOf(' ') if ($breakIndex -lt ([math]::Floor($Width / 2))) { $breakIndex = $Width } $lines.Add($remaining.Substring(0, $breakIndex).TrimEnd()) $remaining = $remaining.Substring($breakIndex).TrimStart() } $lines.Add($remaining) } return @($lines) } function Get-RangerReportPlainTextLines { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Report, [int]$WrapWidth = 96 ) $lines = New-Object System.Collections.Generic.List[string] $lines.Add($Report.Title) $lines.Add(('=' * [math]::Max(8, $Report.Title.Length))) $lines.Add('') $lines.Add("Cluster: $($Report.ClusterName)") $lines.Add("Mode: $($Report.Mode)") $lines.Add("Ranger Version: $($Report.Version)") $lines.Add("Generated: $($Report.GeneratedAt)") $lines.Add('') $lines.Add('Table of Contents') $lines.Add('-----------------') foreach ($heading in @($Report.TableOfContents)) { $lines.Add("- $heading") } $lines.Add('') foreach ($section in @($Report.Sections)) { $lines.Add($section.heading) $lines.Add(('-' * [math]::Max(6, $section.heading.Length))) switch ($section.type) { 'table' { if ($section.headers) { $lines.Add(($section.headers -join ' | ')) $lines.Add(('-' * 80)) } $tblRows = ConvertTo-RangerTableRowArray -Rows @($section.rows) -ColumnCount @($section.headers).Count foreach ($row in $tblRows) { $lines.Add((@($row) -join ' | ')) } } 'kv' { foreach ($pair in @($section.rows)) { $lines.Add(('{0,-28} {1}' -f ($pair[0] + ':'), $pair[1])) } } 'sign-off' { $lines.Add('Role Name Date Signature') $lines.Add(('-' * 80)) $lines.Add('Implementation Engineer _______________ ___________ _______________') $lines.Add('Technical Reviewer _______________ ___________ _______________') $lines.Add('Customer Representative _______________ ___________ _______________') } default { foreach ($entry in @($section.body)) { $lines.Add("- $entry") } } } $lines.Add('') } $lines.Add('Recommendations') $lines.Add('---------------') if (@($Report.Recommendations).Count -eq 0) { $lines.Add('- No recommendations were surfaced for this output tier.') } else { foreach ($recommendation in @($Report.Recommendations)) { $lines.Add(("- [{0}] {1}: {2}" -f $recommendation.severity.ToUpperInvariant(), $recommendation.title, $recommendation.recommendation)) } } $lines.Add('') $lines.Add('Findings') $lines.Add('--------') if (@($Report.Findings).Count -eq 0) { $lines.Add('- No findings were recorded for this output tier.') } else { foreach ($finding in @($Report.Findings)) { $lines.Add(("[{0}] {1}" -f $finding.severity.ToUpperInvariant(), $finding.title)) foreach ($wrappedLine in @(Split-RangerWrappedText -Text $finding.description -Width $WrapWidth)) { $lines.Add(" $wrappedLine") } if ($finding.currentState) { $lines.Add(" Current state: $($finding.currentState)") } if ($finding.recommendation) { $lines.Add(" Recommendation: $($finding.recommendation)") } if (@($finding.affectedComponents).Count -gt 0) { $lines.Add((" Affected components: {0}" -f (@($finding.affectedComponents) -join ', '))) } $lines.Add('') } } $wrapped = New-Object System.Collections.Generic.List[string] foreach ($line in @($lines)) { foreach ($wrappedLine in @(Split-RangerWrappedText -Text $line -Width $WrapWidth)) { $wrapped.Add($wrappedLine) } } return @($wrapped) } function Write-RangerZipEntry { param( [Parameter(Mandatory = $true)] [System.IO.Compression.ZipArchive]$Archive, [Parameter(Mandatory = $true)] [string]$EntryPath, [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Content ) $entry = $Archive.CreateEntry($EntryPath) $stream = $entry.Open() try { $writer = New-Object System.IO.StreamWriter($stream, [System.Text.UTF8Encoding]::new($false)) try { $writer.Write($Content) } finally { $writer.Dispose() } } finally { $stream.Dispose() } } function New-RangerDocxParagraphXml { param( [Parameter(Mandatory = $true)] [string]$Text, [string]$Style = 'Normal' ) $escaped = ConvertTo-RangerXmlText -Value $Text $styleXml = if ($Style -and $Style -ne 'Normal') { '<w:pPr><w:pStyle w:val="{0}"/></w:pPr>' -f $Style } else { '' } '<w:p>{0}<w:r><w:t xml:space="preserve">{1}</w:t></w:r></w:p>' -f $styleXml, $escaped } function New-RangerDocxTableXml { <# .SYNOPSIS v1.6.0 (#208): render a tabular OOXML <w:tbl> from headers + rows. Header row is styled bold and repeats on page breaks. #> param( [string[]]$Headers, [object[]]$Rows, [string]$Caption ) if ($null -eq $Rows -or @($Rows).Count -eq 0) { return (New-RangerDocxParagraphXml -Text 'No data available.' -Style 'Normal') } # Regroup if PowerShell flattened the 2D row array on binding. $arrayRows = ConvertTo-RangerTableRowArray -Rows $Rows -ColumnCount $Headers.Count $tblPr = '<w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:left w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:right w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/></w:tblBorders></w:tblPr>' $headerCells = ($Headers | ForEach-Object { $txt = ConvertTo-RangerXmlText -Value $_ "<w:tc><w:tcPr><w:shd w:val='clear' w:color='auto' w:fill='1E3A5F'/></w:tcPr><w:p><w:pPr><w:rPr><w:b/><w:color w:val='FFFFFF'/></w:rPr></w:pPr><w:r><w:rPr><w:b/><w:color w:val='FFFFFF'/></w:rPr><w:t xml:space='preserve'>$txt</w:t></w:r></w:p></w:tc>" }) -join '' $headerRow = "<w:tr><w:trPr><w:tblHeader/></w:trPr>$headerCells</w:tr>" $bodyRows = @($arrayRows | ForEach-Object { $cells = @(@($_) | ForEach-Object { $txt = ConvertTo-RangerXmlText -Value $_ "<w:tc><w:p><w:r><w:t xml:space='preserve'>$txt</w:t></w:r></w:p></w:tc>" }) -join '' "<w:tr>$cells</w:tr>" }) -join '' $xml = "<w:tbl>$tblPr$headerRow$bodyRows</w:tbl>" if (-not [string]::IsNullOrWhiteSpace($Caption)) { $cap = ConvertTo-RangerXmlText -Value $Caption $xml += "<w:p><w:pPr><w:rPr><w:i/><w:color w:val='64748B'/></w:rPr></w:pPr><w:r><w:rPr><w:i/><w:color w:val='64748B'/></w:rPr><w:t xml:space='preserve'>$cap</w:t></w:r></w:p>" } return $xml } function New-RangerDocxKvTableXml { <# .SYNOPSIS v1.6.0 (#208): render a two-column key/value table. #> param([object[][]]$Pairs) if ($null -eq $Pairs -or @($Pairs).Count -eq 0) { return '' } $tblPr = '<w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:bottom w:val="single" w:sz="2" w:space="0" w:color="F1F5F9"/><w:insideH w:val="single" w:sz="2" w:space="0" w:color="F1F5F9"/></w:tblBorders></w:tblPr>' $rows = @($Pairs | ForEach-Object { $k = ConvertTo-RangerXmlText -Value ([string]$_[0]) $v = ConvertTo-RangerXmlText -Value ([string]$_[1]) "<w:tr><w:tc><w:tcPr><w:tcW w:w='3600' w:type='dxa'/></w:tcPr><w:p><w:pPr><w:rPr><w:b/><w:color w:val='475569'/></w:rPr></w:pPr><w:r><w:rPr><w:b/><w:color w:val='475569'/></w:rPr><w:t xml:space='preserve'>$k</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t xml:space='preserve'>$v</w:t></w:r></w:p></w:tc></w:tr>" }) -join '' return "<w:tbl>$tblPr$rows</w:tbl>" } function Write-RangerDocxReport { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Report, [Parameter(Mandatory = $true)] [string]$Path ) if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Force } $paragraphs = New-Object System.Collections.Generic.List[string] $paragraphs.Add((New-RangerDocxParagraphXml -Text $Report.Title -Style 'Title')) $paragraphs.Add((New-RangerDocxParagraphXml -Text "Cluster: $($Report.ClusterName)")) $paragraphs.Add((New-RangerDocxParagraphXml -Text "Mode: $($Report.Mode)")) $paragraphs.Add((New-RangerDocxParagraphXml -Text "Ranger Version: $($Report.Version)")) $paragraphs.Add((New-RangerDocxParagraphXml -Text "Generated: $($Report.GeneratedAt)")) $paragraphs.Add((New-RangerDocxParagraphXml -Text 'Table of Contents' -Style 'Heading1')) foreach ($heading in @($Report.TableOfContents)) { $paragraphs.Add((New-RangerDocxParagraphXml -Text "- $heading" -Style 'ListParagraph')) } foreach ($section in @($Report.Sections)) { $paragraphs.Add((New-RangerDocxParagraphXml -Text $section.heading -Style 'Heading1')) # v1.6.0 (#208): render section.type='table' and 'kv' as OOXML tables. switch ($section.type) { 'table' { $paragraphs.Add((New-RangerDocxTableXml -Headers $section.headers -Rows $section.rows -Caption $section.caption)) } 'kv' { $paragraphs.Add((New-RangerDocxKvTableXml -Pairs $section.rows)) } 'sign-off' { $paragraphs.Add((New-RangerDocxTableXml -Headers @('Role','Name','Date','Signature') -Rows @( ,@('Implementation Engineer','','','') ,@('Technical Reviewer','','','') ,@('Customer Representative','','','') ))) } default { foreach ($entry in @($section.body)) { $paragraphs.Add((New-RangerDocxParagraphXml -Text "- $entry" -Style 'ListParagraph')) } } } } $paragraphs.Add((New-RangerDocxParagraphXml -Text 'Recommendations' -Style 'Heading1')) if (@($Report.Recommendations).Count -eq 0) { $paragraphs.Add((New-RangerDocxParagraphXml -Text '- No recommendations were surfaced for this output tier.' -Style 'ListParagraph')) } else { foreach ($recommendation in @($Report.Recommendations)) { $paragraphs.Add((New-RangerDocxParagraphXml -Text ("- [{0}] {1}: {2}" -f $recommendation.severity.ToUpperInvariant(), $recommendation.title, $recommendation.recommendation) -Style 'ListParagraph')) } } $paragraphs.Add((New-RangerDocxParagraphXml -Text 'Findings' -Style 'Heading1')) if (@($Report.Findings).Count -eq 0) { $paragraphs.Add((New-RangerDocxParagraphXml -Text '- No findings were recorded for this output tier.' -Style 'ListParagraph')) } else { foreach ($finding in @($Report.Findings)) { $paragraphs.Add((New-RangerDocxParagraphXml -Text ("[{0}] {1}" -f $finding.severity.ToUpperInvariant(), $finding.title) -Style 'Heading2')) $paragraphs.Add((New-RangerDocxParagraphXml -Text $finding.description)) if ($finding.currentState) { $paragraphs.Add((New-RangerDocxParagraphXml -Text "Current state: $($finding.currentState)")) } if ($finding.recommendation) { $paragraphs.Add((New-RangerDocxParagraphXml -Text "Recommendation: $($finding.recommendation)")) } if (@($finding.affectedComponents).Count -gt 0) { $paragraphs.Add((New-RangerDocxParagraphXml -Text ("Affected components: {0}" -f (@($finding.affectedComponents) -join ', ')))) } } } $documentXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <w:document xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" mc:Ignorable="w14 wp14"> <w:body> $($paragraphs -join "`n ") <w:sectPr> <w:pgSz w:w="12240" w:h="15840"/> <w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/> </w:sectPr> </w:body> </w:document> "@ $stylesXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> <w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style> <w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style> <w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style> <w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="24"/></w:rPr></w:style> <w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:ind w:left="360"/></w:style> </w:styles> "@ $contentTypesXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/> <Default Extension="xml" ContentType="application/xml"/> <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/> <Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/> <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/> <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/> </Types> "@ $rootRelsXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/> <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/> <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/> </Relationships> "@ $documentRelsXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/> </Relationships> "@ $coreXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <dc:title>$(ConvertTo-RangerXmlText -Value $Report.Title)</dc:title> <dc:creator>AzureLocalRanger</dc:creator> <cp:lastModifiedBy>AzureLocalRanger</cp:lastModifiedBy> <dcterms:created xsi:type="dcterms:W3CDTF">$((Get-Date).ToUniversalTime().ToString('s'))Z</dcterms:created> <dcterms:modified xsi:type="dcterms:W3CDTF">$((Get-Date).ToUniversalTime().ToString('s'))Z</dcterms:modified> </cp:coreProperties> "@ $appXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"> <Application>AzureLocalRanger</Application> </Properties> "@ $archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Create) try { Write-RangerZipEntry -Archive $archive -EntryPath '[Content_Types].xml' -Content $contentTypesXml Write-RangerZipEntry -Archive $archive -EntryPath '_rels/.rels' -Content $rootRelsXml Write-RangerZipEntry -Archive $archive -EntryPath 'docProps/core.xml' -Content $coreXml Write-RangerZipEntry -Archive $archive -EntryPath 'docProps/app.xml' -Content $appXml Write-RangerZipEntry -Archive $archive -EntryPath 'word/document.xml' -Content $documentXml Write-RangerZipEntry -Archive $archive -EntryPath 'word/styles.xml' -Content $stylesXml Write-RangerZipEntry -Archive $archive -EntryPath 'word/_rels/document.xml.rels' -Content $documentRelsXml } finally { $archive.Dispose() } } function ConvertTo-RangerPdfLiteral { param( [AllowNull()] [string]$Text ) if ($null -eq $Text) { return '' } return (($Text -replace '\\', '\\\\') -replace '\(', '\\(' -replace '\)', '\\)') } function Write-RangerPdfReport { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Report, [Parameter(Mandatory = $true)] [string]$Path ) # Cover page lines (#96) $coverLines = @( '', '', '', ' Azure Local Ranger', (' ' + ('=' * 60)), '', " $($Report.Title)", '', " Cluster: $($Report.ClusterName)", " Mode: $($Report.Mode)", " Ranger Version: $($Report.Version)", " Generated: $($Report.GeneratedAt)", '', (' ' + ('=' * 60)), '', ' CONFIDENTIAL — INTERNAL USE ONLY', '', ' This document was generated automatically by Azure Local Ranger.', ' Review all findings and recommendations before use as a formal record.' ) $allLines = @($coverLines) + @('') + @(Get-RangerReportPlainTextLines -Report $Report -WrapWidth 92) $linesPerPage = 50 $pageSets = New-Object System.Collections.Generic.List[object] for ($index = 0; $index -lt $allLines.Count; $index += $linesPerPage) { $remaining = $allLines.Count - $index $take = [math]::Min($linesPerPage, $remaining) $pageSets.Add(@($allLines[$index..($index + $take - 1)])) } if ($pageSets.Count -eq 0) { $pageSets.Add(@('AzureLocalRanger report')) } $objectBodies = @{} $pageObjectIds = New-Object System.Collections.Generic.List[int] $fontObjectId = (3 + ($pageSets.Count * 2)) $nextObjectId = 3 foreach ($pageLines in $pageSets) { $contentBuilder = New-Object System.Text.StringBuilder [void]$contentBuilder.AppendLine('BT') [void]$contentBuilder.AppendLine('/F1 10 Tf') [void]$contentBuilder.AppendLine('50 780 Td') [void]$contentBuilder.AppendLine('14 TL') foreach ($line in @($pageLines)) { $literal = ConvertTo-RangerPdfLiteral -Text $(if ([string]::IsNullOrEmpty($line)) { ' ' } else { $line }) [void]$contentBuilder.AppendLine("($literal) Tj") [void]$contentBuilder.AppendLine('T*') } [void]$contentBuilder.AppendLine('ET') $contentStream = $contentBuilder.ToString() $contentObjectId = $nextObjectId $pageObjectId = $nextObjectId + 1 $nextObjectId += 2 $objectBodies[$contentObjectId] = "<< /Length $([System.Text.Encoding]::ASCII.GetByteCount($contentStream)) >>`nstream`n$contentStream`nendstream" $objectBodies[$pageObjectId] = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 $fontObjectId 0 R >> >> /Contents $contentObjectId 0 R >>" $pageObjectIds.Add($pageObjectId) } $objectBodies[1] = '<< /Type /Catalog /Pages 2 0 R >>' $objectBodies[2] = "<< /Type /Pages /Count $($pageObjectIds.Count) /Kids [$((@($pageObjectIds) | ForEach-Object { "$_ 0 R" }) -join ' ')] >>" $objectBodies[$fontObjectId] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>' $builder = New-Object System.Text.StringBuilder [void]$builder.Append("%PDF-1.4`n%Ranger`n") $offsets = New-Object System.Collections.Generic.List[int] $offsets.Add(0) for ($objectId = 1; $objectId -le $fontObjectId; $objectId++) { $offsets.Add([System.Text.Encoding]::ASCII.GetByteCount($builder.ToString())) [void]$builder.Append("$objectId 0 obj`n$($objectBodies[$objectId])`nendobj`n") } $xrefOffset = [System.Text.Encoding]::ASCII.GetByteCount($builder.ToString()) [void]$builder.Append("xref`n0 $($fontObjectId + 1)`n") [void]$builder.Append("0000000000 65535 f `n") for ($objectId = 1; $objectId -le $fontObjectId; $objectId++) { [void]$builder.Append(([string]::Format('{0:0000000000} 00000 n `n', $offsets[$objectId]))) } [void]$builder.Append("trailer`n<< /Size $($fontObjectId + 1) /Root 1 0 R >>`nstartxref`n$xrefOffset`n%%EOF") [System.IO.File]::WriteAllText($Path, $builder.ToString(), [System.Text.Encoding]::ASCII) } function ConvertTo-RangerExcelColumnName { param( [Parameter(Mandatory = $true)] [int]$Index ) $name = '' $current = $Index while ($current -gt 0) { $remainder = ($current - 1) % 26 $name = [char](65 + $remainder) + $name $current = [math]::Floor(($current - 1) / 26) } return $name } function Get-RangerExcelSheetDefinitions { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest ) $summary = Get-RangerManifestSummary -Manifest $Manifest $overviewRows = @( [ordered]@{ Metric = 'Cluster'; Value = $summary.ClusterName } [ordered]@{ Metric = 'Mode'; Value = $Manifest.run.mode } [ordered]@{ Metric = 'Generated'; Value = $Manifest.run.endTimeUtc } [ordered]@{ Metric = 'Nodes'; Value = $summary.NodeCount } [ordered]@{ Metric = 'VMs'; Value = $summary.VmCount } [ordered]@{ Metric = 'Azure resources'; Value = $summary.AzureResourceCount } [ordered]@{ Metric = 'Successful collectors'; Value = $summary.SuccessfulCollectors } [ordered]@{ Metric = 'Partial collectors'; Value = $summary.PartialCollectors } [ordered]@{ Metric = 'Failed collectors'; Value = $summary.FailedCollectors } ) $nodeRows = @( @($Manifest.domains.clusterNode.nodes) | ForEach-Object { [ordered]@{ Node = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('name', 'node')) State = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('state', 'status')) Manufacturer = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('manufacturer')) Model = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('model')) Serial = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('serialNumber', 'serial')) CPU = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('processorModel', 'cpuModel')) MemoryGiB = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('memoryGiB', 'memoryGb')) OS = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('osVersion', 'operatingSystem')) } } ) $storageRows = @( @($Manifest.domains.storage.physicalDisks) | ForEach-Object { [ordered]@{ Disk = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('friendlyName', 'name')) MediaType = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('mediaType')) HealthStatus = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('healthStatus')) Operational = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('operationalStatus')) SizeGiB = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('sizeGiB')) Usage = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('usage')) Serial = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('serialNumber', 'serial')) Slot = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('slot', 'slotNumber')) } } ) $networkRows = @( @($Manifest.domains.networking.adapters) | ForEach-Object { [ordered]@{ Node = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('node')) Adapter = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('name', 'interfaceAlias')) Status = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('status', 'state')) LinkSpeedGbps = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('linkSpeedGbps', 'linkSpeedGb')) MacAddress = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('macAddress')) VirtualSwitch = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('virtualSwitch', 'vswitch')) RdmaEnabled = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('rdmaEnabled')) DriverVersion = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('driverVersion')) } } ) $vmRows = @( @($Manifest.domains.virtualMachines.inventory) | ForEach-Object { [ordered]@{ VM = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('name')) Host = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('host', 'computerName')) State = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('state', 'status')) Generation = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('generation')) VcpuCount = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('processorCount', 'vcpuCount')) MemoryStartup = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('memoryStartupGb', 'memoryStartupGiB', 'memoryStartupMb')) Checkpoints = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('checkpointCount')) GuestIp = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('primaryIpAddress')) IpSource = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('ipAddressSource')) WorkloadFamily = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('workloadFamily')) } } ) $azureRows = @( @($Manifest.domains.azureIntegration.resources) | ForEach-Object { [ordered]@{ Name = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('Name', 'name')) ResourceType = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('ResourceType', 'resourceType', 'Type', 'type')) Location = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('Location', 'location')) ResourceGroup = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('ResourceGroupName', 'resourceGroup')) Subscription = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('SubscriptionId', 'subscriptionId')) Id = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('ResourceId', 'Id', 'id')) } } ) $findingRows = @( @($Manifest.findings) | ForEach-Object { [ordered]@{ Severity = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('severity')) Title = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('title')) Description = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('description')) CurrentState = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('currentState')) Recommendation = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('recommendation')) AffectedComponents = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $_ -CandidateNames @('affectedComponents')) } } ) $collectorRows = @( @($Manifest.collectors.Keys | Sort-Object | ForEach-Object { $collector = $Manifest.collectors[$_] [ordered]@{ Collector = $_ Status = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $collector -CandidateNames @('status')) TargetScope = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $collector -CandidateNames @('targetScope')) DurationMs = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $collector -CandidateNames @('durationMs', 'durationMilliseconds')) Evidence = ConvertTo-RangerOfficeText -Value (Get-RangerObjectValue -InputObject $collector -CandidateNames @('rawEvidencePath')) } }) ) # v2.0.0: Arc extensions, logical networks, storage paths, resource bridges, # custom locations, arc gateways, images, cost/licensing tabs. $extRows = @( foreach ($nb in @($Manifest.domains.azureIntegration.arcExtensionsDetail.byNode)) { foreach ($e in @($nb.extensions)) { [ordered]@{ Node = ConvertTo-RangerOfficeText -Value $nb.node Name = ConvertTo-RangerOfficeText -Value $e.name Type = ConvertTo-RangerOfficeText -Value $e.type Publisher = ConvertTo-RangerOfficeText -Value $e.publisher Version = ConvertTo-RangerOfficeText -Value $e.typeHandlerVersion ProvisioningState = ConvertTo-RangerOfficeText -Value $e.provisioningState AutoUpgrade = ConvertTo-RangerOfficeText -Value $e.enableAutomaticUpgrade } } } ) $lnetRows = @( foreach ($ln in @($Manifest.domains.networking.logicalNetworks)) { [ordered]@{ Name = ConvertTo-RangerOfficeText -Value $ln.name VmSwitch = ConvertTo-RangerOfficeText -Value $ln.vmSwitchName DhcpEnabled = ConvertTo-RangerOfficeText -Value $ln.dhcpEnabled SubnetCount = ConvertTo-RangerOfficeText -Value (@($ln.subnets).Count) ProvisioningState = ConvertTo-RangerOfficeText -Value $ln.provisioningState } } ) $subnetRows = @( foreach ($ln in @($Manifest.domains.networking.logicalNetworks)) { foreach ($sn in @($ln.subnets)) { [ordered]@{ Network = ConvertTo-RangerOfficeText -Value $ln.name Subnet = ConvertTo-RangerOfficeText -Value $sn.name AddressPrefix = ConvertTo-RangerOfficeText -Value $sn.addressPrefix Vlan = ConvertTo-RangerOfficeText -Value $sn.vlan IpPools = ConvertTo-RangerOfficeText -Value (@($sn.ipPools).Count) } } } ) $spRows = @( foreach ($sp in @($Manifest.domains.storage.storagePaths)) { [ordered]@{ Name = ConvertTo-RangerOfficeText -Value $sp.name Path = ConvertTo-RangerOfficeText -Value $sp.path AvailableGB = ConvertTo-RangerOfficeText -Value $sp.availableSizeGB FileSystem = ConvertTo-RangerOfficeText -Value $sp.fileSystemType ProvisioningState = ConvertTo-RangerOfficeText -Value $sp.provisioningState } } ) $rbRows = @( foreach ($rb in @($Manifest.domains.azureIntegration.resourceBridgeDetail)) { [ordered]@{ Name = ConvertTo-RangerOfficeText -Value $rb.name Status = ConvertTo-RangerOfficeText -Value $rb.status Version = ConvertTo-RangerOfficeText -Value $rb.version Distro = ConvertTo-RangerOfficeText -Value $rb.distro Provider = ConvertTo-RangerOfficeText -Value $rb.infrastructureProvider Location = ConvertTo-RangerOfficeText -Value $rb.location ProvisioningState = ConvertTo-RangerOfficeText -Value $rb.provisioningState } } ) $clRows = @( foreach ($cl in @($Manifest.domains.azureIntegration.customLocationsDetail)) { [ordered]@{ Name = ConvertTo-RangerOfficeText -Value $cl.name Namespace = ConvertTo-RangerOfficeText -Value $cl.namespace Location = ConvertTo-RangerOfficeText -Value $cl.location ProvisioningState = ConvertTo-RangerOfficeText -Value $cl.provisioningState } } ) $gwRows = @( foreach ($gw in @($Manifest.domains.azureIntegration.arcGateways)) { [ordered]@{ Name = ConvertTo-RangerOfficeText -Value $gw.name Endpoint = ConvertTo-RangerOfficeText -Value $gw.gatewayEndpoint AllowedFeatures = ConvertTo-RangerOfficeText -Value (@($gw.allowedFeatures) -join '; ') AllowedResources = ConvertTo-RangerOfficeText -Value $gw.allowedResources ProvisioningState = ConvertTo-RangerOfficeText -Value $gw.provisioningState } } ) $imgRows = @( foreach ($img in (@($Manifest.domains.azureIntegration.marketplaceImages) + @($Manifest.domains.azureIntegration.galleryImages))) { if ($null -eq $img) { continue } [ordered]@{ Name = ConvertTo-RangerOfficeText -Value $img.name Type = ConvertTo-RangerOfficeText -Value $img.imageType OsType = ConvertTo-RangerOfficeText -Value $img.osType Publisher = ConvertTo-RangerOfficeText -Value $img.publisher Sku = ConvertTo-RangerOfficeText -Value $img.sku Version = ConvertTo-RangerOfficeText -Value $img.version SizeGB = ConvertTo-RangerOfficeText -Value $img.sizeGB ProvisioningState = ConvertTo-RangerOfficeText -Value $img.provisioningState } } ) $costRows = @( foreach ($n in @($Manifest.domains.azureIntegration.costLicensing.perNode)) { [ordered]@{ Node = ConvertTo-RangerOfficeText -Value $n.node PhysicalCores = ConvertTo-RangerOfficeText -Value $n.physicalCores AhbEnabled = ConvertTo-RangerOfficeText -Value $n.ahbEnabled MonthlyCostUsd = ConvertTo-RangerOfficeText -Value ([math]::Round([double]$n.monthlyCostUsd, 2)) MonthlySavingUsd = ConvertTo-RangerOfficeText -Value ([math]::Round([double]$n.monthlySavingUsd, 2)) } } ) $baseTabs = @( [ordered]@{ Name = 'Overview'; Columns = @('Metric', 'Value'); Rows = $overviewRows } [ordered]@{ Name = 'Nodes'; Columns = @('Node', 'State', 'Manufacturer', 'Model', 'Serial', 'CPU', 'MemoryGiB', 'OS'); Rows = $nodeRows } [ordered]@{ Name = 'Storage'; Columns = @('Disk', 'MediaType', 'HealthStatus', 'Operational', 'SizeGiB', 'Usage', 'Serial', 'Slot'); Rows = $storageRows } [ordered]@{ Name = 'Networking'; Columns = @('Node', 'Adapter', 'Status', 'LinkSpeedGbps', 'MacAddress', 'VirtualSwitch', 'RdmaEnabled', 'DriverVersion'); Rows = $networkRows } [ordered]@{ Name = 'VirtualMachines'; Columns = @('VM', 'Host', 'State', 'Generation', 'VcpuCount', 'MemoryStartup', 'Checkpoints', 'GuestIp', 'IpSource', 'WorkloadFamily'); Rows = $vmRows } [ordered]@{ Name = 'AzureResources'; Columns = @('Name', 'ResourceType', 'Location', 'ResourceGroup', 'Subscription', 'Id'); Rows = $azureRows } [ordered]@{ Name = 'Findings'; Columns = @('Severity', 'Title', 'Description', 'CurrentState', 'Recommendation', 'AffectedComponents'); Rows = $findingRows } [ordered]@{ Name = 'Collectors'; Columns = @('Collector', 'Status', 'TargetScope', 'DurationMs', 'Evidence'); Rows = $collectorRows } ) $v2Tabs = @() if ($extRows.Count -gt 0) { $v2Tabs += [ordered]@{ Name = 'Extensions'; Columns = @('Node','Name','Type','Publisher','Version','ProvisioningState','AutoUpgrade'); Rows = $extRows } } if ($lnetRows.Count -gt 0) { $v2Tabs += [ordered]@{ Name = 'LogicalNetworks'; Columns = @('Name','VmSwitch','DhcpEnabled','SubnetCount','ProvisioningState'); Rows = $lnetRows } } if ($subnetRows.Count -gt 0) { $v2Tabs += [ordered]@{ Name = 'Subnets'; Columns = @('Network','Subnet','AddressPrefix','Vlan','IpPools'); Rows = $subnetRows } } if ($spRows.Count -gt 0) { $v2Tabs += [ordered]@{ Name = 'StoragePaths'; Columns = @('Name','Path','AvailableGB','FileSystem','ProvisioningState'); Rows = $spRows } } if ($rbRows.Count -gt 0) { $v2Tabs += [ordered]@{ Name = 'ResourceBridges'; Columns = @('Name','Status','Version','Distro','Provider','Location','ProvisioningState');Rows = $rbRows } } if ($clRows.Count -gt 0) { $v2Tabs += [ordered]@{ Name = 'CustomLocations'; Columns = @('Name','Namespace','Location','ProvisioningState'); Rows = $clRows } } if ($gwRows.Count -gt 0) { $v2Tabs += [ordered]@{ Name = 'ArcGateways'; Columns = @('Name','Endpoint','AllowedFeatures','AllowedResources','ProvisioningState'); Rows = $gwRows } } if ($imgRows.Count -gt 0) { $v2Tabs += [ordered]@{ Name = 'Images'; Columns = @('Name','Type','OsType','Publisher','Sku','Version','SizeGB','ProvisioningState'); Rows = $imgRows } } if ($costRows.Count -gt 0) { $v2Tabs += [ordered]@{ Name = 'CostLicensing'; Columns = @('Node','PhysicalCores','AhbEnabled','MonthlyCostUsd','MonthlySavingUsd'); Rows = $costRows } } return @($baseTabs + $v2Tabs) } function Write-RangerExcelWorkbook { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest, [Parameter(Mandatory = $true)] [string]$Path ) if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Force } $sheetDefinitions = @(Get-RangerExcelSheetDefinitions -Manifest $Manifest) $worksheetEntries = New-Object System.Collections.Generic.List[object] $sheetId = 1 foreach ($sheetDefinition in $sheetDefinitions) { $columns = @($sheetDefinition.Columns) $rows = @($sheetDefinition.Rows) $maxRow = [math]::Max(2, $rows.Count + 1) $lastColumnName = ConvertTo-RangerExcelColumnName -Index $columns.Count $sheetDataRows = New-Object System.Collections.Generic.List[string] $headerCells = New-Object System.Collections.Generic.List[string] for ($columnIndex = 0; $columnIndex -lt $columns.Count; $columnIndex++) { $cellReference = '{0}1' -f (ConvertTo-RangerExcelColumnName -Index ($columnIndex + 1)) $headerCells.Add(('<c r="{0}" t="inlineStr" s="1"><is><t xml:space="preserve">{1}</t></is></c>' -f $cellReference, (ConvertTo-RangerXmlText -Value $columns[$columnIndex]))) } $sheetDataRows.Add(('<row r="1">{0}</row>' -f ($headerCells -join ''))) for ($rowIndex = 0; $rowIndex -lt $rows.Count; $rowIndex++) { $row = $rows[$rowIndex] $cellXml = New-Object System.Collections.Generic.List[string] for ($columnIndex = 0; $columnIndex -lt $columns.Count; $columnIndex++) { $columnName = $columns[$columnIndex] $value = ConvertTo-RangerOfficeText -Value $(if ($row -is [System.Collections.IDictionary] -and $row.Contains($columnName)) { $row[$columnName] } else { $null }) # v1.6.0 (#209): prevent Excel formula injection — cell values # that begin with =, +, -, @ are prefixed with an apostrophe # so Excel treats them as literal text. if ($value -is [string] -and $value.Length -gt 0 -and $value[0] -in @('=', '+', '-', '@')) { $value = "'" + $value } $cellReference = '{0}{1}' -f (ConvertTo-RangerExcelColumnName -Index ($columnIndex + 1)), ($rowIndex + 2) $cellXml.Add(('<c r="{0}" t="inlineStr"><is><t xml:space="preserve">{1}</t></is></c>' -f $cellReference, (ConvertTo-RangerXmlText -Value $value))) } $sheetDataRows.Add(('<row r="{0}">{1}</row>' -f ($rowIndex + 2), ($cellXml -join ''))) } $worksheetXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> <dimension ref="A1:$lastColumnName$maxRow"/> <sheetViews> <sheetView workbookViewId="0"> <pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/> </sheetView> </sheetViews> <sheetFormatPr defaultRowHeight="15"/> <sheetData> $($sheetDataRows -join "`n ") </sheetData> <autoFilter ref="A1:$lastColumnName$maxRow"/> </worksheet> "@ $worksheetEntries.Add([ordered]@{ Name = $sheetDefinition.Name Path = "xl/worksheets/sheet$sheetId.xml" RelId = "rId$sheetId" Xml = $worksheetXml SheetId = $sheetId }) $sheetId++ } $sheetListXml = @($worksheetEntries | ForEach-Object { '<sheet name="{0}" sheetId="{1}" r:id="{2}"/>' -f (ConvertTo-RangerXmlText -Value $_.Name), $_.SheetId, $_.RelId }) -join "`n " $workbookXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> <sheets> $sheetListXml </sheets> </workbook> "@ $workbookRelsXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> $((@($worksheetEntries | ForEach-Object { '<Relationship Id="{0}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{1}.xml"/>' -f $_.RelId, $_.SheetId }) + '<Relationship Id="rId99" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>') -join "`n ") </Relationships> "@ $stylesXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"> <fonts count="2"> <font><sz val="11"/><name val="Calibri"/></font> <font><b/><sz val="11"/><name val="Calibri"/><color rgb="FFFFFFFF"/></font> </fonts> <fills count="3"> <fill><patternFill patternType="none"/></fill> <fill><patternFill patternType="gray125"/></fill> <fill><patternFill patternType="solid"><fgColor rgb="FF0F4C81"/><bgColor indexed="64"/></patternFill></fill> </fills> <borders count="2"> <border><left/><right/><top/><bottom/><diagonal/></border> <border><left style="thin"/><right style="thin"/><top style="thin"/><bottom style="thin"/><diagonal/></border> </borders> <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs> <cellXfs count="2"> <xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/> <xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1"/> </cellXfs> <cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles> </styleSheet> "@ $contentTypesXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/> <Default Extension="xml" ContentType="application/xml"/> <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/> <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/> $(@($worksheetEntries | ForEach-Object { '<Override PartName="/xl/worksheets/sheet{0}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' -f $_.SheetId }) -join "`n ") </Types> "@ $rootRelsXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/> </Relationships> "@ $archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Create) try { Write-RangerZipEntry -Archive $archive -EntryPath '[Content_Types].xml' -Content $contentTypesXml Write-RangerZipEntry -Archive $archive -EntryPath '_rels/.rels' -Content $rootRelsXml Write-RangerZipEntry -Archive $archive -EntryPath 'xl/workbook.xml' -Content $workbookXml Write-RangerZipEntry -Archive $archive -EntryPath 'xl/_rels/workbook.xml.rels' -Content $workbookRelsXml Write-RangerZipEntry -Archive $archive -EntryPath 'xl/styles.xml' -Content $stylesXml foreach ($worksheetEntry in $worksheetEntries) { Write-RangerZipEntry -Archive $archive -EntryPath $worksheetEntry.Path -Content $worksheetEntry.Xml } } finally { $archive.Dispose() } } |