recover-clone.ps1 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. <#
  2. .SYNOPSIS
  3. Safely clone data from a failing drive to a healthy target using
  4. robocopy with retry=0 (skip bad sectors fast, don't pound on them).
  5. .DESCRIPTION
  6. When a drive is dying, the worst thing you can do is repeatedly retry
  7. reads on failing sectors — every retry stresses the drive further and
  8. can finish it off. This script wraps robocopy with the right flags:
  9. /R:0 no retries on read failures
  10. /W:0 no wait between retries (n/a with R:0 but explicit)
  11. /MIR mirror (delete files at target that don't exist at source)
  12. /XJ skip junction points (don't follow recursive mounts)
  13. /COPY:DAT copy Data, Attributes, Timestamps (skip ACL/Owner — faster)
  14. /MT:8 8 threads (default is 8 anyway, explicit for clarity)
  15. /R:0 /W:0 total retry budget zero — fail fast on bad blocks
  16. /LOG full log of what failed
  17. /TEE output to console + log
  18. A separate "failed files" log captures the specific paths that couldn't
  19. be read, so the user can decide what to do with those (often: try
  20. again later with ddrescue, or accept the loss).
  21. The script can resume — robocopy /MIR is idempotent. Re-run after a
  22. crash and it picks up where it left off (modulo files that have
  23. already been mirrored).
  24. .PARAMETER Source
  25. Source path (failing drive). Required.
  26. .PARAMETER Destination
  27. Target path (healthy drive with enough space). Required.
  28. .PARAMETER NoMirror
  29. Use /COPY instead of /MIR. Use this when the destination already has
  30. other content you want preserved.
  31. .PARAMETER MaxRetries
  32. Retry budget per file. Default 0 (no retries — recommended for failing
  33. drives). Set to 1 only if you accept that retries may damage the
  34. drive further.
  35. .PARAMETER LogDir
  36. Where to write the clone log and failed-files log. Default: TEMP.
  37. .PARAMETER DryRun
  38. Use robocopy /L to list what would be copied without copying. Useful
  39. for planning capacity.
  40. .EXAMPLE
  41. scripts/recover-clone.ps1 -Source Y:\ -Destination Z:\backup-of-Y
  42. Full mirror clone with zero retries (safest for failing drive).
  43. .EXAMPLE
  44. scripts/recover-clone.ps1 -Source Y:\important -Destination Z:\rescue -NoMirror
  45. Copy a specific folder without mirroring (won't delete destination files).
  46. .EXAMPLE
  47. scripts/recover-clone.ps1 -Source Y:\ -Destination Z:\backup -DryRun
  48. Enumerate without copying — check capacity, file counts.
  49. .NOTES
  50. Exit codes (robocopy's are remapped to ATP semantics):
  51. 0 success — no files needed copying, or all copied OK
  52. 1 partial — some files copied, some failed
  53. 3 not found — source path doesn't exist
  54. 4 validation — destination has less free space than source data
  55. 5 precondition — robocopy not found
  56. #>
  57. [CmdletBinding(SupportsShouldProcess)]
  58. param(
  59. [Parameter(Mandatory, Position=0)][string]$Source,
  60. [Parameter(Mandatory, Position=1)][string]$Destination,
  61. [switch]$NoMirror,
  62. [ValidateRange(0,5)][int]$MaxRetries = 0,
  63. [string]$LogDir = $env:TEMP,
  64. [switch]$DryRun
  65. )
  66. $ErrorActionPreference = 'Stop'
  67. . "$PSScriptRoot\_lib\common.ps1"
  68. # Preflight
  69. $robo = Get-Command robocopy.exe -ErrorAction SilentlyContinue
  70. if (-not $robo) {
  71. Write-Log -Level FAIL -Message "robocopy.exe not on PATH (should be present on all Windows installs)"
  72. exit $script:EXIT_PRECONDITION
  73. }
  74. if (-not (Test-Path $Source)) {
  75. Write-Log -Level FAIL -Message "Source not found: $Source"
  76. exit $script:EXIT_NOT_FOUND
  77. }
  78. # Capacity preflight
  79. try {
  80. $srcUsedGB = [math]::Round((Get-ChildItem $Source -Recurse -Force -ErrorAction SilentlyContinue |
  81. Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum / 1GB, 1)
  82. } catch { $srcUsedGB = -1 }
  83. $destDriveLetter = $Destination.Substring(0, 1).ToUpper()
  84. $destDrive = Get-PSDrive -PSProvider FileSystem -Name $destDriveLetter -ErrorAction SilentlyContinue
  85. if ($destDrive) {
  86. $destFreeGB = [math]::Round($destDrive.Free / 1GB, 1)
  87. Write-Log -Level INFO -Message "Source data: $srcUsedGB GB | Destination free: $destFreeGB GB"
  88. if ($srcUsedGB -gt 0 -and $destFreeGB -lt $srcUsedGB) {
  89. Write-Log -Level FAIL -Message "Destination has $destFreeGB GB free; source is $srcUsedGB GB. Insufficient space."
  90. exit $script:EXIT_VALIDATION
  91. }
  92. }
  93. # Timestamps and log paths
  94. $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss')
  95. $cloneLog = Join-Path $LogDir "recover-clone-$stamp.log"
  96. $failedLog = Join-Path $LogDir "recover-clone-failed-$stamp.log"
  97. # Build robocopy command
  98. $roboArgs = @($Source, $Destination)
  99. if ($NoMirror) {
  100. $roboArgs += '/E' # subdirectories incl. empty
  101. } else {
  102. $roboArgs += '/MIR' # mirror
  103. }
  104. $roboArgs += '/XJ' # skip junction points
  105. $roboArgs += '/COPY:DAT' # data, attributes, timestamps (skip ACL for speed)
  106. $roboArgs += '/DCOPY:T' # also copy directory timestamps
  107. $roboArgs += "/R:$MaxRetries"
  108. $roboArgs += '/W:0'
  109. $roboArgs += '/MT:8' # 8 threads
  110. $roboArgs += '/V' # verbose — list skipped files
  111. $roboArgs += '/BYTES' # report sizes in bytes (cleaner for parsing)
  112. $roboArgs += '/NP' # no per-file progress (cleaner log)
  113. $roboArgs += "/LOG:$cloneLog"
  114. $roboArgs += '/TEE' # console + log
  115. if ($DryRun) {
  116. $roboArgs += '/L' # list only — no actual copy
  117. Write-Log -Level INFO -Message "DRY-RUN — robocopy /L will enumerate without copying"
  118. }
  119. Write-Log -Level INFO -Message "Logs: $cloneLog"
  120. Write-Log -Level INFO -Message "Robocopy: robocopy $($roboArgs -join ' ')"
  121. if (-not $PSCmdlet.ShouldProcess("$Source -> $Destination", "robocopy clone")) {
  122. Write-Log -Level INFO -Message "WhatIf: would run but skipped due to -WhatIf"
  123. exit $script:EXIT_OK
  124. }
  125. # Run robocopy
  126. $start = Get-Date
  127. & robocopy.exe @roboArgs
  128. $roboExit = $LASTEXITCODE
  129. $end = Get-Date
  130. # Decode robocopy exit code
  131. # 0 — no files copied (nothing to do)
  132. # 1 — files copied OK
  133. # 2 — extra files/dirs detected (not an error in /MIR mode)
  134. # 4 — mismatches detected
  135. # 8 — failures — files could not be copied
  136. # 16 — fatal error
  137. # Combinations possible (bitmask). >=8 means errors.
  138. $elapsed = [math]::Round(($end - $start).TotalMinutes, 1)
  139. Write-Log -Level INFO -Message "Elapsed: $elapsed min | Robocopy exit: $roboExit"
  140. # Extract failed files from log
  141. if (Test-Path $cloneLog) {
  142. $failedFiles = Select-String -Path $cloneLog -Pattern 'ERROR \d+ \(0x[0-9A-Fa-f]+\)' -ErrorAction SilentlyContinue
  143. if ($failedFiles) {
  144. $failedFiles | ForEach-Object { $_.Line } | Set-Content -Path $failedLog
  145. Write-Log -Level WARN -Message "$($failedFiles.Count) file(s) failed to copy — see: $failedLog"
  146. }
  147. }
  148. # Map robocopy exit to ATP semantics
  149. if ($roboExit -ge 16) {
  150. Write-Log -Level FAIL -Message "Fatal robocopy error — review $cloneLog"
  151. exit $script:EXIT_ERROR
  152. } elseif ($roboExit -ge 8) {
  153. Write-Log -Level WARN -Message "Some files could not be copied (drive-failure or permission). Clone is partial."
  154. exit 1 # partial success per ATP
  155. } else {
  156. Write-Log -Level PASS -Message "Clone complete. Robocopy code $roboExit (no errors)."
  157. exit $script:EXIT_OK
  158. }