Skip to content

Use shadow copy (VSS) as backup base #9

Description

@neoground

Use volume shadow copy (VSS) and use it for the backup, so we can access all needed files. The VSS is deleted after we backed everything up.

Example:

# Helper: create a VSS snapshot for a drive letter ("C:", "D:", etc.) and return its root device path
function New-ShadowRoot {
    param([Parameter(Mandatory)] [string]$DriveLetter) # e.g., "C:"
    $driveSpec = ($DriveLetter.TrimEnd('\') + '\')

    $class  = Get-WmiObject -List Win32_ShadowCopy
    $create = $class.Create($driveSpec, 'ClientAccessible')
    if ($create.ReturnValue -ne 0) { throw "VSS create failed for $DriveLetter: $($create.ReturnValue)" }

    $shadow = Get-WmiObject Win32_ShadowCopy | Where-Object { $_.ID -eq $create.ShadowID }
    if (-not $shadow) { throw "VSS snapshot object not found for $DriveLetter." }

    # e.g. \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyXX\
    $root = ($shadow.DeviceObject.TrimEnd('\') + '\')
    [pscustomobject]@{
        Drive   = $DriveLetter.ToUpper()
        Shadow  = $shadow
        Root    = $root
    }
}

# Helper: convert a normal absolute path to its snapshot-based path using the given shadow root
function Convert-ToShadowPath {
    param(
        [Parameter(Mandatory)] [string]$Path,        # e.g. C:\Users
        [Parameter(Mandatory)] [string]$ShadowRoot   # e.g. \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyXX\
    )
    # Require absolute "X:\..." style
    if ($Path -notmatch '^[A-Za-z]:\\') { throw "Source must be an absolute path like C:\..., got: $Path" }
    $rel = $Path.Substring(3)     # strip "C:\" => "Users\..."
    if ([string]::IsNullOrWhiteSpace($rel)) { return $ShadowRoot }  # root of drive
    return (Join-Path $ShadowRoot $rel)
}

# Cache snapshots per drive so we only create one per volume in a run
$shadowCache = @{}   # key: "C:", value: object with .Shadow and .Root

# Track created snapshots for cleanup
$createdShadows = New-Object System.Collections.Generic.List[object]

try {
    foreach ($task in $config.backups) {
        $source       = $task.source          # e.g. "C:\Users" or "D:\Data"
        $destination  = $task.destination     # e.g. "\\192.168.0.2\backup\Users"
        $excludeFiles = $task.excludeFiles
        $excludeDirs  = $task.excludeDirs

        # Determine drive of source (e.g. "C:")
        if ($source -notmatch '^[A-Za-z]:\\') {
            throw "Source path must start with a drive letter: $source"
        }
        $drive = $source.Substring(0,2).ToUpper()

        # Get or create snapshot root for this drive
        if (-not $shadowCache.ContainsKey($drive)) {
            $snap = New-ShadowRoot -DriveLetter $drive
            $shadowCache[$drive] = $snap
            $createdShadows.Add($snap) | Out-Null
        }
        $srcRoot = $shadowCache[$drive].Root

        # Transform source to snapshot path
        $shadowSource = Convert-ToShadowPath -Path $source -ShadowRoot $srcRoot

        # Build robocopy command
        $robocopyCommand = @(
            'robocopy',
            ('"{0}"' -f $shadowSource),    # source from VSS
            ('"{0}"' -f $destination),     # UNC or local
            '/MIR',
            '/COPY:DT',
            '/DCOPY:T',
            '/XJ',
            '/NP',
            '/MT:12',
            "/R:$($config.retryCount)",
            "/W:$($config.waitTime)",
            "/LOG+:`"$($config.rclogPath)`""
        )

        # If you prefer restartable mode with backup fallback:
        # $robocopyCommand += '/ZB'
        # Or keep your original "/B" only:
        if ($isAdmin) { $robocopyCommand += '/B' }

        # If destination is UNC/NAS, add /FFT to tolerate 2-second timestamp granularity
        if ($destination -like '\\*') { $robocopyCommand += '/FFT' }

        # Exclusions
        foreach ($dir in ($excludeDirs | Where-Object { $_ })) {
            $robocopyCommand += '/XD'
            $robocopyCommand += ('"{0}"' -f $dir)
        }
        foreach ($file in ($excludeFiles | Where-Object { $_ })) {
            $robocopyCommand += '/XF'
            $robocopyCommand += ('"{0}"' -f $file)
        }

        $command = $robocopyCommand -join ' '

        "Executing (VSS): $command" | Out-File $logPath -Append
        # Use cmd.exe so robocopy gets its normal exit codes & logging behavior
        $rc = (cmd.exe /c $command).ExitCode

        # Optional: normalize robocopy exit codes (0–7 generally mean success of varying kinds)
        if ($rc -gt 7) {
            "Robocopy returned errorlevel $rc for source $source" | Out-File $logPath -Append
        }
    }
}
finally {
    # Clean up all snapshots we created
    foreach ($snap in $createdShadows) {
        try { [void]$snap.Shadow.Delete() } catch {}
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions