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) }
}