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

feat(snapshots): Implement volume shadow copy support on Windows #3543

Merged
merged 24 commits into from
Feb 4, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/volume-shadow-copy-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Volume Shadow Copy Test
on:
push:
branches: [ master ]
tags:
- v*
pull_request:
branches: [ master ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
vss-test:
name: Volume Shadow Copy Test
runs-on: windows-latest
steps:
- name: Check out repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
with:
go-version-file: 'go.mod'
check-latest: true
id: go
- name: Install gsudo
shell: bash
run: |
choco install -y --no-progress gsudo
echo "C:\tools\gsudo\Current" >> $GITHUB_PATH
- name: Admin Test
run: gsudo make os-snapshot-tests
- name: Non-Admin Test
run: gsudo -i Medium make os-snapshot-tests
- name: Upload Logs
uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # v4.0.0
with:
name: logs
path: .logs/**/*.log
if-no-files-found: ignore
if: ${{ always() }}
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ download-rclone:
go run ./tools/gettool --tool rclone:$(RCLONE_VERSION) --output-dir dist/kopia_linux_arm_6/ --goos=linux --goarch=arm


ci-tests: vet test
ci-tests: vet test

ci-integration-tests:
$(MAKE) robustness-tool-tests socket-activation-tests
Expand Down Expand Up @@ -342,6 +342,11 @@ stress-test: $(gotestsum)
$(GO_TEST) -count=$(REPEAT_TEST) -timeout 3600s github.com/kopia/kopia/tests/stress_test
$(GO_TEST) -count=$(REPEAT_TEST) -timeout 3600s github.com/kopia/kopia/tests/repository_stress_test

os-snapshot-tests: export KOPIA_EXE ?= $(KOPIA_INTEGRATION_EXE)
os-snapshot-tests: GOTESTSUM_FORMAT=testname
os-snapshot-tests: build-integration-test-binary $(gotestsum)
$(GO_TEST) -count=$(REPEAT_TEST) github.com/kopia/kopia/tests/os_snapshot_test $(TEST_FLAGS)

layering-test:
ifneq ($(GOOS),windows)
# verify that code under repo/ can only import code also under repo/ + some
Expand Down Expand Up @@ -484,6 +489,6 @@ perf-benchmark-test-all:
$(MAKE) perf-benchmark-test PERF_BENCHMARK_VERSION=0.7.0~rc1

perf-benchmark-results:
gcloud compute scp $(PERF_BENCHMARK_INSTANCE):psrecord-* tests/perf_benchmark --zone=$(PERF_BENCHMARK_INSTANCE_ZONE)
gcloud compute scp $(PERF_BENCHMARK_INSTANCE):psrecord-* tests/perf_benchmark --zone=$(PERF_BENCHMARK_INSTANCE_ZONE)
gcloud compute scp $(PERF_BENCHMARK_INSTANCE):repo-size-* tests/perf_benchmark --zone=$(PERF_BENCHMARK_INSTANCE_ZONE)
(cd tests/perf_benchmark && go run process_results.go)
6 changes: 6 additions & 0 deletions cli/command_policy_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
policyLoggingFlags
policyRetentionFlags
policySchedulingFlags
policyOSSnapshotFlags
policyUploadFlags
}

Expand All @@ -39,6 +40,7 @@
c.policyLoggingFlags.setup(cmd)
c.policyRetentionFlags.setup(cmd)
c.policySchedulingFlags.setup(cmd)
c.policyOSSnapshotFlags.setup(cmd)
c.policyUploadFlags.setup(cmd)

cmd.Action(svc.repositoryWriterAction(c.run))
Expand Down Expand Up @@ -112,6 +114,10 @@
return errors.Wrap(err, "actions policy")
}

if err := c.setOSSnapshotPolicyFromFlags(ctx, &p.OSSnapshotPolicy, changeCount); err != nil {
return errors.Wrap(err, "OS snapshot policy")
}

Check warning on line 119 in cli/command_policy_set.go

View check run for this annotation

Codecov / codecov/patch

cli/command_policy_set.go#L118-L119

Added lines #L118 - L119 were not covered by tests

if err := c.setLoggingPolicyFromFlags(ctx, &p.LoggingPolicy, changeCount); err != nil {
return errors.Wrap(err, "actions policy")
}
Expand Down
64 changes: 64 additions & 0 deletions cli/command_policy_set_os_snapshot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package cli

import (
"context"

"github.com/alecthomas/kingpin/v2"
"github.com/pkg/errors"

"github.com/kopia/kopia/snapshot/policy"
)

type policyOSSnapshotFlags struct {
policyEnableVolumeShadowCopy string
}

func (c *policyOSSnapshotFlags) setup(cmd *kingpin.CmdClause) {
osSnapshotMode := []string{policy.OSSnapshotNeverString, policy.OSSnapshotAlwaysString, policy.OSSnapshotWhenAvailableString, inheritPolicyString}

cmd.Flag("enable-volume-shadow-copy", "Enable Volume Shadow Copy snapshots ('never', 'always', 'when-available', 'inherit')").PlaceHolder("MODE").EnumVar(&c.policyEnableVolumeShadowCopy, osSnapshotMode...)
}

func (c *policyOSSnapshotFlags) setOSSnapshotPolicyFromFlags(ctx context.Context, fp *policy.OSSnapshotPolicy, changeCount *int) error {
if err := applyPolicyOSSnapshotMode(ctx, "enable volume shadow copy", &fp.VolumeShadowCopy.Enable, c.policyEnableVolumeShadowCopy, changeCount); err != nil {
return errors.Wrap(err, "enable volume shadow copy")
}

Check warning on line 25 in cli/command_policy_set_os_snapshot.go

View check run for this annotation

Codecov / codecov/patch

cli/command_policy_set_os_snapshot.go#L24-L25

Added lines #L24 - L25 were not covered by tests

return nil
}

func applyPolicyOSSnapshotMode(ctx context.Context, desc string, val **policy.OSSnapshotMode, str string, changeCount *int) error {
if str == "" {
// not changed
return nil
}

var mode policy.OSSnapshotMode

switch str {
case inheritPolicyString, defaultPolicyString:
*changeCount++

log(ctx).Infof(" - resetting %q to a default value inherited from parent.", desc)

*val = nil

return nil

Check warning on line 46 in cli/command_policy_set_os_snapshot.go

View check run for this annotation

Codecov / codecov/patch

cli/command_policy_set_os_snapshot.go#L39-L46

Added lines #L39 - L46 were not covered by tests
case policy.OSSnapshotNeverString:
mode = policy.OSSnapshotNever
case policy.OSSnapshotAlwaysString:
mode = policy.OSSnapshotAlways
case policy.OSSnapshotWhenAvailableString:
mode = policy.OSSnapshotWhenAvailable
default:
return errors.Errorf("invalid %q mode %q", desc, str)

Check warning on line 54 in cli/command_policy_set_os_snapshot.go

View check run for this annotation

Codecov / codecov/patch

cli/command_policy_set_os_snapshot.go#L51-L54

Added lines #L51 - L54 were not covered by tests
}

*changeCount++

log(ctx).Infof(" - setting %q to %v.", desc, mode)

*val = &mode

return nil
}
42 changes: 42 additions & 0 deletions cli/command_policy_set_os_snapshot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cli_test

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/kopia/kopia/internal/testutil"
"github.com/kopia/kopia/tests/testenv"
)

func TestSetOSSnapshotPolicy(t *testing.T) {
e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, testenv.NewInProcRunner(t))
defer e.RunAndExpectSuccess(t, "repo", "disconnect")

e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)

lines := e.RunAndExpectSuccess(t, "policy", "show", "--global")
lines = compressSpaces(lines)
require.Contains(t, lines, " Volume Shadow Copy: when-available (defined for this target)")

// make some directory we'll be setting policy on
td := testutil.TempDirectory(t)

lines = e.RunAndExpectSuccess(t, "policy", "show", td)
lines = compressSpaces(lines)
require.Contains(t, lines, " Volume Shadow Copy: when-available inherited from (global)")

e.RunAndExpectSuccess(t, "policy", "set", "--global", "--enable-volume-shadow-copy=always")

lines = e.RunAndExpectSuccess(t, "policy", "show", td)
lines = compressSpaces(lines)

require.Contains(t, lines, " Volume Shadow Copy: always inherited from (global)")

e.RunAndExpectSuccess(t, "policy", "set", "--enable-volume-shadow-copy=never", td)

lines = e.RunAndExpectSuccess(t, "policy", "show", td)
lines = compressSpaces(lines)

require.Contains(t, lines, " Volume Shadow Copy: never (defined for this target)")
}
11 changes: 11 additions & 0 deletions cli/command_policy_show.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ func printPolicy(out *textOutput, p *policy.Policy, def *policy.Definition) {
rows = append(rows, policyTableRow{})
rows = appendActionsPolicyRows(rows, p, def)
rows = append(rows, policyTableRow{})
rows = appendOSSnapshotPolicyRows(rows, p, def)
rows = append(rows, policyTableRow{})
rows = appendLoggingPolicyRows(rows, p, def)

out.printStdout("Policy for %v:\n\n%v\n", p.Target(), alignedPolicyTableRows(rows))
Expand Down Expand Up @@ -449,6 +451,15 @@ func appendActionCommandRows(rows []policyTableRow, h *policy.ActionCommand) []p
return rows
}

func appendOSSnapshotPolicyRows(rows []policyTableRow, p *policy.Policy, def *policy.Definition) []policyTableRow {
rows = append(rows,
policyTableRow{"OS-level snapshot support:", "", ""},
policyTableRow{" Volume Shadow Copy:", p.OSSnapshotPolicy.VolumeShadowCopy.Enable.String(), definitionPointToString(p.Target(), def.OSSnapshotPolicy.VolumeShadowCopy.Enable)},
)

return rows
}

func valueOrNotSet(p *policy.OptionalInt) string {
if p == nil {
return "-"
Expand Down
17 changes: 16 additions & 1 deletion fs/localfs/local_fs_os.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"io"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"

"github.com/pkg/errors"

Expand Down Expand Up @@ -109,7 +111,20 @@

fi, err := os.Lstat(path)
if err != nil {
return nil, errors.Wrap(err, "unable to determine entry type")
// Paths such as `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy01`
// cause os.Lstat to fail with "Incorrect function" error unless they
// end with a separator. Retry the operation with the separator added.
var e syscall.Errno
//nolint:goconst
if runtime.GOOS == "windows" &&
!strings.HasSuffix(path, string(filepath.Separator)) &&
errors.As(err, &e) && e == 1 {
fi, err = os.Lstat(path + string(filepath.Separator))
}

Check warning on line 123 in fs/localfs/local_fs_os.go

View check run for this annotation

Codecov / codecov/patch

fs/localfs/local_fs_os.go#L122-L123

Added lines #L122 - L123 were not covered by tests

if err != nil {
return nil, errors.Wrap(err, "unable to determine entry type")
}
}

if path == "/" {
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ require (
github.com/kylelemons/godebug v1.1.0
github.com/mattn/go-colorable v0.1.13
github.com/minio/minio-go/v7 v7.0.66
github.com/mxk/go-vss v1.1.1
github.com/natefinch/atomic v1.0.1
github.com/pierrec/lz4 v2.6.1+incompatible
github.com/pkg/errors v0.9.1
Expand Down Expand Up @@ -93,6 +94,7 @@ require (
github.com/frankban/quicktest v1.13.1 // indirect
github.com/go-logr/logr v1.3.0 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.3.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
Expand Down Expand Up @@ -239,6 +241,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mxk/go-vss v1.1.1 h1:4arKWVTBdFuHSBoHZvA45NVKmnwBcWcSbVedSOnfqcg=
github.com/mxk/go-vss v1.1.1/go.mod h1:ZQ4yFxCG54vqPnCd+p2IxAe5jwZdz56wSjbwzBXiFd8=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
Expand Down
86 changes: 86 additions & 0 deletions snapshot/policy/os_snapshot_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package policy

import "github.com/kopia/kopia/snapshot"

// OSSnapshotPolicy describes settings for OS-level snapshots.
type OSSnapshotPolicy struct {
VolumeShadowCopy VolumeShadowCopyPolicy `json:"volumeShadowCopy,omitempty"`
}

// OSSnapshotPolicyDefinition specifies which policy definition provided the value of a particular field.
type OSSnapshotPolicyDefinition struct {
VolumeShadowCopy VolumeShadowCopyPolicyDefinition `json:"volumeShadowCopy,omitempty"`
}

// Merge applies default values from the provided policy.
func (p *OSSnapshotPolicy) Merge(src OSSnapshotPolicy, def *OSSnapshotPolicyDefinition, si snapshot.SourceInfo) {
p.VolumeShadowCopy.Merge(src.VolumeShadowCopy, &def.VolumeShadowCopy, si)
}

// VolumeShadowCopyPolicy describes settings for Windows Volume Shadow Copy
// snapshots.
type VolumeShadowCopyPolicy struct {
Enable *OSSnapshotMode `json:"enable,omitempty"`
}

// VolumeShadowCopyPolicyDefinition specifies which policy definition provided
// the value of a particular field.
type VolumeShadowCopyPolicyDefinition struct {
Enable snapshot.SourceInfo `json:"enable,omitempty"`
}

// Merge applies default values from the provided policy.
func (p *VolumeShadowCopyPolicy) Merge(src VolumeShadowCopyPolicy, def *VolumeShadowCopyPolicyDefinition, si snapshot.SourceInfo) {
mergeOSSnapshotMode(&p.Enable, src.Enable, &def.Enable, si)
}

// OSSnapshotMode specifies whether OS-level snapshots are used for file systems
// that support them.
type OSSnapshotMode byte

// OS-level snapshot modes.
const (
OSSnapshotNever OSSnapshotMode = iota // Disable OS-level snapshots
OSSnapshotAlways // Fail if an OS-level snapshot cannot be created
OSSnapshotWhenAvailable // Fall back to regular file access on error
)

// OS-level snapshot mode strings.
const (
OSSnapshotNeverString = "never"
OSSnapshotAlwaysString = "always"
OSSnapshotWhenAvailableString = "when-available"
)

// NewOSSnapshotMode provides an OptionalBool pointer.
func NewOSSnapshotMode(m OSSnapshotMode) *OSSnapshotMode {
return &m
}

// OrDefault returns the OS snapshot mode or the provided default.
func (m *OSSnapshotMode) OrDefault(def OSSnapshotMode) OSSnapshotMode {
mxk marked this conversation as resolved.
Show resolved Hide resolved
if m == nil {
return def
}

Check warning on line 64 in snapshot/policy/os_snapshot_policy.go

View check run for this annotation

Codecov / codecov/patch

snapshot/policy/os_snapshot_policy.go#L61-L64

Added lines #L61 - L64 were not covered by tests

return *m

Check warning on line 66 in snapshot/policy/os_snapshot_policy.go

View check run for this annotation

Codecov / codecov/patch

snapshot/policy/os_snapshot_policy.go#L66

Added line #L66 was not covered by tests
}

func (m OSSnapshotMode) String() string {
switch m {
case OSSnapshotAlways:
return OSSnapshotAlwaysString
case OSSnapshotWhenAvailable:
return OSSnapshotWhenAvailableString
default:
return OSSnapshotNeverString
}
}

func mergeOSSnapshotMode(target **OSSnapshotMode, src *OSSnapshotMode, def *snapshot.SourceInfo, si snapshot.SourceInfo) {
if *target == nil && src != nil {
v := *src
*target = &v
*def = si
}
}
Loading
Loading