Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Volume Shadow Copy Service (VSS) on windows #2274

Merged
merged 1 commit into from Oct 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 12 additions & 0 deletions changelog/unreleased/issue-340
@@ -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
24 changes: 24 additions & 0 deletions cmd/restic/cmd_backup.go
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -96,6 +97,7 @@ type BackupOptions struct {
TimeStamp string
WithAtime bool
IgnoreInode bool
UseFsSnapshot bool
}

var backupOptions BackupOptions
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
}

fgma marked this conversation as resolved.
Show resolved Hide resolved
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")
Expand Down
13 changes: 12 additions & 1 deletion cmd/restic/integration_test.go
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
MichaelEischer marked this conversation as resolved.
Show resolved Hide resolved
}
}

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)
Expand Down
19 changes: 18 additions & 1 deletion doc/040_backup.rst
Expand Up @@ -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!

Expand Down
1 change: 1 addition & 0 deletions doc/manual_rest.rst
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions go.mod
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Expand Up @@ -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=
Expand Down
188 changes: 188 additions & 0 deletions internal/fs/fs_local_vss.go
@@ -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
MichaelEischer marked this conversation as resolved.
Show resolved Hide resolved
}

// 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()
MichaelEischer marked this conversation as resolved.
Show resolved Hide resolved

// 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)
MichaelEischer marked this conversation as resolved.
Show resolved Hide resolved
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 changes: 49 additions & 0 deletions internal/fs/vss.go
@@ -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 ""
}