recover-clone.ps1 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  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. . (Join-Path $PSScriptRoot '..\..\_lib\term.ps1')
  69. Initialize-Term
  70. # Preflight
  71. $robo = Get-Command robocopy.exe -ErrorAction SilentlyContinue
  72. if (-not $robo) {
  73. Write-Log -Level FAIL -Message "robocopy.exe not on PATH (should be present on all Windows installs)"
  74. exit $script:EXIT_PRECONDITION
  75. }
  76. if (-not (Test-Path $Source)) {
  77. Write-Log -Level FAIL -Message "Source not found: $Source"
  78. exit $script:EXIT_NOT_FOUND
  79. }
  80. # Capacity preflight
  81. try {
  82. $srcUsedGB = [math]::Round((Get-ChildItem $Source -Recurse -Force -ErrorAction SilentlyContinue |
  83. Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum / 1GB, 1)
  84. } catch { $srcUsedGB = -1 }
  85. $destDriveLetter = $Destination.Substring(0, 1).ToUpper()
  86. $destDrive = Get-PSDrive -PSProvider FileSystem -Name $destDriveLetter -ErrorAction SilentlyContinue
  87. $destFreeGB = if ($destDrive) { [math]::Round($destDrive.Free / 1GB, 1) } else { -1 }
  88. if ($srcUsedGB -gt 0 -and $destFreeGB -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. # Timestamps and log paths
  93. $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss')
  94. $cloneLog = Join-Path $LogDir "recover-clone-$stamp.log"
  95. $failedLog = Join-Path $LogDir "recover-clone-failed-$stamp.log"
  96. # Build robocopy command
  97. $roboArgs = @($Source, $Destination)
  98. if ($NoMirror) {
  99. $roboArgs += '/E' # subdirectories incl. empty
  100. } else {
  101. $roboArgs += '/MIR' # mirror
  102. }
  103. $roboArgs += '/XJ' # skip junction points
  104. $roboArgs += '/COPY:DAT' # data, attributes, timestamps (skip ACL for speed)
  105. $roboArgs += '/DCOPY:T' # also copy directory timestamps
  106. $roboArgs += "/R:$MaxRetries"
  107. $roboArgs += '/W:0'
  108. $roboArgs += '/MT:8' # 8 threads
  109. $roboArgs += '/V' # verbose — list skipped files
  110. $roboArgs += '/BYTES' # report sizes in bytes (cleaner for parsing)
  111. $roboArgs += '/NP' # no per-file progress (cleaner log)
  112. $roboArgs += "/LOG:$cloneLog"
  113. $roboArgs += '/TEE' # console + log
  114. if ($DryRun) {
  115. $roboArgs += '/L' # list only — no actual copy
  116. Write-Log -Level INFO -Message "DRY-RUN — robocopy /L will enumerate without copying"
  117. }
  118. # ─── Preflight panel ─────────────────────────────────────────────────────────
  119. $mode = if ($DryRun) { 'dry-run' } elseif ($NoMirror) { 'copy' } else { 'mirror' }
  120. Write-TermLine (New-TermPanelOpen -Brand 'windows-ops' -Name 'windows-ops' -Subtitle 'recover-clone' -Indicator $mode)
  121. Write-TermLine (New-TermPanelVert)
  122. $srcDisplay = if ($srcUsedGB -gt 0) { "$srcUsedGB GB" } else { 'size unknown' }
  123. $dstDisplay = if ($destFreeGB -gt 0) { "$destFreeGB GB free" } else { 'free space unknown' }
  124. Write-TermLine (New-TermSummary -Text "$Source → $Destination · $srcDisplay · destination has $dstDisplay")
  125. Write-TermLine (New-TermPanelVert)
  126. Write-TermLine (New-TermSection -State 'INFO' -Label 'robocopy invocation' -Count -1)
  127. Write-TermLine (New-TermLeaf -Name 'retries per file' -Meta "$MaxRetries (0 = recommended for failing drives)")
  128. Write-TermLine (New-TermLeaf -Name 'mirror mode' -Meta $(if ($NoMirror) { '/E (subtree, no delete)' } else { '/MIR' }))
  129. Write-TermLine (New-TermLeaf -Name 'threads' -Meta '/MT:8')
  130. Write-TermLine (New-TermLeaf -Name 'log' -Meta $cloneLog -IsLast)
  131. if ($DryRun) {
  132. Write-TermLine (New-TermAlert -Severity warning -Text 'DRY-RUN — robocopy /L enumerates without copying')
  133. }
  134. Write-TermLine (New-TermPanelVert)
  135. Write-TermLine (New-TermPanelClose -Hotkeys (New-TermHotkey -Key '?' -Verb 'help') -Healths (New-TermHealth -State 'pending' -Text 'starting'))
  136. if (-not $PSCmdlet.ShouldProcess("$Source -> $Destination", "robocopy clone")) {
  137. Write-Log -Level INFO -Message "WhatIf: would run but skipped due to -WhatIf"
  138. exit $script:EXIT_OK
  139. }
  140. # ─── Run robocopy (its own native output goes to its TEE'd console + log) ───
  141. $start = Get-Date
  142. & robocopy.exe @roboArgs
  143. $roboExit = $LASTEXITCODE
  144. $end = Get-Date
  145. # Decode robocopy exit code
  146. # 0 — no files copied (nothing to do)
  147. # 1 — files copied OK
  148. # 2 — extra files/dirs detected (not an error in /MIR mode)
  149. # 4 — mismatches detected
  150. # 8 — failures — files could not be copied
  151. # 16 — fatal error
  152. # Combinations possible (bitmask). >=8 means errors.
  153. $elapsed = [math]::Round(($end - $start).TotalMinutes, 1)
  154. # Extract failed files from log
  155. $failedCount = 0
  156. if (Test-Path $cloneLog) {
  157. $failedFiles = Select-String -Path $cloneLog -Pattern 'ERROR \d+ \(0x[0-9A-Fa-f]+\)' -ErrorAction SilentlyContinue
  158. if ($failedFiles) {
  159. $failedFiles | ForEach-Object { $_.Line } | Set-Content -Path $failedLog
  160. $failedCount = $failedFiles.Count
  161. }
  162. }
  163. # Determine verdict
  164. $verdictState = if ($roboExit -ge 16) { 'FAILING' }
  165. elseif ($roboExit -ge 8) { 'WARN' }
  166. else { 'PASS' }
  167. $verdictText = switch ($verdictState) {
  168. 'FAILING' { 'fatal robocopy error' }
  169. 'WARN' { 'partial clone — some files unreadable' }
  170. 'PASS' { 'clone complete' }
  171. }
  172. # ─── Results panel ───────────────────────────────────────────────────────────
  173. Write-TermLine ''
  174. Write-TermLine (New-TermPanelOpen -Brand 'windows-ops' -Name 'windows-ops' -Subtitle 'recover-clone · results' -Indicator "${elapsed} min")
  175. Write-TermLine (New-TermPanelVert)
  176. Write-TermLine (New-TermSummary -Text "$verdictText · robocopy exit $roboExit")
  177. Write-TermLine (New-TermPanelVert)
  178. Write-TermLine (New-TermSection -State $verdictState -Label $verdictState.ToLower() -Count -1)
  179. Write-TermLine (New-TermLeaf -Name 'elapsed' -Meta "$elapsed minutes")
  180. Write-TermLine (New-TermLeaf -Name 'failed reads' -Meta "$failedCount files")
  181. Write-TermLine (New-TermLeaf -Name 'clone log' -Meta $cloneLog)
  182. if ($failedCount -gt 0) {
  183. Write-TermLine (New-TermLeaf -Name 'failed list' -Meta $failedLog -IsLast)
  184. Write-TermLine (New-TermAlert -Severity warning -Text "$failedCount file(s) unreadable from source — review $failedLog and consider ddrescue for bit-level recovery")
  185. } else {
  186. Write-TermLine (New-TermLeaf -Name 'failures' -Meta 'none' -IsLast)
  187. }
  188. Write-TermLine (New-TermPanelVert)
  189. $footerHealth = switch ($verdictState) {
  190. 'FAILING' { New-TermHealth -State 'critical' -Text 'fatal' }
  191. 'WARN' { New-TermHealth -State 'warning' -Text "$failedCount lost" }
  192. 'PASS' { New-TermHealth -State 'healthy' -Text 'complete' }
  193. }
  194. Write-TermLine (New-TermPanelClose -Hotkeys (New-TermHotkey -Key '?' -Verb 'help') -Healths $footerHealth)
  195. # Map robocopy exit to ATP semantics
  196. if ($roboExit -ge 16) { exit $script:EXIT_ERROR }
  197. elseif ($roboExit -ge 8) { exit 1 }
  198. else { exit $script:EXIT_OK }