From a621cd3fb6fa1a4c9c392418af13079291499ef7 Mon Sep 17 00:00:00 2001 From: Jarek Kowalski Date: Fri, 8 Jul 2022 23:50:17 -0700 Subject: [PATCH] fix(ui): fixed filesysystem restores triggered from UI (#2163) Added comprehensive test for restore API which was previously completely uncovered. Fixes #2162 --- internal/server/api_restore.go | 6 +- internal/server/api_restore_test.go | 219 ++++++++++++++++++++++++++ internal/serverapi/client_wrappers.go | 10 ++ snapshot/restore/local_fs_output.go | 2 +- snapshot/restore/restore.go | 4 +- 5 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 internal/server/api_restore_test.go diff --git a/internal/server/api_restore.go b/internal/server/api_restore.go index 719aef756c..1ccdeb688c 100644 --- a/internal/server/api_restore.go +++ b/internal/server/api_restore.go @@ -41,7 +41,7 @@ func handleRestore(ctx context.Context, rc requestContext) (interface{}, *apiErr rootEntry, err := snapshotfs.FilesystemEntryFromIDWithPath(ctx, rep, req.Root, false) if err != nil { - return nil, internalServerError(err) + return nil, requestError(serverapi.ErrorMalformedRequest, "invalid root entry") } var ( @@ -51,11 +51,11 @@ func handleRestore(ctx context.Context, rc requestContext) (interface{}, *apiErr switch { case req.Filesystem != nil: - out := req.Filesystem - if err := out.Init(); err != nil { + if err := req.Filesystem.Init(); err != nil { return nil, internalServerError(err) } + out = req.Filesystem description = "Destination: " + req.Filesystem.TargetPath case req.ZipFile != "": diff --git a/internal/server/api_restore_test.go b/internal/server/api_restore_test.go new file mode 100644 index 0000000000..e84427e29f --- /dev/null +++ b/internal/server/api_restore_test.go @@ -0,0 +1,219 @@ +package server_test + +import ( + "context" + "math" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/kopia/kopia/internal/apiclient" + "github.com/kopia/kopia/internal/mockfs" + "github.com/kopia/kopia/internal/repotesting" + "github.com/kopia/kopia/internal/serverapi" + "github.com/kopia/kopia/internal/testutil" + "github.com/kopia/kopia/internal/uitask" + "github.com/kopia/kopia/repo" + "github.com/kopia/kopia/repo/manifest" + "github.com/kopia/kopia/snapshot" + "github.com/kopia/kopia/snapshot/restore" + "github.com/kopia/kopia/snapshot/snapshotfs" +) + +func TestRestoreSnapshots(t *testing.T) { + ctx, env := repotesting.NewEnvironment(t, repotesting.FormatNotImportant) + + si1 := env.LocalPathSourceInfo("/dummy/path") + + var id11 manifest.ID + + require.NoError(t, repo.WriteSession(ctx, env.Repository, repo.WriteSessionOptions{Purpose: "Test"}, func(ctx context.Context, w repo.RepositoryWriter) error { + u := snapshotfs.NewUploader(w) + + dir1 := mockfs.NewDirectory() + + dir1.AddFile("file1", []byte{1, 2, 3}, 0o644) + dir1.AddDir("dir1", 0o644).AddFile("file2", []byte{1, 2, 4}, 0o644) + + man11, err := u.Upload(ctx, dir1, nil, si1) + require.NoError(t, err) + id11, err = snapshot.SaveSnapshot(ctx, w, man11) + require.NoError(t, err) + + return nil + })) + + srvInfo := startServer(t, env, false) + + cli, err := apiclient.NewKopiaAPIClient(apiclient.Options{ + BaseURL: srvInfo.BaseURL, + TrustedServerCertificateFingerprint: srvInfo.TrustedServerCertificateFingerprint, + Username: testUIUsername, + Password: testUIPassword, + }) + + require.NoError(t, err) + require.NoError(t, cli.FetchCSRFTokenForTesting(ctx)) + + t.Run("Filesystem", func(t *testing.T) { + targetPath1 := testutil.TempDirectory(t) + restoreTask1, err := serverapi.Restore(ctx, cli, &serverapi.RestoreRequest{ + Root: string(id11), + Options: restore.Options{ + RestoreDirEntryAtDepth: math.MaxInt32, + }, + Filesystem: &restore.FilesystemOutput{ + TargetPath: targetPath1, + SkipOwners: true, + SkipPermissions: true, + }, + }) + + require.NoError(t, err) + waitForTask(t, cli, restoreTask1.TaskID, 30*time.Second) + require.FileExists(t, filepath.Join(targetPath1, "file1")) + require.FileExists(t, filepath.Join(targetPath1, "dir1", "file2")) + }) + + t.Run("FilesystemSubdir", func(t *testing.T) { + targetPath1 := testutil.TempDirectory(t) + restoreTask1, err := serverapi.Restore(ctx, cli, &serverapi.RestoreRequest{ + Root: string(id11) + "/dir1", + Options: restore.Options{ + RestoreDirEntryAtDepth: math.MaxInt32, + }, + Filesystem: &restore.FilesystemOutput{ + TargetPath: targetPath1, + SkipOwners: true, + SkipPermissions: true, + }, + }) + + require.NoError(t, err) + require.Equal(t, uitask.StatusSuccess, waitForTask(t, cli, restoreTask1.TaskID, 30*time.Second).Status) + + require.FileExists(t, filepath.Join(targetPath1, "file2")) + }) + + t.Run("FilesystemFullShallowRestore", func(t *testing.T) { + targetPath1 := testutil.TempDirectory(t) + restoreTask1, err := serverapi.Restore(ctx, cli, &serverapi.RestoreRequest{ + Root: string(id11), + Options: restore.Options{}, + Filesystem: &restore.FilesystemOutput{ + TargetPath: targetPath1, + SkipOwners: true, + SkipPermissions: true, + }, + }) + + require.NoError(t, err) + require.Equal(t, uitask.StatusSuccess, waitForTask(t, cli, restoreTask1.TaskID, 30*time.Second).Status) + require.FileExists(t, filepath.Join(targetPath1, "file1.kopia-entry")) + require.DirExists(t, filepath.Join(targetPath1, "dir1.kopia-entry")) + require.FileExists(t, filepath.Join(targetPath1, "dir1.kopia-entry", ".kopia-entry")) + }) + + t.Run("FilesystemPartialShallowRestore", func(t *testing.T) { + targetPath1 := testutil.TempDirectory(t) + restoreTask1, err := serverapi.Restore(ctx, cli, &serverapi.RestoreRequest{ + Root: string(id11), + Options: restore.Options{ + RestoreDirEntryAtDepth: 1, + }, + Filesystem: &restore.FilesystemOutput{ + TargetPath: targetPath1, + SkipOwners: true, + SkipPermissions: true, + }, + }) + + require.NoError(t, err) + require.Equal(t, uitask.StatusSuccess, waitForTask(t, cli, restoreTask1.TaskID, 30*time.Second).Status) + require.FileExists(t, filepath.Join(targetPath1, "file1")) + require.DirExists(t, filepath.Join(targetPath1, "dir1")) + require.FileExists(t, filepath.Join(targetPath1, "dir1", "file2.kopia-entry")) + }) + + t.Run("ZipFile", func(t *testing.T) { + outputZipFile := filepath.Join(testutil.TempDirectory(t), "test1.zip") + restoreTask1, err := serverapi.Restore(ctx, cli, &serverapi.RestoreRequest{ + Root: string(id11), + Options: restore.Options{ + RestoreDirEntryAtDepth: math.MaxInt32, + }, + ZipFile: outputZipFile, + }) + + require.NoError(t, err) + require.Equal(t, uitask.StatusSuccess, waitForTask(t, cli, restoreTask1.TaskID, 30*time.Second).Status) + require.FileExists(t, outputZipFile) + }) + + t.Run("UncompressedZipFile", func(t *testing.T) { + outputZipFile := filepath.Join(testutil.TempDirectory(t), "test1.zip") + restoreTask1, err := serverapi.Restore(ctx, cli, &serverapi.RestoreRequest{ + Root: string(id11), + Options: restore.Options{ + RestoreDirEntryAtDepth: math.MaxInt32, + }, + ZipFile: outputZipFile, + UncompressedZip: true, + }) + + require.NoError(t, err) + require.Equal(t, uitask.StatusSuccess, waitForTask(t, cli, restoreTask1.TaskID, 30*time.Second).Status) + require.FileExists(t, outputZipFile) + }) + + t.Run("TarFile", func(t *testing.T) { + outputTarFile := filepath.Join(testutil.TempDirectory(t), "test1.tar") + restoreTask1, err := serverapi.Restore(ctx, cli, &serverapi.RestoreRequest{ + Root: string(id11), + Options: restore.Options{ + RestoreDirEntryAtDepth: math.MaxInt32, + }, + TarFile: outputTarFile, + }) + + require.NoError(t, err) + require.Equal(t, uitask.StatusSuccess, waitForTask(t, cli, restoreTask1.TaskID, 30*time.Second).Status) + require.FileExists(t, outputTarFile) + }) + + t.Run("InvalidRequest", func(t *testing.T) { + requests := []*serverapi.RestoreRequest{ + { + Root: string(id11), + // no output + }, + { + // no root + ZipFile: filepath.Join(testutil.TempDirectory(t), "test1.zip"), + }, + { + Root: string(id11 + "bad"), + ZipFile: filepath.Join(testutil.TempDirectory(t), "test1.zip"), + }, + { + Root: string(id11), + ZipFile: "/no/such/directory/" + uuid.NewString() + "/test1.zip", + }, + { + Root: string(id11), + TarFile: "/no/such/directory/" + uuid.NewString() + "/test1.tar", + }, + } + + for _, req := range requests { + _, err := serverapi.Restore(ctx, cli, req) + + var se apiclient.HTTPStatusError + + require.ErrorAs(t, err, &se) + } + }) +} diff --git a/internal/serverapi/client_wrappers.go b/internal/serverapi/client_wrappers.go index 8201613d70..576fb4f2bb 100644 --- a/internal/serverapi/client_wrappers.go +++ b/internal/serverapi/client_wrappers.go @@ -35,6 +35,16 @@ func Estimate(ctx context.Context, c *apiclient.KopiaAPIClient, req *EstimateReq return resp, nil } +// Restore starts snapshot restore task for a given directory. +func Restore(ctx context.Context, c *apiclient.KopiaAPIClient, req *RestoreRequest) (*uitask.Info, error) { + resp := &uitask.Info{} + if err := c.Post(ctx, "restore", req, resp); err != nil { + return nil, errors.Wrap(err, "Restore") + } + + return resp, nil +} + // GetTask starts snapshot estimation task for a given directory. func GetTask(ctx context.Context, c *apiclient.KopiaAPIClient, taskID string) (*uitask.Info, error) { resp := &uitask.Info{} diff --git a/snapshot/restore/local_fs_output.go b/snapshot/restore/local_fs_output.go index 34d08192ef..48a67fd9cd 100644 --- a/snapshot/restore/local_fs_output.go +++ b/snapshot/restore/local_fs_output.go @@ -93,7 +93,7 @@ type FilesystemOutput struct { // copier is the StreamCopier to use for copying the actual bit stream to output. // It is assigned at runtime based on the target filesystem and restore options. - copier streamCopier + copier streamCopier `json:"-"` } // Init initializes the internal members of the filesystem writer output. diff --git a/snapshot/restore/restore.go b/snapshot/restore/restore.go index b306375d7a..ae2220f107 100644 --- a/snapshot/restore/restore.go +++ b/snapshot/restore/restore.go @@ -84,8 +84,8 @@ type Options struct { RestoreDirEntryAtDepth int32 `json:"restoreDirEntryAtDepth"` MinSizeForPlaceholder int32 `json:"minSizeForPlaceholder"` - ProgressCallback func(ctx context.Context, s Stats) - Cancel chan struct{} // channel that can be externally closed to signal cancelation + ProgressCallback func(ctx context.Context, s Stats) `json:"-"` + Cancel chan struct{} `json:"-"` // channel that can be externally closed to signal cancelation } // Entry walks a snapshot root with given root entry and restores it to the provided output.