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 all 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.2.0
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.4.1 // 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.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/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 @@ -237,6 +239,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.2.0 h1:JpdOPc/P6B3XyRoddn0iMiG/ADBi3AuEsv8RlTb+JeE=
github.com/mxk/go-vss v1.2.0/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
23 changes: 8 additions & 15 deletions internal/atomicfile/atomicfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,34 +21,27 @@
// Because long file names have certain limitations:
// - we must replace forward slashes with backslashes.
// - dummy path element (\.\) must be removed.
//
// Relative paths are always limited to a total of MAX_PATH characters:
// https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
func MaybePrefixLongFilenameOnWindows(fname string) string {
if runtime.GOOS != "windows" {
if runtime.GOOS != "windows" || len(fname) < maxPathLength ||
fname[:4] == `\\?\` || !ospath.IsAbs(fname) {
return fname
}

if len(fname) < maxPathLength {
return fname
}

fname = strings.TrimPrefix(fname, "\\\\?\\")

if !ospath.IsAbs(fname) {
// only convert absolute paths
return fname
}

fixed := strings.ReplaceAll(fname, "/", "\\")
fixed := strings.ReplaceAll(fname, "/", `\`)

Check warning on line 33 in internal/atomicfile/atomicfile.go

View check run for this annotation

Codecov / codecov/patch

internal/atomicfile/atomicfile.go#L33

Added line #L33 was not covered by tests

for {
fixed2 := strings.ReplaceAll(fixed, "\\.\\", "\\")
fixed2 := strings.ReplaceAll(fixed, `\.\`, `\`)

Check warning on line 36 in internal/atomicfile/atomicfile.go

View check run for this annotation

Codecov / codecov/patch

internal/atomicfile/atomicfile.go#L36

Added line #L36 was not covered by tests
if fixed2 == fixed {
break
}

fixed = fixed2
}

return "\\\\?\\" + fixed
return `\\?\` + fixed

Check warning on line 44 in internal/atomicfile/atomicfile.go

View check run for this annotation

Codecov / codecov/patch

internal/atomicfile/atomicfile.go#L44

Added line #L44 was not covered by tests
}

// Write is a wrapper around atomic.WriteFile that handles long file names on Windows.
Expand Down
4 changes: 2 additions & 2 deletions internal/logfile/logfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import (
)

var (
cliLogFormat = regexp.MustCompile(`^\d{4}-\d\d\-\d\dT\d\d:\d\d:\d\d\.\d{6}Z (DEBUG|INFO) [a-z/]+ .*$`)
cliLogFormat = regexp.MustCompile(`^\d{4}-\d\d\-\d\dT\d\d:\d\d:\d\d\.\d{6}Z (DEBUG|INFO|WARN) [a-z/]+ .*$`)
contentLogFormat = regexp.MustCompile(`^\d{4}-\d\d\-\d\dT\d\d:\d\d:\d\d\.\d{6}Z .*$`)
cliLogFormatLocalTimezone = regexp.MustCompile(`^\d{4}-\d\d\-\d\dT\d\d:\d\d:\d\d\.\d{6}[^Z][^ ]+ (DEBUG|INFO) [a-z/]+ .*$`)
cliLogFormatLocalTimezone = regexp.MustCompile(`^\d{4}-\d\d\-\d\dT\d\d:\d\d:\d\d\.\d{6}[^Z][^ ]+ (DEBUG|INFO|WARN) [a-z/]+ .*$`)
)

func TestLoggingFlags(t *testing.T) {
Expand Down
Loading
Loading