Tools/Export-AzLocalSideloadDiagnostics.ps1
|
<#
.SYNOPSIS Compresses bounded snapshots of the selected clusters' current copy logs. .DESCRIPTION Reads shared state and logs without modifying them. The ZIP manifest records snapshot offsets, truncation, and unavailable files. Credential files and runner service logs are not collected. Log bytes are not redacted; review the archive before sharing. Output is reporting, including during dry runs. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$StateRoot, [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$ClusterName, [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$DestinationPath, [ValidateRange(1, 10485760)][int]$MaxLogBytes = 5242880, [ValidateRange(1, 104857600)][int]$MaxTotalLogBytes = 52428800, [ValidateRange(1, 1000)][int]$MaxClusters = 100 ) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression $destination = [IO.Path]::GetFullPath($DestinationPath) $null = [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) $temporaryPath = $destination + '.' + [guid]::NewGuid().ToString('N') + '.partial' $logsRoot = [IO.Path]::GetFullPath((Join-Path $StateRoot 'logs')).TrimEnd('\', '/') $records = [Collections.Generic.List[object]]::new() $selectedClusters = @($ClusterName | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) $archiveStream = $null $archive = $null $totalBytes = 0L try { $archiveStream = [IO.File]::Open($temporaryPath, [IO.FileMode]::CreateNew) $archive = [IO.Compression.ZipArchive]::new($archiveStream, [IO.Compression.ZipArchiveMode]::Create, $true) foreach ($cluster in @($selectedClusters | Select-Object -First $MaxClusters)) { $record = [ordered]@{ ClusterName = $cluster; SnapshotUtc = [DateTime]::UtcNow.ToString('o') Status = 'Unavailable'; State = $null; OperationId = $null; OwningMachine = $null ExitCode = $null; LastHeartbeatUtc = $null; Entry = $null SourceLength = 0L; Offset = 0L; CapturedBytes = 0L; Truncated = $false; ErrorType = $null } $sourceStream = $null $entryStream = $null try { $safeName = $cluster -replace '[^A-Za-z0-9._-]', '_' $statePath = Join-Path (Join-Path $StateRoot 'state') ($safeName + '.json') $stateFile = Get-Item -LiteralPath $statePath -ErrorAction Stop if ($stateFile.Length -gt 1MB -or ($stateFile.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Invalid state file.' } $state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json foreach ($property in @('State', 'OperationId', 'OwningMachine', 'ExitCode', 'LastHeartbeatUtc')) { if ($state.PSObject.Properties[$property]) { $record[$property] = $state.$property } } if (-not $state.PSObject.Properties['LogPath'] -or -not $state.LogPath) { $record.Status = 'NoLog' continue } $logPath = [IO.Path]::GetFullPath([string]$state.LogPath) if (-not [IO.Path]::GetDirectoryName($logPath).Equals($logsRoot, [StringComparison]::OrdinalIgnoreCase) -or -not $logPath.EndsWith('.robocopy.log', [StringComparison]::OrdinalIgnoreCase)) { $record.Status = 'RejectedPath' continue } $logFile = Get-Item -LiteralPath $logPath -ErrorAction Stop $logDirectory = Get-Item -LiteralPath $logsRoot -ErrorAction Stop if (($logFile.Attributes -band [IO.FileAttributes]::ReparsePoint) -or ($logDirectory.Attributes -band [IO.FileAttributes]::ReparsePoint)) { $record.Status = 'RejectedPath' continue } if ($totalBytes -ge $MaxTotalLogBytes) { $record.Status = 'BudgetExceeded' continue } $sourceStream = [IO.File]::Open($logPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete) $record.SourceLength = $sourceStream.Length $remaining = [Math]::Min($record.SourceLength, [Math]::Min($MaxLogBytes, $MaxTotalLogBytes - $totalBytes)) $record.Offset = $record.SourceLength - $remaining $null = $sourceStream.Seek($record.Offset, [IO.SeekOrigin]::Begin) $record.Entry = 'logs/{0:D4}.robocopy.log' -f $records.Count $entryStream = $archive.CreateEntry($record.Entry, [IO.Compression.CompressionLevel]::Optimal).Open() $buffer = New-Object byte[] 65536 while ($remaining -gt 0) { $readCount = $sourceStream.Read($buffer, 0, [int][Math]::Min($buffer.Length, $remaining)) if ($readCount -eq 0) { break } $entryStream.Write($buffer, 0, $readCount) $remaining -= $readCount $record.CapturedBytes += $readCount $totalBytes += $readCount } $record.Truncated = $record.CapturedBytes -lt $record.SourceLength $record.Status = 'Captured' } catch { $record.ErrorType = $_.Exception.GetType().Name } finally { if ($entryStream) { $entryStream.Dispose() } if ($sourceStream) { $sourceStream.Dispose() } $records.Add([pscustomobject]$record) } } $module = Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1 $robocopy = Get-Command robocopy.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 $robocopyVersion = if ($robocopy) { (Get-Item -LiteralPath $robocopy.Source).VersionInfo.FileVersion } else { $null } $platform = if ($env:GITHUB_ACTIONS -eq 'true') { 'GitHubActions' } elseif ($env:TF_BUILD -eq 'True') { 'AzureDevOps' } else { 'Local' } $incompleteCount = @($records.ToArray() | Where-Object { $_.Status -ne 'Captured' -or $_.Truncated }).Count $omittedCount = [Math]::Max(0, $selectedClusters.Count - $MaxClusters) $manifest = [ordered]@{ CollectionId = [guid]::NewGuid().ToString('D') CollectionStatus = if ($incompleteCount -gt 0 -or $omittedCount -gt 0) { 'Partial' } else { 'Complete' } Context = [ordered]@{ Platform = $platform RunId = if ($platform -eq 'GitHubActions') { $env:GITHUB_RUN_ID } elseif ($platform -eq 'AzureDevOps') { $env:BUILD_BUILDID } else { $null } RunAttempt = if ($platform -eq 'GitHubActions') { $env:GITHUB_RUN_ATTEMPT } elseif ($platform -eq 'AzureDevOps') { $env:SYSTEM_JOBATTEMPT } else { $null } PowerShellVersion = [string]$PSVersionTable.PSVersion PowerShellEdition = [string]$PSVersionTable.PSEdition DotNetVersion = [string][Environment]::Version ModuleVersion = if ($module) { [string]$module.Version } else { $null } ModuleVersionStatus = if ($module) { 'Loaded' } else { 'NotLoaded' } RobocopyVersion = $robocopyVersion RobocopyVersionStatus = if ($robocopyVersion) { 'Detected' } else { 'Unavailable' } } SnapshotUtc = [DateTime]::UtcNow.ToString('o'); MaxLogBytes = $MaxLogBytes MaxTotalLogBytes = $MaxTotalLogBytes; CapturedLogBytes = $totalBytes OmittedClusterCount = $omittedCount Records = $records.ToArray() } $manifestStream = $archive.CreateEntry('manifest.json').Open() try { $content = [Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Depth 6)) $manifestStream.Write($content, 0, $content.Length) } finally { $manifestStream.Dispose() } $archive.Dispose() $archive = $null $archiveStream.Dispose() $archiveStream = $null Move-Item -LiteralPath $temporaryPath -Destination $destination -Force -WhatIf:$false if ($manifest.CollectionStatus -eq 'Partial') { Write-Warning ("Sideload diagnostics collection is partial: {0} incomplete or truncated record(s), {1} omitted cluster(s). Review manifest.json before sharing." -f $incompleteCount, $omittedCount) -WarningAction Continue } return $destination } finally { if ($archive) { $archive.Dispose() } if ($archiveStream) { $archiveStream.Dispose() } if ([IO.File]::Exists($temporaryPath)) { [IO.File]::Delete($temporaryPath) } } |