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. No credentials or runner
    service logs are collected. 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)
        }
    }
    $manifest = [ordered]@{
        SnapshotUtc = [DateTime]::UtcNow.ToString('o'); MaxLogBytes = $MaxLogBytes
        MaxTotalLogBytes = $MaxTotalLogBytes; CapturedLogBytes = $totalBytes
        OmittedClusterCount = [Math]::Max(0, $selectedClusters.Count - $MaxClusters)
        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
    return $destination
}
finally {
    if ($archive) { $archive.Dispose() }
    if ($archiveStream) { $archiveStream.Dispose() }
    if ([IO.File]::Exists($temporaryPath)) { [IO.File]::Delete($temporaryPath) }
}