forked from TrueCloudLab/restic
vss: Implement VSS support for Windows
The VSS support works for 32 and 64-bit windows, this includes a check that the restic version matches the OS architecture as required by VSS. The backup operation will fail the user has not sufficient permissions to use VSS. Snapshotting volumes also covers mountpoints but skips UNC paths.
This commit is contained in:
parent
5fd3dbccb7
commit
5695f9ebd2
10 changed files with 1373 additions and 2 deletions
12
changelog/unreleased/issue-340
Normal file
12
changelog/unreleased/issue-340
Normal file
|
@ -0,0 +1,12 @@
|
|||
Enhancement: Add support for Volume Shadow Copy Service (VSS) on Windows
|
||||
|
||||
Volume Shadow Copy Service allows read access to files that are locked by
|
||||
another process using an exclusive lock through a filesystem snapshot. Restic
|
||||
was unable to backup those files before. This update enables backing up these
|
||||
files.
|
||||
|
||||
This needs to be enabled explicitely using the --use-fs-snapshot option of the
|
||||
backup command.
|
||||
|
||||
https://github.com/restic/restic/issues/340
|
||||
https://github.com/restic/restic/pull/2274
|
|
@ -10,6 +10,7 @@ import (
|
|||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -96,6 +97,7 @@ type BackupOptions struct {
|
|||
TimeStamp string
|
||||
WithAtime bool
|
||||
IgnoreInode bool
|
||||
UseFsSnapshot bool
|
||||
}
|
||||
|
||||
var backupOptions BackupOptions
|
||||
|
@ -129,6 +131,9 @@ func init() {
|
|||
f.StringVar(&backupOptions.TimeStamp, "time", "", "`time` of the backup (ex. '2012-11-01 22:08:41') (default: now)")
|
||||
f.BoolVar(&backupOptions.WithAtime, "with-atime", false, "store the atime for all files and directories")
|
||||
f.BoolVar(&backupOptions.IgnoreInode, "ignore-inode", false, "ignore inode number changes when checking for modified files")
|
||||
if runtime.GOOS == "windows" {
|
||||
f.BoolVar(&backupOptions.UseFsSnapshot, "use-fs-snapshot", false, "use filesystem snapshot where possible (currently only Windows VSS)")
|
||||
}
|
||||
}
|
||||
|
||||
// filterExisting returns a slice of all existing items, or an error if no
|
||||
|
@ -550,6 +555,25 @@ func runBackup(opts BackupOptions, gopts GlobalOptions, term *termstatus.Termina
|
|||
}
|
||||
|
||||
var targetFS fs.FS = fs.Local{}
|
||||
if runtime.GOOS == "windows" && opts.UseFsSnapshot {
|
||||
if !fs.HasSufficientPrivilegesForVSS() {
|
||||
return errors.Fatal("user doesn't have sufficient privileges to use VSS snapshots\n")
|
||||
}
|
||||
|
||||
errorHandler := func(item string, err error) error {
|
||||
return p.Error(item, nil, err)
|
||||
}
|
||||
|
||||
messageHandler := func(msg string, args ...interface{}) {
|
||||
if !gopts.JSON {
|
||||
p.P(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
localVss := fs.NewLocalVss(errorHandler, messageHandler)
|
||||
defer localVss.DeleteSnapshots()
|
||||
targetFS = localVss
|
||||
}
|
||||
if opts.Stdin {
|
||||
if !gopts.JSON {
|
||||
p.V("read data from stdin")
|
||||
|
|
|
@ -21,6 +21,7 @@ import (
|
|||
|
||||
"github.com/restic/restic/internal/errors"
|
||||
"github.com/restic/restic/internal/filter"
|
||||
"github.com/restic/restic/internal/fs"
|
||||
"github.com/restic/restic/internal/repository"
|
||||
"github.com/restic/restic/internal/restic"
|
||||
rtest "github.com/restic/restic/internal/test"
|
||||
|
@ -281,11 +282,21 @@ func testSetupBackupData(t testing.TB, env *testEnvironment) string {
|
|||
}
|
||||
|
||||
func TestBackup(t *testing.T) {
|
||||
testBackup(t, false)
|
||||
}
|
||||
|
||||
func TestBackupWithFilesystemSnapshots(t *testing.T) {
|
||||
if runtime.GOOS == "windows" && fs.HasSufficientPrivilegesForVSS() {
|
||||
testBackup(t, true)
|
||||
}
|
||||
}
|
||||
|
||||
func testBackup(t *testing.T, useFsSnapshot bool) {
|
||||
env, cleanup := withTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
testSetupBackupData(t, env)
|
||||
opts := BackupOptions{}
|
||||
opts := BackupOptions{UseFsSnapshot: useFsSnapshot}
|
||||
|
||||
// first backup
|
||||
testRunBackup(t, filepath.Dir(env.testdata), []string{"testdata"}, opts, env.gopts)
|
||||
|
|
|
@ -50,7 +50,24 @@ still get a nice live status display. Be aware that the live status shows the
|
|||
processed files and not the transferred data. Transferred volume might be lower
|
||||
(due to de-duplication) or higher.
|
||||
|
||||
If you run the command again, restic will create another snapshot of
|
||||
On Windows, the ``--use-fs-snapshot`` option will use Windows' Volume Shadow Copy
|
||||
Service (VSS) when creating backups. Restic will transparently create a VSS
|
||||
snapshot for each volume that contains files to backup. Files are read from the
|
||||
VSS snapshot instead of the regular filesystem. This allows to backup files that are
|
||||
exclusively locked by another process during the backup.
|
||||
|
||||
By default VSS ignores Outlook OST files. This is not a restriction of restic
|
||||
but the default Windows VSS configuration. The files not to snapshot are
|
||||
configured in the Windows registry under the following key:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\BackupRestore\FilesNotToSnapshot
|
||||
|
||||
For more details refer the official Windows documentation e.g. the article
|
||||
``Registry Keys and Values for Backup and Restore``.
|
||||
|
||||
If you run the backup command again, restic will create another snapshot of
|
||||
your data, but this time it's even faster and no new data was added to the
|
||||
repository (since all data is already there). This is de-duplication at work!
|
||||
|
||||
|
|
|
@ -107,6 +107,7 @@ command:
|
|||
--stdin-filename filename filename to use when reading from stdin (default "stdin")
|
||||
--tag tag add a tag for the new snapshot (can be specified multiple times)
|
||||
--time time time of the backup (ex. '2012-11-01 22:08:41') (default: now)
|
||||
--use-fs-snapshot use filesystem snapshot where possible (currently only Windows VSS)
|
||||
--with-atime store the atime for all files and directories
|
||||
|
||||
Global Flags:
|
||||
|
|
1
go.mod
1
go.mod
|
@ -11,6 +11,7 @@ require (
|
|||
github.com/dchest/siphash v1.2.2
|
||||
github.com/dnaeon/go-vcr v1.0.1 // indirect
|
||||
github.com/elithrar/simple-scrypt v1.3.0
|
||||
github.com/go-ole/go-ole v1.2.4
|
||||
github.com/google/go-cmp v0.5.2
|
||||
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4
|
||||
|
|
2
go.sum
2
go.sum
|
@ -95,6 +95,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
|
|||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI=
|
||||
github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
|
|
188
internal/fs/fs_local_vss.go
Normal file
188
internal/fs/fs_local_vss.go
Normal file
|
@ -0,0 +1,188 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/restic/restic/internal/errors"
|
||||
)
|
||||
|
||||
// ErrorHandler is used to report errors via callback
|
||||
type ErrorHandler func(item string, err error) error
|
||||
|
||||
// MessageHandler is used to report errors/messages via callbacks.
|
||||
type MessageHandler func(msg string, args ...interface{})
|
||||
|
||||
// LocalVss is a wrapper around the local file system which uses windows volume
|
||||
// shadow copy service (VSS) in a transparent way.
|
||||
type LocalVss struct {
|
||||
FS
|
||||
snapshots map[string]VssSnapshot
|
||||
failedSnapshots map[string]struct{}
|
||||
mutex *sync.RWMutex
|
||||
msgError ErrorHandler
|
||||
msgMessage MessageHandler
|
||||
}
|
||||
|
||||
// statically ensure that LocalVss implements FS.
|
||||
var _ FS = &LocalVss{}
|
||||
|
||||
// NewLocalVss creates a new wrapper around the windows filesystem using volume
|
||||
// shadow copy service to access locked files.
|
||||
func NewLocalVss(msgError ErrorHandler, msgMessage MessageHandler) *LocalVss {
|
||||
return &LocalVss{
|
||||
FS: Local{},
|
||||
snapshots: make(map[string]VssSnapshot),
|
||||
failedSnapshots: make(map[string]struct{}),
|
||||
mutex: &sync.RWMutex{},
|
||||
msgError: msgError,
|
||||
msgMessage: msgMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteSnapshots deletes all snapshots that were created automatically.
|
||||
func (fs *LocalVss) DeleteSnapshots() {
|
||||
fs.mutex.Lock()
|
||||
defer fs.mutex.Unlock()
|
||||
|
||||
activeSnapshots := make(map[string]VssSnapshot)
|
||||
|
||||
for volumeName, snapshot := range fs.snapshots {
|
||||
if err := snapshot.Delete(); err != nil {
|
||||
fs.msgError(volumeName, errors.Errorf("failed to delete VSS snapshot: %s", err))
|
||||
activeSnapshots[volumeName] = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
fs.snapshots = activeSnapshots
|
||||
}
|
||||
|
||||
// Open wraps the Open method of the underlying file system.
|
||||
func (fs *LocalVss) Open(name string) (File, error) {
|
||||
return os.Open(fs.snapshotPath(name))
|
||||
}
|
||||
|
||||
// OpenFile wraps the Open method of the underlying file system.
|
||||
func (fs *LocalVss) OpenFile(name string, flag int, perm os.FileMode) (File, error) {
|
||||
return os.OpenFile(fs.snapshotPath(name), flag, perm)
|
||||
}
|
||||
|
||||
// Stat wraps the Open method of the underlying file system.
|
||||
func (fs *LocalVss) Stat(name string) (os.FileInfo, error) {
|
||||
return os.Stat(fs.snapshotPath(name))
|
||||
}
|
||||
|
||||
// Lstat wraps the Open method of the underlying file system.
|
||||
func (fs *LocalVss) Lstat(name string) (os.FileInfo, error) {
|
||||
return os.Lstat(fs.snapshotPath(name))
|
||||
}
|
||||
|
||||
// snapshotPath returns the path inside a VSS snapshots if it already exists.
|
||||
// If the path is not yet available as a snapshot, a snapshot is created.
|
||||
// If creation of a snapshot fails the file's original path is returned as
|
||||
// a fallback.
|
||||
func (fs *LocalVss) snapshotPath(path string) string {
|
||||
|
||||
fixPath := fixpath(path)
|
||||
|
||||
if strings.HasPrefix(fixPath, `\\?\UNC\`) {
|
||||
// UNC network shares are currently not supported so we access the regular file
|
||||
// without snapshotting
|
||||
// TODO: right now there is a problem in fixpath(): "\\host\share" is not returned as a UNC path
|
||||
// "\\host\share\" is returned as a valid UNC path
|
||||
return path
|
||||
}
|
||||
|
||||
fixPath = strings.TrimPrefix(fixpath(path), `\\?\`)
|
||||
fixPathLower := strings.ToLower(fixPath)
|
||||
volumeName := filepath.VolumeName(fixPath)
|
||||
volumeNameLower := strings.ToLower(volumeName)
|
||||
|
||||
fs.mutex.RLock()
|
||||
|
||||
// ensure snapshot for volume exists
|
||||
_, snapshotExists := fs.snapshots[volumeNameLower]
|
||||
_, snapshotFailed := fs.failedSnapshots[volumeNameLower]
|
||||
if !snapshotExists && !snapshotFailed {
|
||||
fs.mutex.RUnlock()
|
||||
fs.mutex.Lock()
|
||||
defer fs.mutex.Unlock()
|
||||
|
||||
_, snapshotExists = fs.snapshots[volumeNameLower]
|
||||
_, snapshotFailed = fs.failedSnapshots[volumeNameLower]
|
||||
|
||||
if !snapshotExists && !snapshotFailed {
|
||||
vssVolume := volumeNameLower + string(filepath.Separator)
|
||||
fs.msgMessage("creating VSS snapshot for [%s]\n", vssVolume)
|
||||
|
||||
if snapshot, err := NewVssSnapshot(vssVolume, 120, fs.msgError); err != nil {
|
||||
fs.msgError(vssVolume, errors.Errorf("failed to create snapshot for [%s]: %s\n",
|
||||
vssVolume, err))
|
||||
fs.failedSnapshots[volumeNameLower] = struct{}{}
|
||||
} else {
|
||||
fs.snapshots[volumeNameLower] = snapshot
|
||||
fs.msgMessage("successfully created snapshot for [%s]\n", vssVolume)
|
||||
if len(snapshot.mountPointInfo) > 0 {
|
||||
fs.msgMessage("mountpoints in snapshot volume [%s]:\n", vssVolume)
|
||||
for mp, mpInfo := range snapshot.mountPointInfo {
|
||||
info := ""
|
||||
if !mpInfo.IsSnapshotted() {
|
||||
info = " (not snapshotted)"
|
||||
}
|
||||
fs.msgMessage(" - %s%s\n", mp, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
defer fs.mutex.RUnlock()
|
||||
}
|
||||
|
||||
var snapshotPath string
|
||||
if snapshot, ok := fs.snapshots[volumeNameLower]; ok {
|
||||
// handle case when data is inside mountpoint
|
||||
for mountPoint, info := range snapshot.mountPointInfo {
|
||||
if HasPathPrefix(mountPoint, fixPathLower) {
|
||||
if !info.IsSnapshotted() {
|
||||
// requested path is under mount point but mount point is
|
||||
// not available as a snapshot (e.g. no filesystem support,
|
||||
// removable media, etc.)
|
||||
// -> try to backup without a snapshot
|
||||
return path
|
||||
}
|
||||
|
||||
// filepath.rel() should always succeed because we checked that fixPath is either
|
||||
// the same path or below mountPoint and operation is case-insensitive
|
||||
relativeToMount, err := filepath.Rel(mountPoint, fixPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
snapshotPath = fs.Join(info.GetSnapshotDeviceObject(), relativeToMount)
|
||||
|
||||
if snapshotPath == info.GetSnapshotDeviceObject() {
|
||||
snapshotPath += string(filepath.Separator)
|
||||
}
|
||||
|
||||
return snapshotPath
|
||||
}
|
||||
}
|
||||
|
||||
// requested data is directly on the volume, not inside a mount point
|
||||
snapshotPath = fs.Join(snapshot.GetSnapshotDeviceObject(),
|
||||
strings.TrimPrefix(fixPath, volumeName))
|
||||
if snapshotPath == snapshot.GetSnapshotDeviceObject() {
|
||||
snapshotPath = snapshotPath + string(filepath.Separator)
|
||||
}
|
||||
|
||||
} else {
|
||||
// no snapshot is available for the requested path:
|
||||
// -> try to backup without a snapshot
|
||||
// TODO: log warning?
|
||||
snapshotPath = path
|
||||
}
|
||||
|
||||
return snapshotPath
|
||||
}
|
49
internal/fs/vss.go
Normal file
49
internal/fs/vss.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
// +build !windows
|
||||
|
||||
package fs
|
||||
|
||||
import (
|
||||
"github.com/restic/restic/internal/errors"
|
||||
)
|
||||
|
||||
// MountPoint is a dummy for non-windows platforms to let client code compile.
|
||||
type MountPoint struct {
|
||||
}
|
||||
|
||||
// IsSnapshotted is true if this mount point was snapshotted successfully.
|
||||
func (p *MountPoint) IsSnapshotted() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetSnapshotDeviceObject returns root path to access the snapshot files and folders.
|
||||
func (p *MountPoint) GetSnapshotDeviceObject() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// VssSnapshot is a dummy for non-windows platforms to let client code compile.
|
||||
type VssSnapshot struct {
|
||||
mountPointInfo map[string]MountPoint
|
||||
}
|
||||
|
||||
// HasSufficientPrivilegesForVSS returns true if the user is allowed to use VSS.
|
||||
func HasSufficientPrivilegesForVSS() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// NewVssSnapshot creates a new vss snapshot. If creating the snapshots doesn't
|
||||
// finish within the timeout an error is returned.
|
||||
func NewVssSnapshot(
|
||||
volume string, timeoutInSeconds uint, msgError ErrorHandler) (VssSnapshot, error) {
|
||||
return VssSnapshot{}, errors.New("VSS snapshots are only supported on windows")
|
||||
}
|
||||
|
||||
// Delete deletes the created snapshot.
|
||||
func (p *VssSnapshot) Delete() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSnapshotDeviceObject returns root path to access the snapshot files
|
||||
// and folders.
|
||||
func (p *VssSnapshot) GetSnapshotDeviceObject() string {
|
||||
return ""
|
||||
}
|
1066
internal/fs/vss_windows.go
Normal file
1066
internal/fs/vss_windows.go
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue