diff --git a/cli/command_restore.go b/cli/command_restore.go index fe8ece9bd..4d5f84b24 100644 --- a/cli/command_restore.go +++ b/cli/command_restore.go @@ -1,13 +1,19 @@ package cli import ( + "archive/zip" + "compress/gzip" "context" + "os" + "path/filepath" + "strings" - kingpin "gopkg.in/alecthomas/kingpin.v2" + "github.com/pkg/errors" + "gopkg.in/alecthomas/kingpin.v2" - "github.com/kopia/kopia/fs/localfs" + "github.com/kopia/kopia/internal/units" "github.com/kopia/kopia/repo" - "github.com/kopia/kopia/snapshot/snapshotfs" + "github.com/kopia/kopia/snapshot/restore" ) const ( @@ -41,23 +47,104 @@ var ( restoreCommand = app.Command("restore", restoreCommandHelp) restoreCommandSourcePath = restoreCommand.Arg("source-path", restoreCommandSourcePathHelp).Required().String() - restoreCommandTargetPath = restoreCommand.Arg("target-path", "Path of the directory for the contents to be restored").Required().String() + restoreTargetPath = "" restoreOverwriteDirectories = true restoreOverwriteFiles = true + restoreMode = restoreModeAuto +) + +const ( + restoreModeLocal = "local" + restoreModeAuto = "auto" + restoreModeZip = "zip" + restoreModeZipNoCompress = "zip-nocompress" + restoreModeTar = "tar" + restoreModeTgz = "tgz" ) func addRestoreFlags(cmd *kingpin.CmdClause) { + cmd.Arg("target-path", "Path of the directory for the contents to be restored").Required().StringVar(&restoreTargetPath) cmd.Flag("overwrite-directories", "Overwrite existing directories").BoolVar(&restoreOverwriteDirectories) - cmd.Flag("overwrite-files", "Specifies whether or not to overwrite already existing files"). - BoolVar(&restoreOverwriteFiles) + cmd.Flag("overwrite-files", "Specifies whether or not to overwrite already existing files").BoolVar(&restoreOverwriteFiles) + cmd.Flag("mode", "Override restore mode").EnumVar(&restoreMode, restoreModeAuto, restoreModeLocal, restoreModeZip, restoreModeZipNoCompress, restoreModeTar, restoreModeTgz) } -func restoreOptions() localfs.CopyOptions { - return localfs.CopyOptions{ - OverwriteDirectories: restoreOverwriteDirectories, - OverwriteFiles: restoreOverwriteFiles, +func restoreOutput() (restore.Output, error) { + p, err := filepath.Abs(restoreTargetPath) + if err != nil { + return nil, err } + + m := detectRestoreMode(restoreMode) + switch m { + case restoreModeLocal: + return &restore.FilesystemOutput{ + TargetPath: p, + OverwriteDirectories: restoreOverwriteDirectories, + OverwriteFiles: restoreOverwriteFiles, + }, nil + + case restoreModeZip, restoreModeZipNoCompress: + f, err := os.Create(restoreTargetPath) + if err != nil { + return nil, errors.Wrap(err, "unable to create output file") + } + + method := zip.Deflate + if m == restoreModeZipNoCompress { + method = zip.Store + } + + return restore.NewZipOutput(f, method), nil + + case restoreModeTar: + f, err := os.Create(restoreTargetPath) + if err != nil { + return nil, errors.Wrap(err, "unable to create output file") + } + + return restore.NewTarOutput(f), nil + + case restoreModeTgz: + f, err := os.Create(restoreTargetPath) + if err != nil { + return nil, errors.Wrap(err, "unable to create output file") + } + + return restore.NewTarOutput(gzip.NewWriter(f)), nil + + default: + return nil, errors.Errorf("unknown mode %v", m) + } +} + +func detectRestoreMode(m string) string { + if m != "auto" { + return m + } + + switch { + case strings.HasSuffix(restoreTargetPath, ".zip"): + printStderr("Restoring to a zip file (%v)...\n", restoreTargetPath) + return restoreModeZip + + case strings.HasSuffix(restoreTargetPath, ".tar"): + printStderr("Restoring to an uncompressed tar file (%v)...\n", restoreTargetPath) + return restoreModeTar + + case strings.HasSuffix(restoreTargetPath, ".tar.gz") || strings.HasSuffix(restoreTargetPath, ".tgz"): + printStderr("Restoring to a tar+gzip file (%v)...\n", restoreTargetPath) + return restoreModeTgz + + default: + printStderr("Restoring to local filesystem (%v)...\n", restoreTargetPath) + return restoreModeLocal + } +} + +func printRestoreStats(st restore.Stats) { + printStderr("Restored %v files and %v directories (%v)\n", st.FileCount, st.DirCount, units.BytesStringBase10(st.TotalFileSize)) } func runRestoreCommand(ctx context.Context, rep repo.Repository) error { @@ -66,7 +153,19 @@ func runRestoreCommand(ctx context.Context, rep repo.Repository) error { return err } - return snapshotfs.RestoreRoot(ctx, rep, *restoreCommandTargetPath, oid, restoreOptions()) + output, err := restoreOutput() + if err != nil { + return errors.Wrap(err, "unable to initialize output") + } + + st, err := restore.Root(ctx, rep, output, oid) + if err != nil { + return err + } + + printRestoreStats(st) + + return nil } func init() { diff --git a/cli/command_snapshot_restore.go b/cli/command_snapshot_restore.go index 83bdca752..b35a1ed8a 100644 --- a/cli/command_snapshot_restore.go +++ b/cli/command_snapshot_restore.go @@ -3,19 +3,32 @@ import ( "context" + "github.com/pkg/errors" + "github.com/kopia/kopia/repo" "github.com/kopia/kopia/repo/manifest" - "github.com/kopia/kopia/snapshot/snapshotfs" + "github.com/kopia/kopia/snapshot/restore" ) var ( - snapshotRestoreCommand = snapshotCommands.Command("restore", "Restore a snapshot from the snapshot ID to the given target path") - snapshotRestoreSnapID = snapshotRestoreCommand.Arg("id", "Snapshot ID to be restored").Required().String() - snapshotRestoreTargetPath = snapshotRestoreCommand.Arg("target-path", "Path of the directory for the contents to be restored").Required().String() + snapshotRestoreCommand = snapshotCommands.Command("restore", "Restore a snapshot from the snapshot ID to the given target path") + snapshotRestoreSnapID = snapshotRestoreCommand.Arg("id", "Snapshot ID to be restored").Required().String() ) func runSnapRestoreCommand(ctx context.Context, rep repo.Repository) error { - return snapshotfs.Restore(ctx, rep, *snapshotRestoreTargetPath, manifest.ID(*snapshotRestoreSnapID), restoreOptions()) + output, err := restoreOutput() + if err != nil { + return errors.Wrap(err, "unable to initialize output") + } + + st, err := restore.Snapshot(ctx, rep, output, manifest.ID(*snapshotRestoreSnapID)) + if err != nil { + return err + } + + printRestoreStats(st) + + return nil } func init() { diff --git a/fs/localfs/copy.go b/snapshot/restore/local_fs_output.go similarity index 56% rename from fs/localfs/copy.go rename to snapshot/restore/local_fs_output.go index eb7e2894a..3b7c6556b 100644 --- a/fs/localfs/copy.go +++ b/snapshot/restore/local_fs_output.go @@ -1,4 +1,5 @@ -package localfs +// Package restore manages restoring filesystem snapshots. +package restore import ( "context" @@ -10,69 +11,76 @@ "github.com/pkg/errors" "github.com/kopia/kopia/fs" + "github.com/kopia/kopia/fs/localfs" ) -// CopyOptions contains the options for copying a file system tree -type CopyOptions struct { +// FilesystemOutput contains the options for outputting a file system tree. +type FilesystemOutput struct { + // TargetPath for restore. + TargetPath string + // If a directory already exists, overwrite the directory. OverwriteDirectories bool + // Indicate whether or not to overwrite existing files. When set to false, // the copier does not modify already existing files and returns an error // instead. OverwriteFiles bool } -// Copy copies e into targetPath in the local file system. If e is an -// fs.Directory, then the contents are recursively copied. -// The targetPath must not exist, except when the target path is the root -// directory. In that case, e must be a fs.Directory and its contents are copied -// to the root directory. -// Copy does not overwrite files or directories and returns an error in that -// case. It also returns an error when the the contents cannot be restored, -// for example due to an I/O error. -func Copy(ctx context.Context, targetPath string, e fs.Entry, opt CopyOptions) error { - targetPath, err := filepath.Abs(filepath.FromSlash(targetPath)) - if err != nil { - return err +// BeginDirectory implements restore.Output interface. +func (o *FilesystemOutput) BeginDirectory(ctx context.Context, relativePath string, e fs.Directory) error { + path := filepath.Join(o.TargetPath, filepath.FromSlash(relativePath)) + + if err := o.createDirectory(ctx, path); err != nil { + return errors.Wrap(err, "error creating directory") } - c := copier{CopyOptions: opt} - - return c.copyEntry(ctx, e, targetPath) + return nil } -type copier struct { - CopyOptions +// FinishDirectory implements restore.Output interface. +func (o *FilesystemOutput) FinishDirectory(ctx context.Context, relativePath string, e fs.Directory) error { + path := filepath.Join(o.TargetPath, filepath.FromSlash(relativePath)) + if err := o.setAttributes(path, e); err != nil { + return errors.Wrap(err, "error setting attributes") + } + + return nil } -func (c *copier) copyEntry(ctx context.Context, e fs.Entry, targetPath string) error { - var err error +// Close implements restore.Output interface. +func (o *FilesystemOutput) Close(ctx context.Context) error { + return nil +} - switch e := e.(type) { - case fs.Directory: - err = c.copyDirectory(ctx, e, targetPath) - case fs.File: - err = c.copyFileContent(ctx, targetPath, e) - case fs.Symlink: - // Not yet implemented - log(ctx).Warningf("Not creating symlink %q from %v", targetPath, e) - return nil - default: - return errors.Errorf("invalid FS entry type for %q: %#v", targetPath, e) +// WriteFile implements restore.Output interface. +func (o *FilesystemOutput) WriteFile(ctx context.Context, relativePath string, f fs.File) error { + log(ctx).Infof("WriteFile %v %v", relativePath, f) + path := filepath.Join(o.TargetPath, filepath.FromSlash(relativePath)) + + if err := o.copyFileContent(ctx, path, f); err != nil { + return errors.Wrap(err, "error creating directory") } - if err != nil { - return err + if err := o.setAttributes(path, f); err != nil { + return errors.Wrap(err, "error setting attributes") } - return c.setAttributes(targetPath, e) + return nil +} + +// CreateSymlink implements restore.Output interface. +func (o *FilesystemOutput) CreateSymlink(ctx context.Context, relativePath string, e fs.Symlink) error { + log(ctx).Debugf("create symlink not implemented yet") + return nil } // set permission, modification time and user/group ids on targetPath -func (c *copier) setAttributes(targetPath string, e fs.Entry) error { +func (o *FilesystemOutput) setAttributes(targetPath string, e fs.Entry) error { const modBits = os.ModePerm | os.ModeSetgid | os.ModeSetuid | os.ModeSticky - le, err := NewEntry(targetPath) + le, err := localfs.NewEntry(targetPath) if err != nil { return errors.Wrap(err, "could not create local FS entry for "+targetPath) } @@ -102,37 +110,14 @@ func (c *copier) setAttributes(targetPath string, e fs.Entry) error { return nil } -func (c *copier) copyDirectory(ctx context.Context, d fs.Directory, targetPath string) error { - if err := c.createDirectory(ctx, targetPath); err != nil { - return err - } - - return c.copyDirectoryContent(ctx, d, targetPath) -} - -func (c *copier) copyDirectoryContent(ctx context.Context, d fs.Directory, targetPath string) error { - entries, err := d.Readdir(ctx) - if err != nil { - return err - } - - for _, e := range entries { - if err := c.copyEntry(ctx, e, filepath.Join(targetPath, e.Name())); err != nil { - return err - } - } - - return nil -} - -func (c *copier) createDirectory(ctx context.Context, path string) error { +func (o *FilesystemOutput) createDirectory(ctx context.Context, path string) error { switch stat, err := os.Stat(path); { case os.IsNotExist(err): return os.MkdirAll(path, 0700) case err != nil: return errors.Wrap(err, "failed to stat path "+path) case stat.Mode().IsDir(): - if !c.OverwriteDirectories { + if !o.OverwriteDirectories { if empty, _ := isEmptyDirectory(path); !empty { return errors.Errorf("non-empty directory already exists, not overwriting it: %q", path) } @@ -146,11 +131,11 @@ func (c *copier) createDirectory(ctx context.Context, path string) error { } } -func (c *copier) copyFileContent(ctx context.Context, targetPath string, f fs.File) error { +func (o *FilesystemOutput) copyFileContent(ctx context.Context, targetPath string, f fs.File) error { switch _, err := os.Stat(targetPath); { case os.IsNotExist(err): // copy file below case err == nil: - if !c.OverwriteFiles { + if !o.OverwriteFiles { return errors.Errorf("unable to create %q, it already exists", targetPath) } diff --git a/snapshot/restore/restore.go b/snapshot/restore/restore.go new file mode 100644 index 000000000..422e649ec --- /dev/null +++ b/snapshot/restore/restore.go @@ -0,0 +1,131 @@ +package restore + +import ( + "context" + "path" + "sync/atomic" + + "github.com/pkg/errors" + + "github.com/kopia/kopia/fs" + "github.com/kopia/kopia/repo" + "github.com/kopia/kopia/repo/logging" + "github.com/kopia/kopia/repo/manifest" + "github.com/kopia/kopia/repo/object" + "github.com/kopia/kopia/snapshot" + "github.com/kopia/kopia/snapshot/snapshotfs" +) + +var log = logging.GetContextLoggerFunc("restore") + +// Output encapsulates output for restore operation. +type Output interface { + BeginDirectory(ctx context.Context, relativePath string, e fs.Directory) error + FinishDirectory(ctx context.Context, relativePath string, e fs.Directory) error + WriteFile(ctx context.Context, relativePath string, e fs.File) error + CreateSymlink(ctx context.Context, relativePath string, e fs.Symlink) error + Close(ctx context.Context) error +} + +// Stats represents restore statistics. +type Stats struct { + TotalFileSize int64 + FileCount int32 + DirCount int32 +} + +// Snapshot walks a snapshot root with given snapshot ID and restores it to the provided output. +func Snapshot(ctx context.Context, rep repo.Repository, output Output, snapID manifest.ID) (Stats, error) { + m, err := snapshot.LoadSnapshot(ctx, rep, snapID) + if err != nil { + return Stats{}, err + } + + if m.RootEntry == nil { + return Stats{}, errors.Errorf("No root entry found in manifest (%v)", snapID) + } + + rootEntry, err := snapshotfs.SnapshotRoot(rep, m) + if err != nil { + return Stats{}, err + } + + return copyToOutput(ctx, output, rootEntry) +} + +// Root walks a snapshot root with given object ID and restores it to the provided output. +func Root(ctx context.Context, rep repo.Repository, output Output, oid object.ID) (Stats, error) { + return copyToOutput(ctx, output, snapshotfs.DirectoryEntry(rep, oid, nil)) +} + +func copyToOutput(ctx context.Context, output Output, rootEntry fs.Entry) (Stats, error) { + c := copier{output: output} + + if err := c.copyEntry(ctx, rootEntry, ""); err != nil { + return Stats{}, errors.Wrap(err, "error copying") + } + + if err := c.output.Close(ctx); err != nil { + return Stats{}, errors.Wrap(err, "error closing output") + } + + return c.stats, nil +} + +type copier struct { + stats Stats + output Output +} + +func (c *copier) copyEntry(ctx context.Context, e fs.Entry, targetPath string) error { + switch e := e.(type) { + case fs.Directory: + log(ctx).Debugf("dir: '%v'", targetPath) + return c.copyDirectory(ctx, e, targetPath) + case fs.File: + log(ctx).Debugf("file: '%v'", targetPath) + + atomic.AddInt32(&c.stats.FileCount, 1) + atomic.AddInt64(&c.stats.TotalFileSize, e.Size()) + + return c.output.WriteFile(ctx, targetPath, e) + case fs.Symlink: + log(ctx).Debugf("symlink: '%v'", targetPath) + return c.output.CreateSymlink(ctx, targetPath, e) + default: + return errors.Errorf("invalid FS entry type for %q: %#v", targetPath, e) + } +} + +func (c *copier) copyDirectory(ctx context.Context, d fs.Directory, targetPath string) error { + atomic.AddInt32(&c.stats.DirCount, 1) + + if err := c.output.BeginDirectory(ctx, targetPath, d); err != nil { + return errors.Wrap(err, "create directory") + } + + if err := c.copyDirectoryContent(ctx, d, targetPath); err != nil { + return errors.Wrap(err, "copy directory contents") + } + + if err := c.output.FinishDirectory(ctx, targetPath, d); err != nil { + return errors.Wrap(err, "finish directory") + } + + return nil +} + +func (c *copier) copyDirectoryContent(ctx context.Context, d fs.Directory, targetPath string) error { + entries, err := d.Readdir(ctx) + if err != nil { + return err + } + + for _, e := range entries { + if err := c.copyEntry(ctx, e, path.Join(targetPath, e.Name())); err != nil { + return err + } + } + + return nil +} diff --git a/snapshot/restore/tar_output.go b/snapshot/restore/tar_output.go new file mode 100644 index 000000000..a62ece882 --- /dev/null +++ b/snapshot/restore/tar_output.go @@ -0,0 +1,111 @@ +package restore + +import ( + "archive/tar" + "context" + "io" + + "github.com/pkg/errors" + + "github.com/kopia/kopia/fs" +) + +// TarOutput contains the options for outputting a file system tree to a tar or .tar.gz file. +type TarOutput struct { + w io.Closer + tf *tar.Writer +} + +// BeginDirectory implements restore.Output interface. +func (o *TarOutput) BeginDirectory(ctx context.Context, relativePath string, d fs.Directory) error { + if relativePath == "" { + return nil + } + + h := &tar.Header{ + Name: relativePath + "/", + ModTime: d.ModTime(), + Mode: int64(d.Mode()), + Uid: int(d.Owner().UserID), + Gid: int(d.Owner().GroupID), + Typeflag: tar.TypeDir, + } + + if err := o.tf.WriteHeader(h); err != nil { + return errors.Wrap(err, "error writing tar header") + } + + return nil +} + +// FinishDirectory implements restore.Output interface. +func (o *TarOutput) FinishDirectory(ctx context.Context, relativePath string, e fs.Directory) error { + return nil +} + +// Close implements restore.Output interface. +func (o *TarOutput) Close(ctx context.Context) error { + if err := o.tf.Close(); err != nil { + return errors.Wrap(err, "error closing tar") + } + + return o.w.Close() +} + +// WriteFile implements restore.Output interface. +func (o *TarOutput) WriteFile(ctx context.Context, relativePath string, f fs.File) error { + r, err := f.Open(ctx) + if err != nil { + return errors.Wrap(err, "error opening file") + } + defer r.Close() //nolint:errcheck + + h := &tar.Header{ + Name: relativePath, + ModTime: f.ModTime(), + Size: f.Size(), + Mode: int64(f.Mode()), + Uid: int(f.Owner().UserID), + Gid: int(f.Owner().GroupID), + Typeflag: tar.TypeReg, + } + + if err := o.tf.WriteHeader(h); err != nil { + return errors.Wrap(err, "error writing tar header") + } + + if _, err := io.Copy(o.tf, r); err != nil { + return errors.Wrap(err, "error copying data to tar") + } + + return nil +} + +// CreateSymlink implements restore.Output interface. +func (o *TarOutput) CreateSymlink(ctx context.Context, relativePath string, l fs.Symlink) error { + target, err := l.Readlink(ctx) + if err != nil { + return errors.Wrap(err, "error reading link target") + } + + h := &tar.Header{ + Name: relativePath, + ModTime: l.ModTime(), + Mode: int64(l.Mode()), + Uid: int(l.Owner().UserID), + Gid: int(l.Owner().GroupID), + Typeflag: tar.TypeSymlink, + Linkname: target, + } + + if err := o.tf.WriteHeader(h); err != nil { + return errors.Wrap(err, "error writing tar header") + } + + return nil +} + +// NewTarOutput creates new tar writer output. +func NewTarOutput(w io.WriteCloser) *TarOutput { + return &TarOutput{w, tar.NewWriter(w)} +} diff --git a/snapshot/restore/zip_output.go b/snapshot/restore/zip_output.go new file mode 100644 index 000000000..65e109c8e --- /dev/null +++ b/snapshot/restore/zip_output.go @@ -0,0 +1,76 @@ +package restore + +import ( + "archive/zip" + "context" + "io" + + "github.com/pkg/errors" + + "github.com/kopia/kopia/fs" +) + +// ZipOutput contains the options for outputting a file system tree to a zip file. +type ZipOutput struct { + w io.Closer + zf *zip.Writer + method uint16 +} + +// BeginDirectory implements restore.Output interface. +func (o *ZipOutput) BeginDirectory(ctx context.Context, relativePath string, e fs.Directory) error { + return nil +} + +// FinishDirectory implements restore.Output interface. +func (o *ZipOutput) FinishDirectory(ctx context.Context, relativePath string, e fs.Directory) error { + return nil +} + +// Close implements restore.Output interface. +func (o *ZipOutput) Close(ctx context.Context) error { + if err := o.zf.Close(); err != nil { + return errors.Wrap(err, "error closing zip") + } + + return o.w.Close() +} + +// WriteFile implements restore.Output interface. +func (o *ZipOutput) WriteFile(ctx context.Context, relativePath string, f fs.File) error { + r, err := f.Open(ctx) + if err != nil { + return errors.Wrap(err, "error opening file") + } + defer r.Close() //nolint:errcheck + + h := &zip.FileHeader{ + Name: relativePath, + Method: o.method, + } + + h.Modified = f.ModTime() + h.SetMode(f.Mode()) + + w, err := o.zf.CreateHeader(h) + if err != nil { + return errors.Wrap(err, "error creating zip entry") + } + + if _, err := io.Copy(w, r); err != nil { + return errors.Wrap(err, "error copying data to zip") + } + + return nil +} + +// CreateSymlink implements restore.Output interface. +func (o *ZipOutput) CreateSymlink(ctx context.Context, relativePath string, e fs.Symlink) error { + log(ctx).Debugf("create symlink not implemented yet") + return nil +} + +// NewZipOutput creates new zip writer output. +func NewZipOutput(w io.WriteCloser, method uint16) *ZipOutput { + return &ZipOutput{w, zip.NewWriter(w), method} +} diff --git a/snapshot/snapshotfs/restore.go b/snapshot/snapshotfs/restore.go deleted file mode 100644 index 49b2a8042..000000000 --- a/snapshot/snapshotfs/restore.go +++ /dev/null @@ -1,37 +0,0 @@ -package snapshotfs - -import ( - "context" - - "github.com/pkg/errors" - - "github.com/kopia/kopia/fs/localfs" - "github.com/kopia/kopia/repo" - "github.com/kopia/kopia/repo/manifest" - "github.com/kopia/kopia/repo/object" - "github.com/kopia/kopia/snapshot" -) - -// Restore walks a snapshot root with given snapshot ID and restores it to the local filesystem -func Restore(ctx context.Context, rep repo.Repository, targetPath string, snapID manifest.ID, opts localfs.CopyOptions) error { - m, err := snapshot.LoadSnapshot(ctx, rep, snapID) - if err != nil { - return err - } - - if m.RootEntry == nil { - return errors.Errorf("No root entry found in manifest (%v)", snapID) - } - - rootEntry, err := SnapshotRoot(rep, m) - if err != nil { - return err - } - - return localfs.Copy(ctx, targetPath, rootEntry, opts) -} - -// RestoreRoot walks a snapshot root with given object ID and restores it to the local filesystem -func RestoreRoot(ctx context.Context, rep repo.Repository, targetPath string, oid object.ID, opts localfs.CopyOptions) error { - return localfs.Copy(ctx, targetPath, DirectoryEntry(rep, oid, nil), opts) -} diff --git a/tests/end_to_end_test/restore_test.go b/tests/end_to_end_test/restore_test.go index 8de533b46..8e3878ea8 100644 --- a/tests/end_to_end_test/restore_test.go +++ b/tests/end_to_end_test/restore_test.go @@ -1,7 +1,12 @@ package endtoend_test import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "io" "os" + "path/filepath" "testing" "time" @@ -153,6 +158,46 @@ func TestSnapshotRestore(t *testing.T) { // Restored contents should match source compareDirs(t, source, restoreDir) + cases := []struct { + fname string + args []string + validator func(t *testing.T, fname string) + }{ + // auto-detected formats + {fname: "output.zip", args: nil, validator: verifyValidZipFile}, + {fname: "output.tar", args: nil, validator: verifyValidTarFile}, + {fname: "output.tar.gz", args: nil, validator: verifyValidTarGzipFile}, + {fname: "output.tgz", args: nil, validator: verifyValidTarGzipFile}, + // forced formats + {fname: "output.nonzip.blah", args: []string{"--mode=zip"}, validator: verifyValidZipFile}, + {fname: "output.nontar.blah", args: []string{"--mode=tar"}, validator: verifyValidTarFile}, + {fname: "output.notargz.blah", args: []string{"--mode=tgz"}, validator: verifyValidTarGzipFile}, + } + + restoreArchiveDir := makeScratchDir(t) + + t.Run("modes", func(t *testing.T) { + for _, tc := range cases { + tc := tc + t.Run(tc.fname, func(t *testing.T) { + t.Parallel() + fname := filepath.Join(restoreArchiveDir, tc.fname) + e.RunAndExpectSuccess(t, append([]string{"snapshot", "restore", snapID, fname}, tc.args...)...) + tc.validator(t, fname) + }) + } + }) + + // create a directory whose name ends with '.zip' and override mode to force treating it as directory. + zipDir := filepath.Join(restoreArchiveDir, "outputdir.zip") + e.RunAndExpectSuccess(t, "snapshot", "restore", snapID, zipDir, "--mode=local") + + // verify we got a directory + st, err := os.Stat(zipDir) + if err != nil || !st.IsDir() { + t.Errorf("unexpected stat() results on output.zip directory %v %v", st, err) + } + // Attempt to restore snapshot with an already-existing target directory // It should fail because the directory is not empty _ = os.MkdirAll(restoreFailDir, 0700) @@ -165,3 +210,56 @@ func TestSnapshotRestore(t *testing.T) { e.RunAndExpectFailure(t, "snapshot", "restore", "--no-overwrite-files", snapID, restoreDir) } + +func verifyValidZipFile(t *testing.T, fname string) { + t.Helper() + + zr, err := zip.OpenReader(fname) + if err != nil { + t.Fatal(err) + } + + defer zr.Close() +} + +func verifyValidTarFile(t *testing.T, fname string) { + t.Helper() + + f, err := os.Open(fname) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + verifyValidTarReader(t, tar.NewReader(f)) +} + +func verifyValidTarReader(t *testing.T, tr *tar.Reader) { + t.Helper() + + _, err := tr.Next() + for err == nil { + _, err = tr.Next() + } + + if err != io.EOF { + t.Errorf("invalid tar file: %v", err) + } +} + +func verifyValidTarGzipFile(t *testing.T, fname string) { + t.Helper() + + f, err := os.Open(fname) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + + verifyValidTarReader(t, tar.NewReader(gz)) +}