Public/Repair-FileShareIntegrity.ps1
|
function Repair-FileShareIntegrity { <# .SYNOPSIS Remediates file integrity issues between two Azure file shares by re-copying files. .DESCRIPTION Accepts a list of relative file paths and runs a three-phase remediation: Phase 1 — Pre-check: verifies each file still has an issue (skippable). Phase 2 — Copy: re-copies confirmed-bad files (SMB or azcopy). Phase 3 — Post-check: re-validates every copied file. A remediation report CSV is produced with per-file results. .PARAMETER FilePaths Array of relative file paths to remediate. Accepts pipeline input. .PARAMETER SourceAccountName Name of the source (authoritative) storage account. .PARAMETER DestinationAccountName Name of the destination (target) storage account. .PARAMETER ShareName Name of the file share on both accounts. .PARAMETER SourceDriveLetter Drive letter for source mount. Default: X. .PARAMETER DestinationDriveLetter Drive letter for destination mount. Default: Y. .PARAMETER AzCopy Use azcopy instead of SMB file copy for Phase 2. .PARAMETER SasExpiryHours SAS token validity in hours (azcopy mode). Default: 24. .PARAMETER SkipPreCheck Skip Phase 1 pre-check. .PARAMETER LogDirectory Base directory for reports. Default: current directory. .EXAMPLE $files = Select-IntegrityReportFile -ReportPath '.\report.csv' -Status ByteMismatch Repair-FileShareIntegrity -FilePaths $files -SourceAccountName 'sourceaccount' -DestinationAccountName 'destaccount' -ShareName 'data' .EXAMPLE Select-IntegrityReportFile -ReportPath '.\report.csv' -Status ByteMismatch | Repair-FileShareIntegrity -SourceAccountName 'sourceaccount' -DestinationAccountName 'destaccount' -ShareName 'data' -SkipPreCheck #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPositionalParameters', '', Justification = 'Join-Path positional parameters are idiomatic and readable')] param( [Parameter(Mandatory, ValueFromPipeline)] [AllowEmptyCollection()] [string[]]$FilePaths, [Parameter(Mandatory)] [string]$SourceAccountName, [Parameter(Mandatory)] [string]$DestinationAccountName, [Parameter(Mandatory)] [string]$ShareName, [Parameter()] [ValidatePattern('^[A-Za-z]$')] [string]$SourceDriveLetter = 'X', [Parameter()] [ValidatePattern('^[A-Za-z]$')] [string]$DestinationDriveLetter = 'Y', [Parameter()] [switch]$AzCopy, [Parameter()] [int]$SasExpiryHours = 24, [Parameter()] [switch]$SkipPreCheck, [Parameter()] [string]$LogDirectory = (Get-Location).Path ) begin { Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $collectedPaths = [System.Collections.Generic.List[string]]::new() } process { foreach ($p in $FilePaths) { if ($p) { $collectedPaths.Add($p) } } } end { if ($collectedPaths.Count -eq 0) { Write-Host 'No file paths provided. Nothing to do.' -ForegroundColor Yellow return } # Deduplicate $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $allPaths = [System.Collections.Generic.List[string]]::new() foreach ($p in $collectedPaths) { if ($seen.Add($p)) { $allPaths.Add($p) } } if ($SourceDriveLetter -eq $DestinationDriveLetter) { throw 'Source and destination drive letters must be different.' } $copyMethod = if ($AzCopy) { 'azcopy' } else { 'SMB' } Write-Host "[$(Get-Timestamp)] $($allPaths.Count) file path(s) to remediate (copy method: $copyMethod)." -ForegroundColor Cyan # ── Pre-flight ─────────────────────────────────────────────────── if ($AzCopy) { Assert-AzCopyInstalled } Assert-AzCliLogin | Out-Null $srcDrive = "${SourceDriveLetter}:" $dstDrive = "${DestinationDriveLetter}:" foreach ($drive in @($srcDrive, $dstDrive)) { if (Test-Path "${drive}\") { throw "Drive '$drive' is already mounted." } } $sourceKey = Get-StorageAccountKey -AccountName $SourceAccountName $destKey = Get-StorageAccountKey -AccountName $DestinationAccountName # SAS tokens for azcopy mode $sourceSas = $null $destSas = $null if ($AzCopy) { $sourceSas = New-SasToken -AccountName $SourceAccountName -Permissions 'rl' -ExpiryHours $SasExpiryHours $destSas = New-SasToken -AccountName $DestinationAccountName -Permissions 'rwdlc' -ExpiryHours $SasExpiryHours } # ── Report setup ───────────────────────────────────────────────── $reportDir = Join-Path $LogDirectory 'logs' "repair-integrity_$(Get-Date -Format 'yyyyMMdd_HHmmss')" New-Item -ItemType Directory -Path $reportDir -Force -Confirm:$false -WhatIf:$false | Out-Null $csvPath = Join-Path $reportDir "${ShareName}_remediation-report.csv" $csvWriter = [System.IO.StreamWriter]::new($csvPath, $false, [System.Text.Encoding]::UTF8) $csvWriter.WriteLine('"RelativePath","PreCheckResult","Action","PostCheckResult","Error"') function Write-ReportRow { param([System.IO.StreamWriter]$Writer, [string]$RelativePath, [string]$PreCheckResult, [string]$Action, [string]$PostCheckResult, [string]$ErrorMessage) $escaped = $RelativePath.Replace('"', '""') $errEscaped = $ErrorMessage.Replace('"', '""') $Writer.WriteLine("`"$escaped`",`"$PreCheckResult`",`"$Action`",`"$PostCheckResult`",`"$errEscaped`"") } $mountedSource = $false $mountedDest = $false $srcRoot = "${srcDrive}\" $dstRoot = "${dstDrive}\" $totalSelected = $allPaths.Count $skippedCount = 0 $preErrorCount = 0 $toRemediate = [System.Collections.Generic.List[hashtable]]::new() try { # ── Phase 1: Pre-check ─────────────────────────────────────── if ($SkipPreCheck) { Write-Host "`n[$(Get-Timestamp)] Phase 1: Skipped (-SkipPreCheck)." -ForegroundColor Yellow foreach ($rel in $allPaths) { $toRemediate.Add(@{ RelativePath = $rel; PreCheckResult = 'Skipped' }) } } else { Mount-SmbDrive -AccountName $SourceAccountName -AccountKey $sourceKey -ShareName $ShareName -DriveLetter $SourceDriveLetter $mountedSource = $true Mount-SmbDrive -AccountName $DestinationAccountName -AccountKey $destKey -ShareName $ShareName -DriveLetter $DestinationDriveLetter $mountedDest = $true Write-Host "`n[$(Get-Timestamp)] Phase 1: Pre-checking $totalSelected file(s)..." -ForegroundColor Cyan $fileNum = 0 foreach ($rel in $allPaths) { $fileNum++ $srcPath = Join-Path $srcRoot $rel $dstPath = Join-Path $dstRoot $rel if ($fileNum % 500 -eq 0 -or $fileNum -eq $totalSelected) { Write-Host " Pre-check: $fileNum / $totalSelected ($([math]::Round(($fileNum / $totalSelected) * 100, 1))%)" -ForegroundColor Gray } try { $preCheck = Test-FileByteIntegrity -SourcePath $srcPath -DestinationPath $dstPath } catch { $preErrorCount++ Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult 'Error' -Action 'None' -PostCheckResult '' -ErrorMessage $_.Exception.Message continue } if ($preCheck -eq 'Match') { $skippedCount++ Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult 'Match' -Action 'Skipped' -PostCheckResult 'Match' -ErrorMessage '' continue } if ($preCheck -eq 'MissingOnSource') { $skippedCount++ Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult 'MissingOnSource' -Action 'Skipped' -PostCheckResult '' -ErrorMessage 'Source file does not exist' continue } $toRemediate.Add(@{ RelativePath = $rel; PreCheckResult = $preCheck }) } Write-Host " Already matching : $skippedCount" -ForegroundColor Gray Write-Host " Pre-check errors : $preErrorCount" -ForegroundColor $(if ($preErrorCount -gt 0) { 'Red' } else { 'Green' }) Write-Host " Need remediation : $($toRemediate.Count)" -ForegroundColor $(if ($toRemediate.Count -gt 0) { 'Yellow' } else { 'Green' }) } if ($toRemediate.Count -eq 0) { Write-Host "`n[$(Get-Timestamp)] No files need remediation. Done." -ForegroundColor Green } # ── Phase 2: Copy ──────────────────────────────────────────── elseif ($PSCmdlet.ShouldProcess("$($toRemediate.Count) file(s) on '$ShareName'", "$copyMethod copy")) { $copyFailCount = 0 if ($AzCopy) { $listFilePath = Join-Path $reportDir 'azcopy-file-list.txt' $listWriter = [System.IO.StreamWriter]::new($listFilePath, $false, [System.Text.Encoding]::UTF8) foreach ($item in $toRemediate) { $listWriter.WriteLine($item.RelativePath.Replace('\', '/')) } $listWriter.Flush(); $listWriter.Dispose() Write-Host "`n[$(Get-Timestamp)] Phase 2: Copying $($toRemediate.Count) file(s) with azcopy..." -ForegroundColor Cyan $sourceUrl = "https://${SourceAccountName}.file.core.windows.net/${ShareName}/?${sourceSas}" $destUrl = "https://${DestinationAccountName}.file.core.windows.net/${ShareName}/?${destSas}" $azcopyArgs = @('copy', $sourceUrl, $destUrl, "--list-of-files=$listFilePath", '--preserve-smb-permissions=false', '--preserve-smb-info=true', '--overwrite=true', '--log-level=INFO') $env:AZCOPY_LOG_LOCATION = $reportDir $azcopyOutput = & azcopy @azcopyArgs 2>&1 $azcopyExitCode = $LASTEXITCODE $azcopyOutput | Out-File -FilePath (Join-Path $reportDir 'azcopy-output.log') -Encoding utf8 if ($azcopyExitCode -ne 0) { Write-Warning "azcopy exited with code $azcopyExitCode." } else { Write-Host ' ✓ azcopy completed successfully.' -ForegroundColor Green } } else { # SMB copy if (-not $mountedSource) { Mount-SmbDrive -AccountName $SourceAccountName -AccountKey $sourceKey -ShareName $ShareName -DriveLetter $SourceDriveLetter $mountedSource = $true } if (-not $mountedDest) { Mount-SmbDrive -AccountName $DestinationAccountName -AccountKey $destKey -ShareName $ShareName -DriveLetter $DestinationDriveLetter $mountedDest = $true } Write-Host "`n[$(Get-Timestamp)] Phase 2: Copying $($toRemediate.Count) file(s) via SMB..." -ForegroundColor Cyan $copyNum = 0 foreach ($item in $toRemediate) { $copyNum++ $rel = $item.RelativePath $srcPath = Join-Path $srcRoot $rel $dstPath = Join-Path $dstRoot $rel if ($copyNum % 500 -eq 0 -or $copyNum -eq $toRemediate.Count) { Write-Host " Copy: $copyNum / $($toRemediate.Count) ($([math]::Round(($copyNum / $toRemediate.Count) * 100, 1))%)" -ForegroundColor Gray } try { $dstDir = [System.IO.Path]::GetDirectoryName($dstPath) if (-not [System.IO.Directory]::Exists($dstDir)) { [System.IO.Directory]::CreateDirectory($dstDir) | Out-Null } [System.IO.File]::Copy($srcPath, $dstPath, $true) } catch { $copyFailCount++ Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult $item.PreCheckResult -Action 'CopyFailed' -PostCheckResult '' -ErrorMessage $_.Exception.Message $item.PreCheckResult = 'CopyFailed' } } if ($copyFailCount -gt 0) { Write-Warning "$copyFailCount file(s) failed to copy." $toRemediate = [System.Collections.Generic.List[hashtable]]@( $toRemediate | Where-Object { $_.PreCheckResult -ne 'CopyFailed' } ) } else { Write-Host ' ✓ All files copied.' -ForegroundColor Green } } # ── Phase 3: Post-copy verification ────────────────────── if ($toRemediate.Count -gt 0) { if (-not $mountedSource) { Mount-SmbDrive -AccountName $SourceAccountName -AccountKey $sourceKey -ShareName $ShareName -DriveLetter $SourceDriveLetter $mountedSource = $true } if (-not $mountedDest) { Mount-SmbDrive -AccountName $DestinationAccountName -AccountKey $destKey -ShareName $ShareName -DriveLetter $DestinationDriveLetter $mountedDest = $true } Write-Host "`n[$(Get-Timestamp)] Phase 3: Verifying $($toRemediate.Count) remediated file(s)..." -ForegroundColor Cyan $verifiedCount = 0 $postFailCount = 0 $postErrorCount = 0 $checkNum = 0 foreach ($item in $toRemediate) { $checkNum++ $rel = $item.RelativePath $srcPath = Join-Path $srcRoot $rel $dstPath = Join-Path $dstRoot $rel if ($checkNum % 500 -eq 0 -or $checkNum -eq $toRemediate.Count) { Write-Host " Post-check: $checkNum / $($toRemediate.Count)" -ForegroundColor Gray } try { $postCheck = Test-FileByteIntegrity -SourcePath $srcPath -DestinationPath $dstPath if ($postCheck -eq 'Match') { $verifiedCount++ } else { $postFailCount++ } Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult $item.PreCheckResult -Action 'Copied' -PostCheckResult $postCheck -ErrorMessage '' } catch { $postErrorCount++ Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult $item.PreCheckResult -Action 'Copied' -PostCheckResult 'Error' -ErrorMessage $_.Exception.Message } } } } else { foreach ($item in $toRemediate) { Write-ReportRow -Writer $csvWriter -RelativePath $item.RelativePath -PreCheckResult $item.PreCheckResult -Action 'WhatIf' -PostCheckResult '' -ErrorMessage '' } } } finally { if ($csvWriter) { try { $csvWriter.Flush(); $csvWriter.Dispose() } catch { Write-Debug "CSV writer dispose failed: $_" } } if ($mountedDest) { Dismount-SmbDrive -DriveLetter $DestinationDriveLetter } if ($mountedSource) { Dismount-SmbDrive -DriveLetter $SourceDriveLetter } } # ── Summary ────────────────────────────────────────────────────── $modeLabel = if ($WhatIfPreference) { 'DRY-RUN' } else { 'REMEDIATION' } if (-not (Get-Variable verifiedCount -ErrorAction SilentlyContinue)) { $verifiedCount = 0 } if (-not (Get-Variable postFailCount -ErrorAction SilentlyContinue)) { $postFailCount = 0 } if (-not (Get-Variable postErrorCount -ErrorAction SilentlyContinue)) { $postErrorCount = 0 } if (-not (Get-Variable copyFailCount -ErrorAction SilentlyContinue)) { $copyFailCount = 0 } Write-Host "`n════════════════════════════════════════════════════════════" -ForegroundColor Cyan Write-Host " $modeLabel — '$ShareName' ($copyMethod)" -ForegroundColor Cyan Write-Host '════════════════════════════════════════════════════════════' -ForegroundColor Cyan Write-Host " Files input : $totalSelected" -ForegroundColor White Write-Host " Copied : $($toRemediate.Count)" -ForegroundColor Cyan if ($copyFailCount -gt 0) { Write-Host " Copy failures : $copyFailCount" -ForegroundColor Red } Write-Host " Post-copy verified : $verifiedCount" -ForegroundColor $(if ($verifiedCount -eq $toRemediate.Count -and $toRemediate.Count -gt 0) { 'Green' } else { 'White' }) Write-Host " Post-copy FAILED : $postFailCount" -ForegroundColor $(if ($postFailCount -gt 0) { 'Red' } else { 'Green' }) Write-Host " Report : $csvPath" -ForegroundColor Cyan Write-Host '' $totalIssues = $preErrorCount + $postFailCount + $postErrorCount + $copyFailCount if ($totalIssues -gt 0) { Write-Warning "$totalIssues file(s) could not be fully remediated. Review the report." } elseif (-not $WhatIfPreference -and $toRemediate.Count -gt 0) { Write-Host " ✓ All $verifiedCount copied file(s) verified successfully." -ForegroundColor Green } } } |