2
2
mirror of https://github.com/octoleo/restic.git synced 2025-01-27 17:18:34 +00:00

Merge pull request #4839 from MichaelEischer/restore-dry-run

restore: add `--dry-run` and extended progress output
This commit is contained in:
Michael Eischer 2024-07-05 21:02:13 +02:00 committed by GitHub
commit 83fdcf21fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 360 additions and 86 deletions

View 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

View File

@ -47,6 +47,7 @@ type RestoreOptions struct {
includePatternOptions includePatternOptions
Target string Target string
restic.SnapshotFilter restic.SnapshotFilter
DryRun bool
Sparse bool Sparse bool
Verify bool Verify bool
Overwrite restorer.OverwriteBehavior Overwrite restorer.OverwriteBehavior
@ -64,6 +65,7 @@ func init() {
initIncludePatternOptions(flags, &restoreOptions.includePatternOptions) initIncludePatternOptions(flags, &restoreOptions.includePatternOptions)
initSingleSnapshotFilter(flags, &restoreOptions.SnapshotFilter) 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.Sparse, "sparse", false, "restore files as sparse")
flags.BoolVar(&restoreOptions.Verify, "verify", false, "verify restored files content") 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)") 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 { if hasExcludes && hasIncludes {
return errors.Fatal("exclude and include patterns are mutually exclusive") 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] snapshotIDString := args[0]
@ -133,13 +138,14 @@ func runRestore(ctx context.Context, opts RestoreOptions, gopts GlobalOptions,
msg := ui.NewMessage(term, gopts.verbosity) msg := ui.NewMessage(term, gopts.verbosity)
var printer restoreui.ProgressPrinter var printer restoreui.ProgressPrinter
if gopts.JSON { if gopts.JSON {
printer = restoreui.NewJSONProgress(term) printer = restoreui.NewJSONProgress(term, gopts.verbosity)
} else { } else {
printer = restoreui.NewTextProgress(term) printer = restoreui.NewTextProgress(term, gopts.verbosity)
} }
progress := restoreui.NewProgress(printer, calculateProgressInterval(!gopts.Quiet, gopts.JSON)) progress := restoreui.NewProgress(printer, calculateProgressInterval(!gopts.Quiet, gopts.JSON))
res := restorer.NewRestorer(repo, sn, restorer.Options{ res := restorer.NewRestorer(repo, sn, restorer.Options{
DryRun: opts.DryRun,
Sparse: opts.Sparse, Sparse: opts.Sparse,
Progress: progress, Progress: progress,
Overwrite: opts.Overwrite, Overwrite: opts.Overwrite,

View File

@ -242,7 +242,7 @@ case, specify the ``--skip-if-unchanged`` option.
Note that when using absolute paths to specify the backup target, then also 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 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 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. the corresponding folder and use relative paths.
.. code-block:: console .. code-block:: console

View File

@ -69,7 +69,7 @@ There are case insensitive variants of ``--exclude`` and ``--include`` called
ignore the casing of paths. ignore the casing of paths.
There are also ``--include-file``, ``--exclude-file``, ``--iinclude-file`` and 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 Restoring symbolic links on windows is only possible when the user has
``SeCreateSymbolicLinkPrivilege`` privilege or is running as admin. This is a ``SeCreateSymbolicLinkPrivilege`` privilege or is running as admin. This is a
@ -111,6 +111,28 @@ values are supported:
newer modification time (mtime). newer modification time (mtime).
* ``--overwrite never``: never overwrite existing files. * ``--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 Restore using mount
=================== ===================

View File

@ -511,6 +511,22 @@ Status
|``bytes_skipped`` | Total size of skipped files | |``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 Summary
^^^^^^^ ^^^^^^^

View File

@ -215,7 +215,7 @@ func (r *fileRestorer) restoreEmptyFileAt(location string) error {
return err return err
} }
r.progress.AddProgress(location, 0, 0) r.progress.AddProgress(location, restore.ActionFileRestored, 0, 0)
return nil return nil
} }
@ -337,7 +337,11 @@ func (r *fileRestorer) downloadBlobs(ctx context.Context, packID restic.ID,
createSize = file.size createSize = file.size
} }
writeErr := r.filesWriter.writeToFile(r.targetPath(file.location), blobData, offset, createSize, file.sparse) 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 return writeErr
} }
err := r.sanitizeError(file, writeToFile()) err := r.sanitizeError(file, writeToFile())

View File

@ -33,6 +33,7 @@ type Restorer struct {
var restorerAbortOnAllErrors = func(_ string, err error) error { return err } var restorerAbortOnAllErrors = func(_ string, err error) error { return err }
type Options struct { type Options struct {
DryRun bool
Sparse bool Sparse bool
Progress *restoreui.Progress Progress *restoreui.Progress
Overwrite OverwriteBehavior 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 { 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 !res.opts.DryRun {
if err := fs.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { debug.Log("restoreNode %v %v %v", node.Name, target, location)
return errors.Wrap(err, "RemoveNode") 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) res.opts.Progress.AddProgress(location, restoreui.ActionFileRestored, 0, 0)
if err != nil {
debug.Log("node.CreateAt(%s) error %v", target, err)
return err
}
res.opts.Progress.AddProgress(location, 0, 0)
return res.restoreNodeMetadataTo(node, target, location) return res.restoreNodeMetadataTo(node, target, location)
} }
func (res *Restorer) restoreNodeMetadataTo(node *restic.Node, target, location string) error { 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) debug.Log("restoreNodeMetadata %v %v %v", node.Name, target, location)
err := node.RestoreMetadata(target, res.Warn) err := node.RestoreMetadata(target, res.Warn)
if err != nil { 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 { func (res *Restorer) restoreHardlinkAt(node *restic.Node, target, path, location string) error {
if err := fs.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { if !res.opts.DryRun {
return errors.Wrap(err, "RemoveCreateHardlink") 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 { err := fs.Link(target, path)
return errors.WithStack(err) 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 // TODO investigate if hardlinks have separate metadata on any supported system
return res.restoreNodeMetadataTo(node, path, location) return res.restoreNodeMetadataTo(node, path, location)
} }
func (res *Restorer) ensureDir(target string) error { func (res *Restorer) ensureDir(target string) error {
if res.opts.DryRun {
return nil
}
fi, err := fs.Lstat(target) fi, err := fs.Lstat(target)
if err != nil && !errors.Is(err, os.ErrNotExist) { if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to check for directory: %w", err) 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) 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 { if updateMetadataOnly {
res.opts.Progress.AddSkippedFile(node.Size) res.opts.Progress.AddSkippedFile(location, node.Size)
} else { } else {
res.opts.Progress.AddFile(node.Size) 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) res.trackFile(location, updateMetadataOnly)
return nil return nil
@ -341,9 +361,11 @@ func (res *Restorer) RestoreTo(ctx context.Context, dst string) error {
return err return err
} }
err = filerestorer.restoreFiles(ctx) if !res.opts.DryRun {
if err != nil { err = filerestorer.restoreFiles(ctx)
return err if err != nil {
return err
}
} }
debug.Log("second pass for %q", dst) 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 { visitNode: func(node *restic.Node, target, location string) error {
debug.Log("second pass, visitNode: restore node %q", location) debug.Log("second pass, visitNode: restore node %q", location)
if node.Type != "file" { 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 res.restoreNodeTo(ctx, node, target, location)
}) })
return err return err
} }
if idx.Has(node.Inode, node.DeviceID) && idx.Value(node.Inode, node.DeviceID) != location { 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 res.restoreHardlinkAt(node, filerestorer.targetPath(idx.Value(node.Inode, node.DeviceID)), target, location)
}) })
return err 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 { leaveDir: func(node *restic.Node, target, location string) error {
err := res.restoreNodeMetadataTo(node, target, location) err := res.restoreNodeMetadataTo(node, target, location)
if err == nil { if err == nil {
res.opts.Progress.AddProgress(location, 0, 0) res.opts.Progress.AddProgress(location, restoreui.ActionDirRestored, 0, 0)
} }
return err return err
}, },
@ -392,7 +414,7 @@ func (res *Restorer) hasRestoredFile(location string) (metadataOnly bool, ok boo
return metadataOnly, ok 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) overwrite, err := shouldOverwrite(res.opts.Overwrite, node, target)
if err != nil { if err != nil {
return buf, err return buf, err
@ -401,7 +423,7 @@ func (res *Restorer) withOverwriteCheck(node *restic.Node, target string, isHard
if isHardlink { if isHardlink {
size = 0 size = 0
} }
res.opts.Progress.AddSkippedFile(size) res.opts.Progress.AddSkippedFile(location, size)
return buf, nil return buf, nil
} }

View File

@ -15,6 +15,7 @@ import (
"time" "time"
"github.com/restic/restic/internal/archiver" "github.com/restic/restic/internal/archiver"
"github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/fs" "github.com/restic/restic/internal/fs"
"github.com/restic/restic/internal/repository" "github.com/restic/restic/internal/repository"
"github.com/restic/restic/internal/restic" "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)
}

View File

@ -76,11 +76,21 @@ type printerMock struct {
func (p *printerMock) Update(_ restoreui.State, _ time.Duration) { 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) { func (p *printerMock) Finish(s restoreui.State, _ time.Duration) {
p.s = s p.s = s
} }
func TestRestorerProgressBar(t *testing.T) { 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) repo := repository.TestRepository(t)
sn, _ := saveSnapshot(t, repo, Snapshot{ sn, _ := saveSnapshot(t, repo, Snapshot{
@ -97,7 +107,7 @@ func TestRestorerProgressBar(t *testing.T) {
mock := &printerMock{} mock := &printerMock{}
progress := restoreui.NewProgress(mock, 0) 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) { res.SelectFilter = func(item string, dstpath string, node *restic.Node) (selectedForRestore bool, childMayBeSelected bool) {
return true, true return true, true
} }

View File

@ -7,12 +7,14 @@ import (
) )
type jsonPrinter struct { type jsonPrinter struct {
terminal term terminal term
verbosity uint
} }
func NewJSONProgress(terminal term) ProgressPrinter { func NewJSONProgress(terminal term, verbosity uint) ProgressPrinter {
return &jsonPrinter{ return &jsonPrinter{
terminal: terminal, terminal: terminal,
verbosity: verbosity,
} }
} }
@ -39,6 +41,34 @@ func (t *jsonPrinter) Update(p State, duration time.Duration) {
t.print(status) 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) { func (t *jsonPrinter) Finish(p State, duration time.Duration) {
status := summaryOutput{ status := summaryOutput{
MessageType: "summary", MessageType: "summary",
@ -65,6 +95,13 @@ type statusUpdate struct {
BytesSkipped uint64 `json:"bytes_skipped,omitempty"` 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 { type summaryOutput struct {
MessageType string `json:"message_type"` // "summary" MessageType string `json:"message_type"` // "summary"
SecondsElapsed uint64 `json:"seconds_elapsed,omitempty"` SecondsElapsed uint64 `json:"seconds_elapsed,omitempty"`

View File

@ -7,37 +7,55 @@ import (
"github.com/restic/restic/internal/test" "github.com/restic/restic/internal/test"
) )
func TestJSONPrintUpdate(t *testing.T) { func createJSONProgress() (*mockTerm, ProgressPrinter) {
term := &mockTerm{} 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) 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) 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) { func TestJSONPrintUpdateWithSkipped(t *testing.T) {
term := &mockTerm{} term, printer := createJSONProgress()
printer := NewJSONProgress(term)
printer.Update(State{3, 11, 2, 29, 47, 59}, 5*time.Second) 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) 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) { func TestJSONPrintSummaryOnSuccess(t *testing.T) {
term := &mockTerm{} term, printer := createJSONProgress()
printer := NewJSONProgress(term)
printer.Finish(State{11, 11, 0, 47, 47, 0}, 5*time.Second) 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) 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) { func TestJSONPrintSummaryOnErrors(t *testing.T) {
term := &mockTerm{} term, printer := createJSONProgress()
printer := NewJSONProgress(term)
printer.Finish(State{3, 11, 0, 29, 47, 0}, 5*time.Second) 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) 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) { func TestJSONPrintSummaryOnSuccessWithSkipped(t *testing.T) {
term := &mockTerm{} term, printer := createJSONProgress()
printer := NewJSONProgress(term)
printer.Finish(State{11, 11, 2, 47, 47, 59}, 5*time.Second) 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) 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)
}
}

View File

@ -39,9 +39,20 @@ type term interface {
type ProgressPrinter interface { type ProgressPrinter interface {
Update(progress State, duration time.Duration) Update(progress State, duration time.Duration)
CompleteItem(action ItemAction, item string, size uint64)
Finish(progress State, duration time.Duration) 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 { func NewProgress(printer ProgressPrinter, interval time.Duration) *Progress {
p := &Progress{ p := &Progress{
progressInfoMap: make(map[string]progressInfoEntry), 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 // 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 { if p == nil {
return return
} }
@ -96,10 +107,12 @@ func (p *Progress) AddProgress(name string, bytesWrittenPortion uint64, bytesTot
if entry.bytesWritten == entry.bytesTotal { if entry.bytesWritten == entry.bytesTotal {
delete(p.progressInfoMap, name) delete(p.progressInfoMap, name)
p.s.FilesFinished++ 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 { if p == nil {
return return
} }
@ -109,6 +122,8 @@ func (p *Progress) AddSkippedFile(size uint64) {
p.s.FilesSkipped++ p.s.FilesSkipped++
p.s.AllBytesSkipped += size p.s.AllBytesSkipped += size
p.printer.CompleteItem(ActionFileUnchanged, name, size)
} }
func (p *Progress) Finish() { func (p *Progress) Finish() {

View File

@ -16,8 +16,16 @@ type printerTraceEntry struct {
type printerTrace []printerTraceEntry type printerTrace []printerTraceEntry
type itemTraceEntry struct {
action ItemAction
item string
size uint64
}
type itemTrace []itemTraceEntry
type mockPrinter struct { type mockPrinter struct {
trace printerTrace trace printerTrace
items itemTrace
} }
const mockFinishDuration = 42 * time.Second const mockFinishDuration = 42 * time.Second
@ -25,95 +33,109 @@ const mockFinishDuration = 42 * time.Second
func (p *mockPrinter) Update(progress State, duration time.Duration) { func (p *mockPrinter) Update(progress State, duration time.Duration) {
p.trace = append(p.trace, printerTraceEntry{progress, duration, false}) 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) { func (p *mockPrinter) Finish(progress State, _ time.Duration) {
p.trace = append(p.trace, printerTraceEntry{progress, mockFinishDuration, true}) 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{} printer := &mockPrinter{}
progress := NewProgress(printer, 0) progress := NewProgress(printer, 0)
final := fn(progress) final := fn(progress)
progress.update(0, final) progress.update(0, final)
trace := append(printerTrace{}, printer.trace...) trace := append(printerTrace{}, printer.trace...)
items := append(itemTrace{}, printer.items...)
// cleanup to avoid goroutine leak, but copy trace first // cleanup to avoid goroutine leak, but copy trace first
progress.Finish() progress.Finish()
return trace return trace, items
} }
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
result := testProgress(func(progress *Progress) bool { result, items := testProgress(func(progress *Progress) bool {
return false return false
}) })
test.Equals(t, printerTrace{ test.Equals(t, printerTrace{
printerTraceEntry{State{0, 0, 0, 0, 0, 0}, 0, false}, printerTraceEntry{State{0, 0, 0, 0, 0, 0}, 0, false},
}, result) }, result)
test.Equals(t, itemTrace{}, items)
} }
func TestAddFile(t *testing.T) { func TestAddFile(t *testing.T) {
fileSize := uint64(100) fileSize := uint64(100)
result := testProgress(func(progress *Progress) bool { result, items := testProgress(func(progress *Progress) bool {
progress.AddFile(fileSize) progress.AddFile(fileSize)
return false return false
}) })
test.Equals(t, printerTrace{ test.Equals(t, printerTrace{
printerTraceEntry{State{0, 1, 0, 0, fileSize, 0}, 0, false}, printerTraceEntry{State{0, 1, 0, 0, fileSize, 0}, 0, false},
}, result) }, result)
test.Equals(t, itemTrace{}, items)
} }
func TestFirstProgressOnAFile(t *testing.T) { func TestFirstProgressOnAFile(t *testing.T) {
expectedBytesWritten := uint64(5) expectedBytesWritten := uint64(5)
expectedBytesTotal := uint64(100) expectedBytesTotal := uint64(100)
result := testProgress(func(progress *Progress) bool { result, items := testProgress(func(progress *Progress) bool {
progress.AddFile(expectedBytesTotal) progress.AddFile(expectedBytesTotal)
progress.AddProgress("test", expectedBytesWritten, expectedBytesTotal) progress.AddProgress("test", ActionFileUpdated, expectedBytesWritten, expectedBytesTotal)
return false return false
}) })
test.Equals(t, printerTrace{ test.Equals(t, printerTrace{
printerTraceEntry{State{0, 1, 0, expectedBytesWritten, expectedBytesTotal, 0}, 0, false}, printerTraceEntry{State{0, 1, 0, expectedBytesWritten, expectedBytesTotal, 0}, 0, false},
}, result) }, result)
test.Equals(t, itemTrace{}, items)
} }
func TestLastProgressOnAFile(t *testing.T) { func TestLastProgressOnAFile(t *testing.T) {
fileSize := uint64(100) fileSize := uint64(100)
result := testProgress(func(progress *Progress) bool { result, items := testProgress(func(progress *Progress) bool {
progress.AddFile(fileSize) progress.AddFile(fileSize)
progress.AddProgress("test", 30, fileSize) progress.AddProgress("test", ActionFileUpdated, 30, fileSize)
progress.AddProgress("test", 35, fileSize) progress.AddProgress("test", ActionFileUpdated, 35, fileSize)
progress.AddProgress("test", 35, fileSize) progress.AddProgress("test", ActionFileUpdated, 35, fileSize)
return false return false
}) })
test.Equals(t, printerTrace{ test.Equals(t, printerTrace{
printerTraceEntry{State{1, 1, 0, fileSize, fileSize, 0}, 0, false}, printerTraceEntry{State{1, 1, 0, fileSize, fileSize, 0}, 0, false},
}, result) }, result)
test.Equals(t, itemTrace{
itemTraceEntry{action: ActionFileUpdated, item: "test", size: fileSize},
}, items)
} }
func TestLastProgressOnLastFile(t *testing.T) { func TestLastProgressOnLastFile(t *testing.T) {
fileSize := uint64(100) fileSize := uint64(100)
result := testProgress(func(progress *Progress) bool { result, items := testProgress(func(progress *Progress) bool {
progress.AddFile(fileSize) progress.AddFile(fileSize)
progress.AddFile(50) progress.AddFile(50)
progress.AddProgress("test1", 50, 50) progress.AddProgress("test1", ActionFileUpdated, 50, 50)
progress.AddProgress("test2", 50, fileSize) progress.AddProgress("test2", ActionFileUpdated, 50, fileSize)
progress.AddProgress("test2", 50, fileSize) progress.AddProgress("test2", ActionFileUpdated, 50, fileSize)
return false return false
}) })
test.Equals(t, printerTrace{ test.Equals(t, printerTrace{
printerTraceEntry{State{2, 2, 0, 50 + fileSize, 50 + fileSize, 0}, 0, false}, printerTraceEntry{State{2, 2, 0, 50 + fileSize, 50 + fileSize, 0}, 0, false},
}, result) }, 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) { func TestSummaryOnSuccess(t *testing.T) {
fileSize := uint64(100) fileSize := uint64(100)
result := testProgress(func(progress *Progress) bool { result, _ := testProgress(func(progress *Progress) bool {
progress.AddFile(fileSize) progress.AddFile(fileSize)
progress.AddFile(50) progress.AddFile(50)
progress.AddProgress("test1", 50, 50) progress.AddProgress("test1", ActionFileUpdated, 50, 50)
progress.AddProgress("test2", fileSize, fileSize) progress.AddProgress("test2", ActionFileUpdated, fileSize, fileSize)
return true return true
}) })
test.Equals(t, printerTrace{ test.Equals(t, printerTrace{
@ -124,11 +146,11 @@ func TestSummaryOnSuccess(t *testing.T) {
func TestSummaryOnErrors(t *testing.T) { func TestSummaryOnErrors(t *testing.T) {
fileSize := uint64(100) fileSize := uint64(100)
result := testProgress(func(progress *Progress) bool { result, _ := testProgress(func(progress *Progress) bool {
progress.AddFile(fileSize) progress.AddFile(fileSize)
progress.AddFile(50) progress.AddFile(50)
progress.AddProgress("test1", 50, 50) progress.AddProgress("test1", ActionFileUpdated, 50, 50)
progress.AddProgress("test2", fileSize/2, fileSize) progress.AddProgress("test2", ActionFileUpdated, fileSize/2, fileSize)
return true return true
}) })
test.Equals(t, printerTrace{ test.Equals(t, printerTrace{
@ -139,11 +161,30 @@ func TestSummaryOnErrors(t *testing.T) {
func TestSkipFile(t *testing.T) { func TestSkipFile(t *testing.T) {
fileSize := uint64(100) fileSize := uint64(100)
result := testProgress(func(progress *Progress) bool { result, items := testProgress(func(progress *Progress) bool {
progress.AddSkippedFile(fileSize) progress.AddSkippedFile("test", fileSize)
return true return true
}) })
test.Equals(t, printerTrace{ test.Equals(t, printerTrace{
printerTraceEntry{State{0, 0, 1, 0, 0, fileSize}, mockFinishDuration, true}, printerTraceEntry{State{0, 0, 1, 0, 0, fileSize}, mockFinishDuration, true},
}, result) }, 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)
} }

View File

@ -8,12 +8,14 @@ import (
) )
type textPrinter struct { type textPrinter struct {
terminal term terminal term
verbosity uint
} }
func NewTextProgress(terminal term) ProgressPrinter { func NewTextProgress(terminal term, verbosity uint) ProgressPrinter {
return &textPrinter{ 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}) 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) { func (t *textPrinter) Finish(p State, duration time.Duration) {
t.terminal.SetStatus([]string{}) t.terminal.SetStatus([]string{})

View File

@ -19,37 +19,55 @@ func (m *mockTerm) SetStatus(lines []string) {
m.output = append([]string{}, lines...) m.output = append([]string{}, lines...)
} }
func TestPrintUpdate(t *testing.T) { func createTextProgress() (*mockTerm, ProgressPrinter) {
term := &mockTerm{} 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) 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) 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) { func TestPrintUpdateWithSkipped(t *testing.T) {
term := &mockTerm{} term, printer := createTextProgress()
printer := NewTextProgress(term)
printer.Update(State{3, 11, 2, 29, 47, 59}, 5*time.Second) 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) 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) { func TestPrintSummaryOnSuccess(t *testing.T) {
term := &mockTerm{} term, printer := createTextProgress()
printer := NewTextProgress(term)
printer.Finish(State{11, 11, 0, 47, 47, 0}, 5*time.Second) 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) test.Equals(t, []string{"Summary: Restored 11 files/dirs (47 B) in 0:05"}, term.output)
} }
func TestPrintSummaryOnErrors(t *testing.T) { func TestPrintSummaryOnErrors(t *testing.T) {
term := &mockTerm{} term, printer := createTextProgress()
printer := NewTextProgress(term)
printer.Finish(State{3, 11, 0, 29, 47, 0}, 5*time.Second) 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) test.Equals(t, []string{"Summary: Restored 3 / 11 files/dirs (29 B / 47 B) in 0:05"}, term.output)
} }
func TestPrintSummaryOnSuccessWithSkipped(t *testing.T) { func TestPrintSummaryOnSuccessWithSkipped(t *testing.T) {
term := &mockTerm{} term, printer := createTextProgress()
printer := NewTextProgress(term)
printer.Finish(State{11, 11, 2, 47, 47, 59}, 5*time.Second) 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) 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)
}
}