diff --git a/pkg/kopia/command/parse_command_output.go b/pkg/kopia/command/parse_command_output.go index f7b4a24e63..2e4e208294 100644 --- a/pkg/kopia/command/parse_command_output.go +++ b/pkg/kopia/command/parse_command_output.go @@ -40,6 +40,7 @@ const ( //nolint:lll snapshotCreateOutputRegEx = `(?P[|/\-\\\*]).+[^\d](?P\d+) hashed \((?P[^\)]+)\), (?P\d+) cached \((?P[^\)]+)\), uploaded (?P[^\)]+), (?:estimating...|estimated (?P[^\)]+) \((?P[^\)]+)\%\).+)` restoreOutputRegEx = `Processed (?P\d+) \((?P.*)\) of (?P\d+) \((?P.*)\) (?P.*) \((?P.*)%\) remaining (?P.*)\.` + restoreResultOutputRegEx = `Restored (?P\d+) files, (?P\d+) directories and (?P\d+) symbolic links \((?P[^\)]+)\).` extractSnapshotIDRegEx = `Created snapshot with root ([^\s]+) and ID ([^\s]+).*$` repoTotalSizeFromBlobStatsRegEx = `Total: (\d+)$` repoCountFromBlobStatsRegEx = `Count: (\d+)$` @@ -207,8 +208,9 @@ type SnapshotCreateStats struct { } var ( - kopiaProgressPattern = regexp.MustCompile(snapshotCreateOutputRegEx) - kopiaRestorePattern = regexp.MustCompile(restoreOutputRegEx) + kopiaProgressPattern = regexp.MustCompile(snapshotCreateOutputRegEx) + kopiaRestorePattern = regexp.MustCompile(restoreOutputRegEx) + kopiaRestoreResultPattern = regexp.MustCompile(restoreResultOutputRegEx) ) // SnapshotStatsFromSnapshotCreate parses the output of a `kopia snapshot @@ -430,6 +432,64 @@ func parseKopiaRestoreProgressLine(line string) (stats *RestoreStats) { } } +// RestoreResultFromRestoreOutput parses the output of a `kopia restore` +// line-by-line in search of restore result statistics. +// It returns nil if no final statistics are found. +func RestoreResultFromRestoreOutput( + restoreStderrOutput string, +) (stats *RestoreStats) { + if restoreStderrOutput == "" { + return nil + } + logs := regexp.MustCompile("[\r\n]").Split(restoreStderrOutput, -1) + + for _, l := range logs { + lineStats := parseKopiaRestoreResultLine(l) + if lineStats != nil { + stats = lineStats + } + } + + return stats +} + +// parseKopiaRestoreResultLine parses final restore stats from the output log line, +// which is expected to be in the following format: +// Restored 1 files, 1 directories and 0 symbolic links (1.1 GB). +func parseKopiaRestoreResultLine(line string) (stats *RestoreStats) { + match := kopiaRestoreResultPattern.FindStringSubmatch(line) + if len(match) < 4 { + return nil + } + + groups := make(map[string]string) + for i, name := range kopiaRestoreResultPattern.SubexpNames() { + if i != 0 && name != "" { + groups[name] = match[i] + } + } + + processedFilesCount, err := strconv.Atoi(groups["processedFilesCount"]) + if err != nil { + log.WithError(err).Print("Skipping entry due to inability to parse number of processed files", field.M{"processedFilesCount": groups["processedFilesCount"]}) + return nil + } + + restoredSize, err := humanize.ParseBytes(groups["restoredSize"]) + if err != nil { + log.WithError(err).Print("Skipping entry due to inability to parse amount of restored bytes", field.M{"restoredSize": groups["restoredSize"]}) + return nil + } + + return &RestoreStats{ + FilesProcessed: int64(processedFilesCount), + SizeProcessedB: int64(restoredSize), + FilesTotal: int64(processedFilesCount), + SizeTotalB: int64(restoredSize), + ProgressPercent: int64(100), + } +} + // RepoSizeStatsFromBlobStatsRaw takes a string as input, interprets it as a kopia blob stats // output in an expected format (Contains the line "Total: "), and returns the integer // size in bytes or an error if parsing is unsuccessful. diff --git a/pkg/kopia/command/parse_command_output_test.go b/pkg/kopia/command/parse_command_output_test.go index 67671e78c4..8c752a8eeb 100644 --- a/pkg/kopia/command/parse_command_output_test.go +++ b/pkg/kopia/command/parse_command_output_test.go @@ -502,6 +502,49 @@ func (kParse *KopiaParseUtilsTestSuite) TestRestoreStatsFromRestoreOutput(c *C) } } +func (kParse *KopiaParseUtilsTestSuite) TestRestoreResultFromRestoreOutput(c *C) { + type args struct { + restoreOutput string + } + tests := []struct { + name string + args args + wantStats *RestoreStats + }{ + { + name: "Basic test case", + args: args{ + restoreOutput: "Processed 2 (397.5 MB) of 3 (3.1 GB) 14.9 MB/s (12.6%) remaining 3m3s.\r\nRestored 1 files, 1 directories and 0 symbolic links (1.1 GB).", + }, + wantStats: &RestoreStats{ + FilesProcessed: 1, + SizeProcessedB: 1100000000, + FilesTotal: 1, + SizeTotalB: 1100000000, + ProgressPercent: 100, + }, + }, + { + name: "Not finished test case", + args: args{ + restoreOutput: "Processed 2 (13.7 MB) of 2 (3.1 GB) 8.5 MB/s (0.4%) remaining 6m10s.", + }, + wantStats: nil, + }, + { + name: "Ignore incomplete stats without during estimation", + args: args{ + restoreOutput: "Processed 2 (32.8 KB) of 2 (3.1 GB).", + }, + wantStats: nil, + }, + } + for _, tt := range tests { + stats := RestoreResultFromRestoreOutput(tt.args.restoreOutput) + c.Check(stats, DeepEquals, tt.wantStats, Commentf("Failed for %s", tt.name)) + } +} + func (kParse *KopiaParseUtilsTestSuite) TestPhysicalSizeFromBlobStatsRaw(c *C) { for _, tc := range []struct { blobStatsOutput string