diff --git a/changelogs/unreleased/9994-Lyndon-Li b/changelogs/unreleased/9994-Lyndon-Li new file mode 100644 index 000000000..81ea8a01e --- /dev/null +++ b/changelogs/unreleased/9994-Lyndon-Li @@ -0,0 +1 @@ +Add block device operations for block uploader backup \ No newline at end of file diff --git a/pkg/uploader/block/dev_linux.go b/pkg/uploader/block/dev_linux.go index 85b378c55..297815390 100644 --- a/pkg/uploader/block/dev_linux.go +++ b/pkg/uploader/block/dev_linux.go @@ -21,11 +21,58 @@ package block import ( "os" + "path/filepath" + "syscall" "github.com/cockroachdb/errors" ) -// implement in following PRs +var lstatFunc = os.Lstat +var openFileFunc = os.OpenFile + +// openBlockDevice opens a block device for read/write, caller needs to close the returned handle func openBlockDevice(path string, read bool) (*os.File, error) { - return nil, errors.New("Not implemented") + devPath, err := resolveSymlink(path) + if err != nil { + return nil, errors.Wrap(err, "resolveSymlink") + } + + fileInfo, err := lstatFunc(devPath) + if err != nil { + return nil, errors.Wrapf(err, "unable to get the device information %s", devPath) + } + + if (fileInfo.Sys().(*syscall.Stat_t).Mode & syscall.S_IFMT) != syscall.S_IFBLK { + return nil, errors.Errorf("path %s is not a block device", devPath) + } + + flag := os.O_RDWR + mode := os.FileMode(0666) + if read { + flag = os.O_RDONLY + mode = 0 + } + + device, err := openFileFunc(devPath, flag|syscall.O_DIRECT, mode) + if err != nil { + if os.IsPermission(err) || errors.Is(err, syscall.EPERM) { + return nil, errors.Wrapf(err, "no permission to open device %s with mode %v", devPath, mode) + } + return nil, errors.Wrapf(err, "unable to open device %s", devPath) + } + + return device, nil +} + +func resolveSymlink(path string) (string, error) { + st, err := os.Lstat(path) + if err != nil { + return "", errors.Wrap(err, "stat") + } + + if (st.Mode() & os.ModeSymlink) == 0 { + return path, nil + } + + return filepath.EvalSymlinks(path) } diff --git a/pkg/uploader/block/dev_linux_test.go b/pkg/uploader/block/dev_linux_test.go new file mode 100644 index 000000000..42f0dd83e --- /dev/null +++ b/pkg/uploader/block/dev_linux_test.go @@ -0,0 +1,358 @@ +//go:build linux +// +build linux + +/* +Copyright The Velero Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package block + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeBlockDevFileInfo struct{} + +func (fakeBlockDevFileInfo) Name() string { return "fake-blk" } +func (fakeBlockDevFileInfo) Size() int64 { return 0 } +func (fakeBlockDevFileInfo) Mode() os.FileMode { return os.ModeDevice } +func (fakeBlockDevFileInfo) ModTime() time.Time { return time.Time{} } +func (fakeBlockDevFileInfo) IsDir() bool { return false } +func (fakeBlockDevFileInfo) Sys() any { + return &syscall.Stat_t{Mode: syscall.S_IFBLK} +} + +func TestResolveSymlink(t *testing.T) { + testCases := []struct { + name string + setupPath func(t *testing.T) string + expectError bool + errContains string + checkResult func(t *testing.T, input, result string) + }{ + { + name: "path does not exist returns error", + setupPath: func(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "nonexistent") + }, + expectError: true, + errContains: "stat", + }, + { + name: "regular file returns same path", + setupPath: func(t *testing.T) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "regular-*") + require.NoError(t, err) + f.Close() + return f.Name() + }, + checkResult: func(t *testing.T, input, result string) { + t.Helper() + assert.Equal(t, input, result) + }, + }, + { + name: "directory returns same path", + setupPath: func(t *testing.T) string { + t.Helper() + return t.TempDir() + }, + checkResult: func(t *testing.T, input, result string) { + t.Helper() + assert.Equal(t, input, result) + }, + }, + { + name: "symlink to existing file returns target real path", + setupPath: func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + target, err := os.CreateTemp(dir, "target-*") + require.NoError(t, err) + target.Close() + linkPath := filepath.Join(dir, "link") + require.NoError(t, os.Symlink(target.Name(), linkPath)) + return linkPath + }, + checkResult: func(t *testing.T, input, result string) { + t.Helper() + assert.NotEqual(t, input, result) + fi, err := os.Lstat(result) + require.NoError(t, err) + assert.Zero(t, fi.Mode()&os.ModeSymlink) + }, + }, + { + name: "symlink to existing directory returns resolved path", + setupPath: func(t *testing.T) string { + t.Helper() + outer := t.TempDir() + inner := t.TempDir() + linkPath := filepath.Join(outer, "dirlink") + require.NoError(t, os.Symlink(inner, linkPath)) + return linkPath + }, + checkResult: func(t *testing.T, input, result string) { + t.Helper() + assert.NotEqual(t, input, result) + fi, err := os.Lstat(result) + require.NoError(t, err) + assert.True(t, fi.IsDir()) + }, + }, + { + name: "broken symlink — target does not exist — returns error", + setupPath: func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + linkPath := filepath.Join(dir, "broken-link") + require.NoError(t, os.Symlink(filepath.Join(dir, "nonexistent-target"), linkPath)) + return linkPath + }, + expectError: true, + errContains: "no such file or directory", + }, + { + name: "chain of symlinks is fully resolved", + setupPath: func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + // real → link1 → link2 (two-hop chain) + real, err := os.CreateTemp(dir, "real-*") + require.NoError(t, err) + real.Close() + link1 := filepath.Join(dir, "link1") + require.NoError(t, os.Symlink(real.Name(), link1)) + link2 := filepath.Join(dir, "link2") + require.NoError(t, os.Symlink(link1, link2)) + return link2 + }, + checkResult: func(t *testing.T, input, result string) { + t.Helper() + assert.NotEqual(t, input, result) + fi, err := os.Lstat(result) + require.NoError(t, err) + assert.Zero(t, fi.Mode()&os.ModeSymlink) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + input := tc.setupPath(t) + result, err := resolveSymlink(input) + + if tc.expectError { + require.Error(t, err) + if tc.errContains != "" { + require.ErrorContains(t, err, tc.errContains) + } + assert.Empty(t, result) + } else { + require.NoError(t, err) + if tc.checkResult != nil { + tc.checkResult(t, input, result) + } + } + }) + } +} + +func TestOpenBlockDevice(t *testing.T) { + testCases := []struct { + name string + setupPath func(t *testing.T) string + read bool + expectError bool + errContains string + injectLstat func(string) (os.FileInfo, error) + injectOpenFile func(string, int, os.FileMode) (*os.File, error) + }{ + { + name: "path does not exist — resolveSymlink fails", + setupPath: func(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "nonexistent") + }, + read: true, + expectError: true, + errContains: "resolveSymlink", + }, + { + name: "regular file is not a block device — read mode", + setupPath: func(t *testing.T) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "regular-*") + require.NoError(t, err) + f.Close() + return f.Name() + }, + read: true, + expectError: true, + errContains: "is not a block device", + }, + { + name: "regular file is not a block device — write mode", + setupPath: func(t *testing.T) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "regular-*") + require.NoError(t, err) + f.Close() + return f.Name() + }, + read: false, + expectError: true, + errContains: "is not a block device", + }, + { + name: "directory is not a block device", + setupPath: func(t *testing.T) string { + t.Helper() + return t.TempDir() + }, + read: true, + expectError: true, + errContains: "is not a block device", + }, + { + name: "symlink to regular file is not a block device", + setupPath: func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + target, err := os.CreateTemp(dir, "target-*") + require.NoError(t, err) + target.Close() + linkPath := filepath.Join(dir, "link") + require.NoError(t, os.Symlink(target.Name(), linkPath)) + return linkPath + }, + read: true, + expectError: true, + errContains: "is not a block device", + }, + { + name: "broken symlink — resolveSymlink fails", + setupPath: func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + linkPath := filepath.Join(dir, "broken-link") + require.NoError(t, os.Symlink(filepath.Join(dir, "ghost"), linkPath)) + return linkPath + }, + read: true, + expectError: true, + errContains: "resolveSymlink", + }, + { + name: "EACCES from OpenFile — permission denied message", + setupPath: func(t *testing.T) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "blk-*") + require.NoError(t, err) + f.Close() + return f.Name() + }, + read: true, + expectError: true, + errContains: "no permission to open device", + injectLstat: func(_ string) (os.FileInfo, error) { + return fakeBlockDevFileInfo{}, nil + }, + injectOpenFile: func(name string, _ int, _ os.FileMode) (*os.File, error) { + t.Helper() + return nil, &os.PathError{Op: "open", Path: name, Err: syscall.EACCES} + }, + }, + { + name: "EPERM from OpenFile — permission denied message", + setupPath: func(t *testing.T) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "blk-*") + require.NoError(t, err) + f.Close() + return f.Name() + }, + read: false, + expectError: true, + errContains: "no permission to open device", + injectLstat: func(_ string) (os.FileInfo, error) { + return fakeBlockDevFileInfo{}, nil + }, + injectOpenFile: func(name string, _ int, _ os.FileMode) (*os.File, error) { + return nil, &os.PathError{Op: "open", Path: name, Err: syscall.EPERM} + }, + }, + { + name: "generic OpenFile error — unable to open device message", + setupPath: func(t *testing.T) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "blk-*") + require.NoError(t, err) + f.Close() + return f.Name() + }, + read: true, + expectError: true, + errContains: "unable to open device", + injectLstat: func(_ string) (os.FileInfo, error) { + t.Helper() + return fakeBlockDevFileInfo{}, nil + }, + injectOpenFile: func(name string, _ int, _ os.FileMode) (*os.File, error) { + t.Helper() + return nil, &os.PathError{Op: "open", Path: name, Err: syscall.EIO} + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Cleanup(func() { + lstatFunc = os.Lstat + openFileFunc = os.OpenFile + }) + if tc.injectLstat != nil { + lstatFunc = tc.injectLstat + } + if tc.injectOpenFile != nil { + openFileFunc = tc.injectOpenFile + } + + path := tc.setupPath(t) + f, err := openBlockDevice(path, tc.read) + + if tc.expectError { + require.Error(t, err) + if tc.errContains != "" { + require.ErrorContains(t, err, tc.errContains) + } + assert.Nil(t, f) + } else { + require.NoError(t, err) + require.NotNil(t, f) + f.Close() + } + }) + } +} diff --git a/pkg/uploader/block/snapshot.go b/pkg/uploader/block/snapshot.go index 30626da53..e30f5c1bb 100644 --- a/pkg/uploader/block/snapshot.go +++ b/pkg/uploader/block/snapshot.go @@ -67,6 +67,8 @@ func Backup(ctx context.Context, blkUp Uploader, repoWriter udmrepo.BackupRepo, return uploader.SnapshotInfo{}, false, errors.Wrapf(err, "error opening block device %s", source) } + defer sourceInfo.dev.Close() + sourceInfo.size, err = sourceInfo.dev.Seek(0, io.SeekEnd) if err != nil { return uploader.SnapshotInfo{}, false, errors.Wrapf(err, "error getting length of block device %s", source) @@ -218,6 +220,8 @@ func Restore(ctx context.Context, blkUp Uploader, rep udmrepo.BackupRepo, snapsh return 0, errors.Wrapf(err, "error opening block device '%s'", destPath) } + defer destDev.Close() + size, err := blkUp.Restore(snapshot, destInfo{dev: destDev, path: destPath}, bitmap.Iterator(), uploaderCfg) if err != nil { return 0, errors.Wrapf(err, "error restoring to block dev %s", destPath)