forked from TrueCloudLab/restic
Merge pull request #4839 from MichaelEischer/restore-dry-run
restore: add `--dry-run` and extended progress output
This commit is contained in:
commit
83fdcf21fe
15 changed files with 360 additions and 86 deletions
7
changelog/unreleased/pull-4839
Normal file
7
changelog/unreleased/pull-4839
Normal file
|
@ -0,0 +1,7 @@
|
|||
Enhancement: Add dry-run support to `restore` command
|
||||
|
||||
The `restore` command now supports the `--dry-run` option to perform
|
||||
a dry run. Pass the `--verbose=2` option to see which files would
|
||||
remain unchanged, which would be updated or freshly restored.
|
||||
|
||||
https://github.com/restic/restic/pull/4839
|
|
@ -47,6 +47,7 @@ type RestoreOptions struct {
|
|||
includePatternOptions
|
||||
Target string
|
||||
restic.SnapshotFilter
|
||||
DryRun bool
|
||||
Sparse bool
|
||||
Verify bool
|
||||
Overwrite restorer.OverwriteBehavior
|
||||
|
@ -64,6 +65,7 @@ func init() {
|
|||
initIncludePatternOptions(flags, &restoreOptions.includePatternOptions)
|
||||
|
||||
initSingleSnapshotFilter(flags, &restoreOptions.SnapshotFilter)
|
||||
flags.BoolVar(&restoreOptions.DryRun, "dry-run", false, "do not write any data, just show what would be done")
|
||||
flags.BoolVar(&restoreOptions.Sparse, "sparse", false, "restore files as sparse")
|
||||
flags.BoolVar(&restoreOptions.Verify, "verify", false, "verify restored files content")
|
||||
flags.Var(&restoreOptions.Overwrite, "overwrite", "overwrite behavior, one of (always|if-changed|if-newer|never) (default: always)")
|
||||
|
@ -99,6 +101,9 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
|
|||
if hasExcludes && hasIncludes {
|
||||
return errors.Fatal("exclude and include patterns are mutually exclusive")
|
||||
}
|
||||
if opts.DryRun && opts.Verify {
|
||||
return errors.Fatal("--dry-run and --verify are mutually exclusive")
|
||||
}
|
||||
|
||||
snapshotIDString := args[0]
|
||||
|
||||
|
@ -133,13 +138,14 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
|
|||
msg := ui.NewMessage(term, gopts.verbosity)
|
||||
var printer restoreui.ProgressPrinter
|
||||
if gopts.JSON {
|
||||
printer = restoreui.NewJSONProgress(term)
|
||||
printer = restoreui.NewJSONProgress(term, gopts.verbosity)
|
||||
} else {
|
||||
printer = restoreui.NewTextProgress(term)
|
||||
printer = restoreui.NewTextProgress(term, gopts.verbosity)
|
||||
}
|
||||
|
||||
progress := restoreui.NewProgress(printer, calculateProgressInterval(!gopts.Quiet, gopts.JSON))
|
||||
res := restorer.NewRestorer(repo, sn, restorer.Options{
|
||||
DryRun: opts.DryRun,
|
||||
Sparse: opts.Sparse,
|
||||
Progress: progress,
|
||||
Overwrite: opts.Overwrite,
|
||||
|
|
|
@ -242,7 +242,7 @@ case, specify the ``--skip-if-unchanged`` option.
|
|||
Note that when using absolute paths to specify the backup target, then also
|
||||
changes to the parent folders result in a changed snapshot. For example, a backup
|
||||
of ``/home/user/work`` will create a new snapshot if the metadata of either
|
||||
``/``, ``/home`` or ``/home/user`` change. To avoid this problem run restic from
|
||||
``/``, ``/home`` or ``/home/user`` change. To avoid this problem run restic from
|
||||
the corresponding folder and use relative paths.
|
||||
|
||||
.. code-block:: console
|
||||
|
|
|
@ -69,7 +69,7 @@ There are case insensitive variants of ``--exclude`` and ``--include`` called
|
|||
ignore the casing of paths.
|
||||
|
||||
There are also ``--include-file``, ``--exclude-file``, ``--iinclude-file`` and
|
||||
``--iexclude-file`` flags that read the include and exclude patterns from a file.
|
||||
``--iexclude-file`` flags that read the include and exclude patterns from a file.
|
||||
|
||||
Restoring symbolic links on windows is only possible when the user has
|
||||
``SeCreateSymbolicLinkPrivilege`` privilege or is running as admin. This is a
|
||||
|
@ -111,6 +111,28 @@ values are supported:
|
|||
newer modification time (mtime).
|
||||
* ``--overwrite never``: never overwrite existing files.
|
||||
|
||||
Dry run
|
||||
-------
|
||||
|
||||
As restore operations can take a long time, it can be useful to perform a dry-run to
|
||||
see what would be restored without having to run the full restore operation. The
|
||||
restore command supports the ``--dry-run`` option and prints information about the
|
||||
restored files when specifying ``--verbose=2``.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ restic restore --target /tmp/restore-work --dry-run --verbose=2 latest
|
||||
|
||||
unchanged /restic/internal/walker/walker.go with size 2.812 KiB
|
||||
updated /restic/internal/walker/walker_test.go with size 11.143 KiB
|
||||
restored /restic/restic with size 35.318 MiB
|
||||
restored /restic
|
||||
[...]
|
||||
Summary: Restored 9072 files/dirs (153.597 MiB) in 0:00
|
||||
|
||||
Files with already up to date content are reported as ``unchanged``. Files whose content
|
||||
was modified are ``updated`` and files that are new are shown as ``restored``. Directories
|
||||
and other file types like symlinks are always reported as ``restored``.
|
||||
|
||||
Restore using mount
|
||||
===================
|
||||
|
|
|
@ -511,6 +511,22 @@ Status
|
|||
|``bytes_skipped`` | Total size of skipped files |
|
||||
+----------------------+------------------------------------------------------------+
|
||||
|
||||
Verbose Status
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
Verbose status provides details about the progress, including details about restored files.
|
||||
Only printed if `--verbose=2` is specified.
|
||||
|
||||
+----------------------+-----------------------------------------------------------+
|
||||
| ``message_type`` | Always "verbose_status" |
|
||||
+----------------------+-----------------------------------------------------------+
|
||||
| ``action`` | Either "restored", "updated" or "unchanged" |
|
||||
+----------------------+-----------------------------------------------------------+
|
||||
| ``item`` | The item in question |
|
||||
+----------------------+-----------------------------------------------------------+
|
||||
| ``size`` | Size of the item in bytes |
|
||||
+----------------------+-----------------------------------------------------------+
|
||||
|
||||
Summary
|
||||
^^^^^^^
|
||||
|
||||
|
|
|
@ -215,7 +215,7 @@ func (r *fileRestorer) restoreEmptyFileAt(location string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
r.progress.AddProgress(location, 0, 0)
|
||||
r.progress.AddProgress(location, restore.ActionFileRestored, 0, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -337,7 +337,11 @@ func (r *fileRestorer) downloadBlobs(ctx context.Context, packID restic.ID,
|
|||
createSize = file.size
|
||||
}
|
||||
writeErr := r.filesWriter.writeToFile(r.targetPath(file.location), blobData, offset, createSize, file.sparse)
|
||||
r.progress.AddProgress(file.location, uint64(len(blobData)), uint64(file.size))
|
||||
action := restore.ActionFileUpdated
|
||||
if file.state == nil {
|
||||
action = restore.ActionFileRestored
|
||||
}
|
||||
r.progress.AddProgress(file.location, action, uint64(len(blobData)), uint64(file.size))
|
||||
return writeErr
|
||||
}
|
||||
err := r.sanitizeError(file, writeToFile())
|
||||
|
|
|
@ -33,6 +33,7 @@ type Restorer struct {
|
|||
var restorerAbortOnAllErrors = func(_ string, err error) error { return err }
|
||||
|
||||
type Options struct {
|
||||
DryRun bool
|
||||
Sparse bool
|
||||
Progress *restoreui.Progress
|
||||
Overwrite OverwriteBehavior
|
||||
|
@ -220,22 +221,27 @@ func (res *Restorer) traverseTree(ctx context.Context, target, location string,
|
|||
}
|
||||
|
||||
func (res *Restorer) restoreNodeTo(ctx context.Context, node *restic.Node, target, location string) error {
|
||||
debug.Log("restoreNode %v %v %v", node.Name, target, location)
|
||||
if err := fs.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.Wrap(err, "RemoveNode")
|
||||
if !res.opts.DryRun {
|
||||
debug.Log("restoreNode %v %v %v", node.Name, target, location)
|
||||
if err := fs.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.Wrap(err, "RemoveNode")
|
||||
}
|
||||
|
||||
err := node.CreateAt(ctx, target, res.repo)
|
||||
if err != nil {
|
||||
debug.Log("node.CreateAt(%s) error %v", target, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := node.CreateAt(ctx, target, res.repo)
|
||||
if err != nil {
|
||||
debug.Log("node.CreateAt(%s) error %v", target, err)
|
||||
return err
|
||||
}
|
||||
|
||||
res.opts.Progress.AddProgress(location, 0, 0)
|
||||
res.opts.Progress.AddProgress(location, restoreui.ActionFileRestored, 0, 0)
|
||||
return res.restoreNodeMetadataTo(node, target, location)
|
||||
}
|
||||
|
||||
func (res *Restorer) restoreNodeMetadataTo(node *restic.Node, target, location string) error {
|
||||
if res.opts.DryRun {
|
||||
return nil
|
||||
}
|
||||
debug.Log("restoreNodeMetadata %v %v %v", node.Name, target, location)
|
||||
err := node.RestoreMetadata(target, res.Warn)
|
||||
if err != nil {
|
||||
|
@ -245,21 +251,26 @@ func (res *Restorer) restoreNodeMetadataTo(node *restic.Node, target, location s
|
|||
}
|
||||
|
||||
func (res *Restorer) restoreHardlinkAt(node *restic.Node, target, path, location string) error {
|
||||
if err := fs.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.Wrap(err, "RemoveCreateHardlink")
|
||||
}
|
||||
err := fs.Link(target, path)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
if !res.opts.DryRun {
|
||||
if err := fs.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.Wrap(err, "RemoveCreateHardlink")
|
||||
}
|
||||
err := fs.Link(target, path)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
res.opts.Progress.AddProgress(location, 0, 0)
|
||||
|
||||
res.opts.Progress.AddProgress(location, restoreui.ActionFileRestored, 0, 0)
|
||||
// TODO investigate if hardlinks have separate metadata on any supported system
|
||||
return res.restoreNodeMetadataTo(node, path, location)
|
||||
}
|
||||
|
||||
func (res *Restorer) ensureDir(target string) error {
|
||||
if res.opts.DryRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
fi, err := fs.Lstat(target)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("failed to check for directory: %w", err)
|
||||
|
@ -324,12 +335,21 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
|
|||
idx.Add(node.Inode, node.DeviceID, location)
|
||||
}
|
||||
|
||||
buf, err = res.withOverwriteCheck(node, target, false, buf, func(updateMetadataOnly bool, matches *fileState) error {
|
||||
buf, err = res.withOverwriteCheck(node, target, location, false, buf, func(updateMetadataOnly bool, matches *fileState) error {
|
||||
if updateMetadataOnly {
|
||||
res.opts.Progress.AddSkippedFile(node.Size)
|
||||
res.opts.Progress.AddSkippedFile(location, node.Size)
|
||||
} else {
|
||||
res.opts.Progress.AddFile(node.Size)
|
||||
filerestorer.addFile(location, node.Content, int64(node.Size), matches)
|
||||
if !res.opts.DryRun {
|
||||
filerestorer.addFile(location, node.Content, int64(node.Size), matches)
|
||||
} else {
|
||||
action := restoreui.ActionFileUpdated
|
||||
if matches == nil {
|
||||
action = restoreui.ActionFileRestored
|
||||
}
|
||||
// immediately mark as completed
|
||||
res.opts.Progress.AddProgress(location, action, node.Size, node.Size)
|
||||
}
|
||||
}
|
||||
res.trackFile(location, updateMetadataOnly)
|
||||
return nil
|
||||
|
@ -341,9 +361,11 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
err = filerestorer.restoreFiles(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
if !res.opts.DryRun {
|
||||
err = filerestorer.restoreFiles(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
debug.Log("second pass for %q", dst)
|
||||
|
@ -353,14 +375,14 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
|
|||
visitNode: func(node *restic.Node, target, location string) error {
|
||||
debug.Log("second pass, visitNode: restore node %q", location)
|
||||
if node.Type != "file" {
|
||||
_, err := res.withOverwriteCheck(node, target, false, nil, func(_ bool, _ *fileState) error {
|
||||
_, err := res.withOverwriteCheck(node, target, location, false, nil, func(_ bool, _ *fileState) error {
|
||||
return res.restoreNodeTo(ctx, node, target, location)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
if idx.Has(node.Inode, node.DeviceID) && idx.Value(node.Inode, node.DeviceID) != location {
|
||||
_, err := res.withOverwriteCheck(node, target, true, nil, func(_ bool, _ *fileState) error {
|
||||
_, err := res.withOverwriteCheck(node, target, location, true, nil, func(_ bool, _ *fileState) error {
|
||||
return res.restoreHardlinkAt(node, filerestorer.targetPath(idx.Value(node.Inode, node.DeviceID)), target, location)
|
||||
})
|
||||
return err
|
||||
|
@ -375,7 +397,7 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
|
|||
leaveDir: func(node *restic.Node, target, location string) error {
|
||||
err := res.restoreNodeMetadataTo(node, target, location)
|
||||
if err == nil {
|
||||
res.opts.Progress.AddProgress(location, 0, 0)
|
||||
res.opts.Progress.AddProgress(location, restoreui.ActionDirRestored, 0, 0)
|
||||
}
|
||||
return err
|
||||
},
|
||||
|
@ -392,7 +414,7 @@ func (res *Restorer) hasRestoredFile(location string) (metadataOnly bool, ok boo
|
|||
return metadataOnly, ok
|
||||
}
|
||||
|
||||
func (res *Restorer) withOverwriteCheck(node *restic.Node, target string, isHardlink bool, buf []byte, cb func(updateMetadataOnly bool, matches *fileState) error) ([]byte, error) {
|
||||
func (res *Restorer) withOverwriteCheck(node *restic.Node, target, location string, isHardlink bool, buf []byte, cb func(updateMetadataOnly bool, matches *fileState) error) ([]byte, error) {
|
||||
overwrite, err := shouldOverwrite(res.opts.Overwrite, node, target)
|
||||
if err != nil {
|
||||
return buf, err
|
||||
|
@ -401,7 +423,7 @@ func (res *Restorer) withOverwriteCheck(node *restic.Node, target string, isHard
|
|||
if isHardlink {
|
||||
size = 0
|
||||
}
|
||||
res.opts.Progress.AddSkippedFile(size)
|
||||
res.opts.Progress.AddSkippedFile(location, size)
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/restic/restic/internal/archiver"
|
||||
"github.com/restic/restic/internal/errors"
|
||||
"github.com/restic/restic/internal/fs"
|
||||
"github.com/restic/restic/internal/repository"
|
||||
"github.com/restic/restic/internal/restic"
|
||||
|
@ -1166,3 +1167,32 @@ func TestRestoreIfChanged(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreDryRun(t *testing.T) {
|
||||
snapshot := Snapshot{
|
||||
Nodes: map[string]Node{
|
||||
"foo": File{Data: "content: foo\n", Links: 2, Inode: 42},
|
||||
"foo2": File{Data: "content: foo\n", Links: 2, Inode: 42},
|
||||
"dirtest": Dir{
|
||||
Nodes: map[string]Node{
|
||||
"file": File{Data: "content: file\n"},
|
||||
},
|
||||
},
|
||||
"link": Symlink{Target: "foo"},
|
||||
},
|
||||
}
|
||||
|
||||
repo := repository.TestRepository(t)
|
||||
tempdir := filepath.Join(rtest.TempDir(t), "target")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sn, id := saveSnapshot(t, repo, snapshot, noopGetGenericAttributes)
|
||||
t.Logf("snapshot saved as %v", id.Str())
|
||||
|
||||
res := NewRestorer(repo, sn, Options{DryRun: true})
|
||||
rtest.OK(t, res.RestoreTo(ctx, tempdir))
|
||||
|
||||
_, err := os.Stat(tempdir)
|
||||
rtest.Assert(t, errors.Is(err, os.ErrNotExist), "expected no file to be created, got %v", err)
|
||||
}
|
||||
|
|
|
@ -76,11 +76,21 @@ type printerMock struct {
|
|||
|
||||
func (p *printerMock) Update(_ restoreui.State, _ time.Duration) {
|
||||
}
|
||||
func (p *printerMock) CompleteItem(action restoreui.ItemAction, item string, size uint64) {
|
||||
}
|
||||
func (p *printerMock) Finish(s restoreui.State, _ time.Duration) {
|
||||
p.s = s
|
||||
}
|
||||
|
||||
func TestRestorerProgressBar(t *testing.T) {
|
||||
testRestorerProgressBar(t, false)
|
||||
}
|
||||
|
||||
func TestRestorerProgressBarDryRun(t *testing.T) {
|
||||
testRestorerProgressBar(t, true)
|
||||
}
|
||||
|
||||
func testRestorerProgressBar(t *testing.T, dryRun bool) {
|
||||
repo := repository.TestRepository(t)
|
||||
|
||||
sn, _ := saveSnapshot(t, repo, Snapshot{
|
||||
|
@ -97,7 +107,7 @@ func TestRestorerProgressBar(t *testing.T) {
|
|||
|
||||
mock := &printerMock{}
|
||||
progress := restoreui.NewProgress(mock, 0)
|
||||
res := NewRestorer(repo, sn, Options{Progress: progress})
|
||||
res := NewRestorer(repo, sn, Options{Progress: progress, DryRun: dryRun})
|
||||
res.SelectFilter = func(item string, dstpath string, node *restic.Node) (selectedForRestore bool, childMayBeSelected bool) {
|
||||
return true, true
|
||||
}
|
||||
|
|
|
@ -7,12 +7,14 @@ import (
|
|||
)
|
||||
|
||||
type jsonPrinter struct {
|
||||
terminal term
|
||||
terminal term
|
||||
verbosity uint
|
||||
}
|
||||
|
||||
func NewJSONProgress(terminal term) ProgressPrinter {
|
||||
func NewJSONProgress(terminal term, verbosity uint) ProgressPrinter {
|
||||
return &jsonPrinter{
|
||||
terminal: terminal,
|
||||
terminal: terminal,
|
||||
verbosity: verbosity,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -39,6 +41,34 @@ func (t *jsonPrinter) Update(p State, duration time.Duration) {
|
|||
t.print(status)
|
||||
}
|
||||
|
||||
func (t *jsonPrinter) CompleteItem(messageType ItemAction, item string, size uint64) {
|
||||
if t.verbosity < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
var action string
|
||||
switch messageType {
|
||||
case ActionDirRestored:
|
||||
action = "restored"
|
||||
case ActionFileRestored:
|
||||
action = "restored"
|
||||
case ActionFileUpdated:
|
||||
action = "updated"
|
||||
case ActionFileUnchanged:
|
||||
action = "unchanged"
|
||||
default:
|
||||
panic("unknown message type")
|
||||
}
|
||||
|
||||
status := verboseUpdate{
|
||||
MessageType: "verbose_status",
|
||||
Action: action,
|
||||
Item: item,
|
||||
Size: size,
|
||||
}
|
||||
t.print(status)
|
||||
}
|
||||
|
||||
func (t *jsonPrinter) Finish(p State, duration time.Duration) {
|
||||
status := summaryOutput{
|
||||
MessageType: "summary",
|
||||
|
@ -65,6 +95,13 @@ type statusUpdate struct {
|
|||
BytesSkipped uint64 `json:"bytes_skipped,omitempty"`
|
||||
}
|
||||
|
||||
type verboseUpdate struct {
|
||||
MessageType string `json:"message_type"` // "verbose_status"
|
||||
Action string `json:"action"`
|
||||
Item string `json:"item"`
|
||||
Size uint64 `json:"size"`
|
||||
}
|
||||
|
||||
type summaryOutput struct {
|
||||
MessageType string `json:"message_type"` // "summary"
|
||||
SecondsElapsed uint64 `json:"seconds_elapsed,omitempty"`
|
||||
|
|
|
@ -7,37 +7,55 @@ import (
|
|||
"github.com/restic/restic/internal/test"
|
||||
)
|
||||
|
||||
func TestJSONPrintUpdate(t *testing.T) {
|
||||
func createJSONProgress() (*mockTerm, ProgressPrinter) {
|
||||
term := &mockTerm{}
|
||||
printer := NewJSONProgress(term)
|
||||
printer := NewJSONProgress(term, 3)
|
||||
return term, printer
|
||||
}
|
||||
|
||||
func TestJSONPrintUpdate(t *testing.T) {
|
||||
term, printer := createJSONProgress()
|
||||
printer.Update(State{3, 11, 0, 29, 47, 0}, 5*time.Second)
|
||||
test.Equals(t, []string{"{\"message_type\":\"status\",\"seconds_elapsed\":5,\"percent_done\":0.6170212765957447,\"total_files\":11,\"files_restored\":3,\"total_bytes\":47,\"bytes_restored\":29}\n"}, term.output)
|
||||
}
|
||||
|
||||
func TestJSONPrintUpdateWithSkipped(t *testing.T) {
|
||||
term := &mockTerm{}
|
||||
printer := NewJSONProgress(term)
|
||||
term, printer := createJSONProgress()
|
||||
printer.Update(State{3, 11, 2, 29, 47, 59}, 5*time.Second)
|
||||
test.Equals(t, []string{"{\"message_type\":\"status\",\"seconds_elapsed\":5,\"percent_done\":0.6170212765957447,\"total_files\":11,\"files_restored\":3,\"files_skipped\":2,\"total_bytes\":47,\"bytes_restored\":29,\"bytes_skipped\":59}\n"}, term.output)
|
||||
}
|
||||
|
||||
func TestJSONPrintSummaryOnSuccess(t *testing.T) {
|
||||
term := &mockTerm{}
|
||||
printer := NewJSONProgress(term)
|
||||
term, printer := createJSONProgress()
|
||||
printer.Finish(State{11, 11, 0, 47, 47, 0}, 5*time.Second)
|
||||
test.Equals(t, []string{"{\"message_type\":\"summary\",\"seconds_elapsed\":5,\"total_files\":11,\"files_restored\":11,\"total_bytes\":47,\"bytes_restored\":47}\n"}, term.output)
|
||||
}
|
||||
|
||||
func TestJSONPrintSummaryOnErrors(t *testing.T) {
|
||||
term := &mockTerm{}
|
||||
printer := NewJSONProgress(term)
|
||||
term, printer := createJSONProgress()
|
||||
printer.Finish(State{3, 11, 0, 29, 47, 0}, 5*time.Second)
|
||||
test.Equals(t, []string{"{\"message_type\":\"summary\",\"seconds_elapsed\":5,\"total_files\":11,\"files_restored\":3,\"total_bytes\":47,\"bytes_restored\":29}\n"}, term.output)
|
||||
}
|
||||
|
||||
func TestJSONPrintSummaryOnSuccessWithSkipped(t *testing.T) {
|
||||
term := &mockTerm{}
|
||||
printer := NewJSONProgress(term)
|
||||
term, printer := createJSONProgress()
|
||||
printer.Finish(State{11, 11, 2, 47, 47, 59}, 5*time.Second)
|
||||
test.Equals(t, []string{"{\"message_type\":\"summary\",\"seconds_elapsed\":5,\"total_files\":11,\"files_restored\":11,\"files_skipped\":2,\"total_bytes\":47,\"bytes_restored\":47,\"bytes_skipped\":59}\n"}, term.output)
|
||||
}
|
||||
|
||||
func TestJSONPrintCompleteItem(t *testing.T) {
|
||||
for _, data := range []struct {
|
||||
action ItemAction
|
||||
size uint64
|
||||
expected string
|
||||
}{
|
||||
{ActionDirRestored, 0, "{\"message_type\":\"verbose_status\",\"action\":\"restored\",\"item\":\"test\",\"size\":0}\n"},
|
||||
{ActionFileRestored, 123, "{\"message_type\":\"verbose_status\",\"action\":\"restored\",\"item\":\"test\",\"size\":123}\n"},
|
||||
{ActionFileUpdated, 123, "{\"message_type\":\"verbose_status\",\"action\":\"updated\",\"item\":\"test\",\"size\":123}\n"},
|
||||
{ActionFileUnchanged, 123, "{\"message_type\":\"verbose_status\",\"action\":\"unchanged\",\"item\":\"test\",\"size\":123}\n"},
|
||||
} {
|
||||
term, printer := createJSONProgress()
|
||||
printer.CompleteItem(data.action, "test", data.size)
|
||||
test.Equals(t, []string{data.expected}, term.output)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,9 +39,20 @@ type term interface {
|
|||
|
||||
type ProgressPrinter interface {
|
||||
Update(progress State, duration time.Duration)
|
||||
CompleteItem(action ItemAction, item string, size uint64)
|
||||
Finish(progress State, duration time.Duration)
|
||||
}
|
||||
|
||||
type ItemAction string
|
||||
|
||||
// Constants for the different CompleteItem actions.
|
||||
const (
|
||||
ActionDirRestored ItemAction = "dir restored"
|
||||
ActionFileRestored ItemAction = "file restored"
|
||||
ActionFileUpdated ItemAction = "file updated"
|
||||
ActionFileUnchanged ItemAction = "file unchanged"
|
||||
)
|
||||
|
||||
func NewProgress(printer ProgressPrinter, interval time.Duration) *Progress {
|
||||
p := &Progress{
|
||||
progressInfoMap: make(map[string]progressInfoEntry),
|
||||
|
@ -77,7 +88,7 @@ func (p *Progress) AddFile(size uint64) {
|
|||
}
|
||||
|
||||
// AddProgress accumulates the number of bytes written for a file
|
||||
func (p *Progress) AddProgress(name string, bytesWrittenPortion uint64, bytesTotal uint64) {
|
||||
func (p *Progress) AddProgress(name string, action ItemAction, bytesWrittenPortion uint64, bytesTotal uint64) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
|
@ -96,10 +107,12 @@ func (p *Progress) AddProgress(name string, bytesWrittenPortion uint64, bytesTot
|
|||
if entry.bytesWritten == entry.bytesTotal {
|
||||
delete(p.progressInfoMap, name)
|
||||
p.s.FilesFinished++
|
||||
|
||||
p.printer.CompleteItem(action, name, bytesTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Progress) AddSkippedFile(size uint64) {
|
||||
func (p *Progress) AddSkippedFile(name string, size uint64) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
|
@ -109,6 +122,8 @@ func (p *Progress) AddSkippedFile(size uint64) {
|
|||
|
||||
p.s.FilesSkipped++
|
||||
p.s.AllBytesSkipped += size
|
||||
|
||||
p.printer.CompleteItem(ActionFileUnchanged, name, size)
|
||||
}
|
||||
|
||||
func (p *Progress) Finish() {
|
||||
|
|
|
@ -16,8 +16,16 @@ type printerTraceEntry struct {
|
|||
|
||||
type printerTrace []printerTraceEntry
|
||||
|
||||
type itemTraceEntry struct {
|
||||
action ItemAction
|
||||
item string
|
||||
size uint64
|
||||
}
|
||||
|
||||
type itemTrace []itemTraceEntry
|
||||
type mockPrinter struct {
|
||||
trace printerTrace
|
||||
items itemTrace
|
||||
}
|
||||
|
||||
const mockFinishDuration = 42 * time.Second
|
||||
|
@ -25,95 +33,109 @@ const mockFinishDuration = 42 * time.Second
|
|||
func (p *mockPrinter) Update(progress State, duration time.Duration) {
|
||||
p.trace = append(p.trace, printerTraceEntry{progress, duration, false})
|
||||
}
|
||||
func (p *mockPrinter) CompleteItem(action ItemAction, item string, size uint64) {
|
||||
p.items = append(p.items, itemTraceEntry{action, item, size})
|
||||
}
|
||||
func (p *mockPrinter) Finish(progress State, _ time.Duration) {
|
||||
p.trace = append(p.trace, printerTraceEntry{progress, mockFinishDuration, true})
|
||||
}
|
||||
|
||||
func testProgress(fn func(progress *Progress) bool) printerTrace {
|
||||
func testProgress(fn func(progress *Progress) bool) (printerTrace, itemTrace) {
|
||||
printer := &mockPrinter{}
|
||||
progress := NewProgress(printer, 0)
|
||||
final := fn(progress)
|
||||
progress.update(0, final)
|
||||
trace := append(printerTrace{}, printer.trace...)
|
||||
items := append(itemTrace{}, printer.items...)
|
||||
// cleanup to avoid goroutine leak, but copy trace first
|
||||
progress.Finish()
|
||||
return trace
|
||||
return trace, items
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
result := testProgress(func(progress *Progress) bool {
|
||||
result, items := testProgress(func(progress *Progress) bool {
|
||||
return false
|
||||
})
|
||||
test.Equals(t, printerTrace{
|
||||
printerTraceEntry{State{0, 0, 0, 0, 0, 0}, 0, false},
|
||||
}, result)
|
||||
test.Equals(t, itemTrace{}, items)
|
||||
}
|
||||
|
||||
func TestAddFile(t *testing.T) {
|
||||
fileSize := uint64(100)
|
||||
|
||||
result := testProgress(func(progress *Progress) bool {
|
||||
result, items := testProgress(func(progress *Progress) bool {
|
||||
progress.AddFile(fileSize)
|
||||
return false
|
||||
})
|
||||
test.Equals(t, printerTrace{
|
||||
printerTraceEntry{State{0, 1, 0, 0, fileSize, 0}, 0, false},
|
||||
}, result)
|
||||
test.Equals(t, itemTrace{}, items)
|
||||
}
|
||||
|
||||
func TestFirstProgressOnAFile(t *testing.T) {
|
||||
expectedBytesWritten := uint64(5)
|
||||
expectedBytesTotal := uint64(100)
|
||||
|
||||
result := testProgress(func(progress *Progress) bool {
|
||||
result, items := testProgress(func(progress *Progress) bool {
|
||||
progress.AddFile(expectedBytesTotal)
|
||||
progress.AddProgress("test", expectedBytesWritten, expectedBytesTotal)
|
||||
progress.AddProgress("test", ActionFileUpdated, expectedBytesWritten, expectedBytesTotal)
|
||||
return false
|
||||
})
|
||||
test.Equals(t, printerTrace{
|
||||
printerTraceEntry{State{0, 1, 0, expectedBytesWritten, expectedBytesTotal, 0}, 0, false},
|
||||
}, result)
|
||||
test.Equals(t, itemTrace{}, items)
|
||||
}
|
||||
|
||||
func TestLastProgressOnAFile(t *testing.T) {
|
||||
fileSize := uint64(100)
|
||||
|
||||
result := testProgress(func(progress *Progress) bool {
|
||||
result, items := testProgress(func(progress *Progress) bool {
|
||||
progress.AddFile(fileSize)
|
||||
progress.AddProgress("test", 30, fileSize)
|
||||
progress.AddProgress("test", 35, fileSize)
|
||||
progress.AddProgress("test", 35, fileSize)
|
||||
progress.AddProgress("test", ActionFileUpdated, 30, fileSize)
|
||||
progress.AddProgress("test", ActionFileUpdated, 35, fileSize)
|
||||
progress.AddProgress("test", ActionFileUpdated, 35, fileSize)
|
||||
return false
|
||||
})
|
||||
test.Equals(t, printerTrace{
|
||||
printerTraceEntry{State{1, 1, 0, fileSize, fileSize, 0}, 0, false},
|
||||
}, result)
|
||||
test.Equals(t, itemTrace{
|
||||
itemTraceEntry{action: ActionFileUpdated, item: "test", size: fileSize},
|
||||
}, items)
|
||||
}
|
||||
|
||||
func TestLastProgressOnLastFile(t *testing.T) {
|
||||
fileSize := uint64(100)
|
||||
|
||||
result := testProgress(func(progress *Progress) bool {
|
||||
result, items := testProgress(func(progress *Progress) bool {
|
||||
progress.AddFile(fileSize)
|
||||
progress.AddFile(50)
|
||||
progress.AddProgress("test1", 50, 50)
|
||||
progress.AddProgress("test2", 50, fileSize)
|
||||
progress.AddProgress("test2", 50, fileSize)
|
||||
progress.AddProgress("test1", ActionFileUpdated, 50, 50)
|
||||
progress.AddProgress("test2", ActionFileUpdated, 50, fileSize)
|
||||
progress.AddProgress("test2", ActionFileUpdated, 50, fileSize)
|
||||
return false
|
||||
})
|
||||
test.Equals(t, printerTrace{
|
||||
printerTraceEntry{State{2, 2, 0, 50 + fileSize, 50 + fileSize, 0}, 0, false},
|
||||
}, result)
|
||||
test.Equals(t, itemTrace{
|
||||
itemTraceEntry{action: ActionFileUpdated, item: "test1", size: 50},
|
||||
itemTraceEntry{action: ActionFileUpdated, item: "test2", size: fileSize},
|
||||
}, items)
|
||||
}
|
||||
|
||||
func TestSummaryOnSuccess(t *testing.T) {
|
||||
fileSize := uint64(100)
|
||||
|
||||
result := testProgress(func(progress *Progress) bool {
|
||||
result, _ := testProgress(func(progress *Progress) bool {
|
||||
progress.AddFile(fileSize)
|
||||
progress.AddFile(50)
|
||||
progress.AddProgress("test1", 50, 50)
|
||||
progress.AddProgress("test2", fileSize, fileSize)
|
||||
progress.AddProgress("test1", ActionFileUpdated, 50, 50)
|
||||
progress.AddProgress("test2", ActionFileUpdated, fileSize, fileSize)
|
||||
return true
|
||||
})
|
||||
test.Equals(t, printerTrace{
|
||||
|
@ -124,11 +146,11 @@ func TestSummaryOnSuccess(t *testing.T) {
|
|||
func TestSummaryOnErrors(t *testing.T) {
|
||||
fileSize := uint64(100)
|
||||
|
||||
result := testProgress(func(progress *Progress) bool {
|
||||
result, _ := testProgress(func(progress *Progress) bool {
|
||||
progress.AddFile(fileSize)
|
||||
progress.AddFile(50)
|
||||
progress.AddProgress("test1", 50, 50)
|
||||
progress.AddProgress("test2", fileSize/2, fileSize)
|
||||
progress.AddProgress("test1", ActionFileUpdated, 50, 50)
|
||||
progress.AddProgress("test2", ActionFileUpdated, fileSize/2, fileSize)
|
||||
return true
|
||||
})
|
||||
test.Equals(t, printerTrace{
|
||||
|
@ -139,11 +161,30 @@ func TestSummaryOnErrors(t *testing.T) {
|
|||
func TestSkipFile(t *testing.T) {
|
||||
fileSize := uint64(100)
|
||||
|
||||
result := testProgress(func(progress *Progress) bool {
|
||||
progress.AddSkippedFile(fileSize)
|
||||
result, items := testProgress(func(progress *Progress) bool {
|
||||
progress.AddSkippedFile("test", fileSize)
|
||||
return true
|
||||
})
|
||||
test.Equals(t, printerTrace{
|
||||
printerTraceEntry{State{0, 0, 1, 0, 0, fileSize}, mockFinishDuration, true},
|
||||
}, result)
|
||||
test.Equals(t, itemTrace{
|
||||
itemTraceEntry{ActionFileUnchanged, "test", fileSize},
|
||||
}, items)
|
||||
}
|
||||
|
||||
func TestProgressTypes(t *testing.T) {
|
||||
fileSize := uint64(100)
|
||||
|
||||
_, items := testProgress(func(progress *Progress) bool {
|
||||
progress.AddFile(fileSize)
|
||||
progress.AddFile(0)
|
||||
progress.AddProgress("dir", ActionDirRestored, fileSize, fileSize)
|
||||
progress.AddProgress("new", ActionFileRestored, 0, 0)
|
||||
return true
|
||||
})
|
||||
test.Equals(t, itemTrace{
|
||||
itemTraceEntry{ActionDirRestored, "dir", fileSize},
|
||||
itemTraceEntry{ActionFileRestored, "new", 0},
|
||||
}, items)
|
||||
}
|
||||
|
|
|
@ -8,12 +8,14 @@ import (
|
|||
)
|
||||
|
||||
type textPrinter struct {
|
||||
terminal term
|
||||
terminal term
|
||||
verbosity uint
|
||||
}
|
||||
|
||||
func NewTextProgress(terminal term) ProgressPrinter {
|
||||
func NewTextProgress(terminal term, verbosity uint) ProgressPrinter {
|
||||
return &textPrinter{
|
||||
terminal: terminal,
|
||||
terminal: terminal,
|
||||
verbosity: verbosity,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -31,6 +33,32 @@ func (t *textPrinter) Update(p State, duration time.Duration) {
|
|||
t.terminal.SetStatus([]string{progress})
|
||||
}
|
||||
|
||||
func (t *textPrinter) CompleteItem(messageType ItemAction, item string, size uint64) {
|
||||
if t.verbosity < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
var action string
|
||||
switch messageType {
|
||||
case ActionDirRestored:
|
||||
action = "restored"
|
||||
case ActionFileRestored:
|
||||
action = "restored"
|
||||
case ActionFileUpdated:
|
||||
action = "updated"
|
||||
case ActionFileUnchanged:
|
||||
action = "unchanged"
|
||||
default:
|
||||
panic("unknown message type")
|
||||
}
|
||||
|
||||
if messageType == ActionDirRestored {
|
||||
t.terminal.Print(fmt.Sprintf("restored %v", item))
|
||||
} else {
|
||||
t.terminal.Print(fmt.Sprintf("%-9v %v with size %v", action, item, ui.FormatBytes(size)))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *textPrinter) Finish(p State, duration time.Duration) {
|
||||
t.terminal.SetStatus([]string{})
|
||||
|
||||
|
|
|
@ -19,37 +19,55 @@ func (m *mockTerm) SetStatus(lines []string) {
|
|||
m.output = append([]string{}, lines...)
|
||||
}
|
||||
|
||||
func TestPrintUpdate(t *testing.T) {
|
||||
func createTextProgress() (*mockTerm, ProgressPrinter) {
|
||||
term := &mockTerm{}
|
||||
printer := NewTextProgress(term)
|
||||
printer := NewTextProgress(term, 3)
|
||||
return term, printer
|
||||
}
|
||||
|
||||
func TestPrintUpdate(t *testing.T) {
|
||||
term, printer := createTextProgress()
|
||||
printer.Update(State{3, 11, 0, 29, 47, 0}, 5*time.Second)
|
||||
test.Equals(t, []string{"[0:05] 61.70% 3 files/dirs 29 B, total 11 files/dirs 47 B"}, term.output)
|
||||
}
|
||||
|
||||
func TestPrintUpdateWithSkipped(t *testing.T) {
|
||||
term := &mockTerm{}
|
||||
printer := NewTextProgress(term)
|
||||
term, printer := createTextProgress()
|
||||
printer.Update(State{3, 11, 2, 29, 47, 59}, 5*time.Second)
|
||||
test.Equals(t, []string{"[0:05] 61.70% 3 files/dirs 29 B, total 11 files/dirs 47 B, skipped 2 files/dirs 59 B"}, term.output)
|
||||
}
|
||||
|
||||
func TestPrintSummaryOnSuccess(t *testing.T) {
|
||||
term := &mockTerm{}
|
||||
printer := NewTextProgress(term)
|
||||
term, printer := createTextProgress()
|
||||
printer.Finish(State{11, 11, 0, 47, 47, 0}, 5*time.Second)
|
||||
test.Equals(t, []string{"Summary: Restored 11 files/dirs (47 B) in 0:05"}, term.output)
|
||||
}
|
||||
|
||||
func TestPrintSummaryOnErrors(t *testing.T) {
|
||||
term := &mockTerm{}
|
||||
printer := NewTextProgress(term)
|
||||
term, printer := createTextProgress()
|
||||
printer.Finish(State{3, 11, 0, 29, 47, 0}, 5*time.Second)
|
||||
test.Equals(t, []string{"Summary: Restored 3 / 11 files/dirs (29 B / 47 B) in 0:05"}, term.output)
|
||||
}
|
||||
|
||||
func TestPrintSummaryOnSuccessWithSkipped(t *testing.T) {
|
||||
term := &mockTerm{}
|
||||
printer := NewTextProgress(term)
|
||||
term, printer := createTextProgress()
|
||||
printer.Finish(State{11, 11, 2, 47, 47, 59}, 5*time.Second)
|
||||
test.Equals(t, []string{"Summary: Restored 11 files/dirs (47 B) in 0:05, skipped 2 files/dirs 59 B"}, term.output)
|
||||
}
|
||||
|
||||
func TestPrintCompleteItem(t *testing.T) {
|
||||
for _, data := range []struct {
|
||||
action ItemAction
|
||||
size uint64
|
||||
expected string
|
||||
}{
|
||||
{ActionDirRestored, 0, "restored test"},
|
||||
{ActionFileRestored, 123, "restored test with size 123 B"},
|
||||
{ActionFileUpdated, 123, "updated test with size 123 B"},
|
||||
{ActionFileUnchanged, 123, "unchanged test with size 123 B"},
|
||||
} {
|
||||
term, printer := createTextProgress()
|
||||
printer.CompleteItem(data.action, "test", data.size)
|
||||
test.Equals(t, []string{data.expected}, term.output)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue