mirror of
https://github.com/kopia/kopia.git
synced 2026-03-13 19:57:27 -04:00
Fixes #690 This is a breaking change for folks who are expecting snapshots to fail quickly without writing a snapshot manifest in case of an error. Before this change, any source read failure would cause the entire snapshot to fail (and not write a snapshot manifest as a result), unless `ignoreFileErrors` or `ignoreDirectoryErrors` was set. The new behavior is to continue snapshotting remaining files and directories (this can be disabled by passing `--fail-fast` flag or setting `KOPIA_SNAPSHOT_FAIL_FAST=1` environment variable) and defer returning an error until the very end. After snapshotting we will always attempt to write the snapshot manifest (except when the root of the snapshot itself cannot be opened). In case of a fail-fast error, the manifest will be marked as 'partial' and the directory tree will contain only partial set of files. In case of any errors, the manifest (and each directory object) will list the number if failures and no more than 10 examples of failed files/directories along with their respective errors. Once the snapshot is complete we will return non-zero exit code to the operating system if there were any fatal errors during snapshotting. With this change we are repurposing `ignoreFileErrors` and `ignoreDirectoryErrors` to designate some errors as non-fatal. Non-fatal errors are reported as warnings in the logs and will not cause a non-zero exit code to be returned.
287 lines
8.0 KiB
Go
287 lines
8.0 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/kopia/kopia/fs"
|
|
"github.com/kopia/kopia/internal/units"
|
|
"github.com/kopia/kopia/repo"
|
|
"github.com/kopia/kopia/repo/manifest"
|
|
"github.com/kopia/kopia/repo/object"
|
|
"github.com/kopia/kopia/snapshot"
|
|
"github.com/kopia/kopia/snapshot/policy"
|
|
"github.com/kopia/kopia/snapshot/snapshotfs"
|
|
)
|
|
|
|
var (
|
|
snapshotListCommand = snapshotCommands.Command("list", "List snapshots of files and directories.").Alias("ls")
|
|
snapshotListPath = snapshotListCommand.Arg("source", "File or directory to show history of.").String()
|
|
snapshotListIncludeIncomplete = snapshotListCommand.Flag("incomplete", "Include incomplete.").Short('i').Bool()
|
|
snapshotListShowHumanReadable = snapshotListCommand.Flag("human-readable", "Show human-readable units").Default("true").Bool()
|
|
snapshotListShowDelta = snapshotListCommand.Flag("delta", "Include deltas.").Short('d').Bool()
|
|
snapshotListShowItemID = snapshotListCommand.Flag("manifest-id", "Include manifest item ID.").Short('m').Bool()
|
|
snapshotListShowRetentionReasons = snapshotListCommand.Flag("retention", "Include retention reasons.").Default("true").Bool()
|
|
snapshotListShowModTime = snapshotListCommand.Flag("mtime", "Include file mod time").Bool()
|
|
shapshotListShowOwner = snapshotListCommand.Flag("owner", "Include owner").Bool()
|
|
snapshotListShowIdentical = snapshotListCommand.Flag("show-identical", "Show identical snapshots").Short('l').Bool()
|
|
snapshotListShowAll = snapshotListCommand.Flag("all", "Show all shapshots (not just current username/host)").Short('a').Bool()
|
|
maxResultsPerPath = snapshotListCommand.Flag("max-results", "Maximum number of entries per source.").Short('n').Int()
|
|
)
|
|
|
|
func findSnapshotsForSource(ctx context.Context, rep repo.Repository, sourceInfo snapshot.SourceInfo) (manifestIDs []manifest.ID, relPath string, err error) {
|
|
for len(sourceInfo.Path) > 0 {
|
|
list, err := snapshot.ListSnapshotManifests(ctx, rep, &sourceInfo)
|
|
if err != nil {
|
|
return nil, "", errors.Wrapf(err, "error listing manifests for %v", sourceInfo)
|
|
}
|
|
|
|
if len(list) > 0 {
|
|
return list, relPath, nil
|
|
}
|
|
|
|
if len(relPath) > 0 {
|
|
relPath = filepath.Base(sourceInfo.Path) + "/" + relPath
|
|
} else {
|
|
relPath = filepath.Base(sourceInfo.Path)
|
|
}
|
|
|
|
log(ctx).Debugf("No snapshots of %v@%v:%v", sourceInfo.UserName, sourceInfo.Host, sourceInfo.Path)
|
|
|
|
parentPath := filepath.Dir(sourceInfo.Path)
|
|
if parentPath == sourceInfo.Path {
|
|
break
|
|
}
|
|
|
|
sourceInfo.Path = parentPath
|
|
}
|
|
|
|
return nil, "", nil
|
|
}
|
|
|
|
func findManifestIDs(ctx context.Context, rep repo.Repository, source string) ([]manifest.ID, string, error) {
|
|
if source == "" {
|
|
man, err := snapshot.ListSnapshotManifests(ctx, rep, nil)
|
|
return man, "", errors.Wrap(err, "error listing all snapshot manifests")
|
|
}
|
|
|
|
si, err := snapshot.ParseSourceInfo(source, rep.ClientOptions().Hostname, rep.ClientOptions().Username)
|
|
if err != nil {
|
|
return nil, "", errors.Errorf("invalid directory: '%s': %s", source, err)
|
|
}
|
|
|
|
manifestIDs, relPath, err := findSnapshotsForSource(ctx, rep, si)
|
|
if relPath != "" {
|
|
relPath = "/" + relPath
|
|
}
|
|
|
|
return manifestIDs, relPath, err
|
|
}
|
|
|
|
func runSnapshotsCommand(ctx context.Context, rep repo.Repository) error {
|
|
manifestIDs, relPath, err := findManifestIDs(ctx, rep, *snapshotListPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
manifests, err := snapshot.LoadSnapshots(ctx, rep, manifestIDs)
|
|
if err != nil {
|
|
return errors.Wrap(err, "unable to load snapshots")
|
|
}
|
|
|
|
return outputManifestGroups(ctx, rep, manifests, strings.Split(relPath, "/"))
|
|
}
|
|
|
|
func shouldOutputSnapshotSource(rep repo.Repository, src snapshot.SourceInfo) bool {
|
|
if *snapshotListShowAll {
|
|
return true
|
|
}
|
|
|
|
co := rep.ClientOptions()
|
|
|
|
if src.Host != co.Hostname {
|
|
return false
|
|
}
|
|
|
|
return src.UserName == co.Username
|
|
}
|
|
|
|
func outputManifestGroups(ctx context.Context, rep repo.Repository, manifests []*snapshot.Manifest, relPathParts []string) error {
|
|
separator := ""
|
|
|
|
var anyOutput bool
|
|
|
|
for _, snapshotGroup := range snapshot.GroupBySource(manifests) {
|
|
src := snapshotGroup[0].Source
|
|
if !shouldOutputSnapshotSource(rep, src) {
|
|
log(ctx).Debugf("skipping %v", src)
|
|
continue
|
|
}
|
|
|
|
fmt.Printf("%v%v\n", separator, src)
|
|
|
|
separator = "\n"
|
|
anyOutput = true
|
|
|
|
pol, _, err := policy.GetEffectivePolicy(ctx, rep, src)
|
|
if err != nil {
|
|
log(ctx).Warningf("unable to determine effective policy for %v", src)
|
|
} else {
|
|
pol.RetentionPolicy.ComputeRetentionReasons(snapshotGroup)
|
|
}
|
|
|
|
if err := outputManifestFromSingleSource(ctx, rep, snapshotGroup, relPathParts); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !anyOutput && !*snapshotListShowAll && len(manifests) > 0 {
|
|
log(ctx).Infof("No snapshots found. Pass --all to show snapshots from all users/hosts.\n")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func outputManifestFromSingleSource(ctx context.Context, rep repo.Repository, manifests []*snapshot.Manifest, parts []string) error {
|
|
var (
|
|
count int
|
|
lastTotalFileSize int64
|
|
previousOID object.ID
|
|
elidedCount int
|
|
maxElidedTime time.Time
|
|
)
|
|
|
|
manifests = snapshot.SortByTime(manifests, false)
|
|
if *maxResultsPerPath > 0 && len(manifests) > *maxResultsPerPath {
|
|
manifests = manifests[len(manifests)-*maxResultsPerPath:]
|
|
}
|
|
|
|
outputElided := func() {
|
|
if elidedCount > 0 {
|
|
fmt.Printf(
|
|
" + %v identical snapshots until %v\n",
|
|
elidedCount,
|
|
formatTimestamp(maxElidedTime),
|
|
)
|
|
}
|
|
}
|
|
|
|
for _, m := range manifests {
|
|
root, err := snapshotfs.SnapshotRoot(rep, m)
|
|
if err != nil {
|
|
fmt.Printf(" %v <ERROR> %v\n", formatTimestamp(m.StartTime), err)
|
|
continue
|
|
}
|
|
|
|
ent, err := snapshotfs.GetNestedEntry(ctx, root, parts)
|
|
if err != nil {
|
|
fmt.Printf(" %v <ERROR> %v\n", formatTimestamp(m.StartTime), err)
|
|
continue
|
|
}
|
|
|
|
if _, ok := ent.(object.HasObjectID); !ok {
|
|
log(ctx).Warningf("entry does not have object ID: %v", ent, err)
|
|
continue
|
|
}
|
|
|
|
if m.IncompleteReason != "" && !*snapshotListIncludeIncomplete {
|
|
continue
|
|
}
|
|
|
|
bits, col := entryBits(ctx, m, ent, lastTotalFileSize)
|
|
|
|
oid := ent.(object.HasObjectID).ObjectID()
|
|
if !*snapshotListShowIdentical && oid == previousOID {
|
|
elidedCount++
|
|
|
|
maxElidedTime = m.StartTime
|
|
|
|
continue
|
|
}
|
|
|
|
outputElided()
|
|
|
|
elidedCount = 0
|
|
previousOID = oid
|
|
|
|
col.Print(fmt.Sprintf(" %v %v %v\n", formatTimestamp(m.StartTime), oid, strings.Join(bits, " "))) //nolint:errcheck
|
|
|
|
count++
|
|
|
|
if m.IncompleteReason == "" {
|
|
lastTotalFileSize = m.Stats.TotalFileSize
|
|
}
|
|
}
|
|
|
|
outputElided()
|
|
|
|
return nil
|
|
}
|
|
|
|
func entryBits(ctx context.Context, m *snapshot.Manifest, ent fs.Entry, lastTotalFileSize int64) (bits []string, col *color.Color) {
|
|
col = color.New() // default color
|
|
|
|
if m.IncompleteReason != "" {
|
|
bits = append(bits, "incomplete:"+m.IncompleteReason)
|
|
}
|
|
|
|
bits = append(bits,
|
|
maybeHumanReadableBytes(*snapshotListShowHumanReadable, ent.Size()),
|
|
fmt.Sprintf("%v", ent.Mode()))
|
|
if *shapshotListShowOwner {
|
|
bits = append(bits,
|
|
fmt.Sprintf("uid:%v", ent.Owner().UserID),
|
|
fmt.Sprintf("gid:%v", ent.Owner().GroupID))
|
|
}
|
|
|
|
if *snapshotListShowModTime {
|
|
bits = append(bits, fmt.Sprintf("modified:%v", formatTimestamp(ent.ModTime())))
|
|
}
|
|
|
|
if *snapshotListShowItemID {
|
|
bits = append(bits, "manifest:"+string(m.ID))
|
|
}
|
|
|
|
if *snapshotListShowDelta {
|
|
bits = append(bits, deltaBytes(ent.Size()-lastTotalFileSize))
|
|
}
|
|
|
|
if dws, ok := ent.(fs.DirectoryWithSummary); ok {
|
|
if s, _ := dws.Summary(ctx); s != nil {
|
|
bits = append(bits,
|
|
fmt.Sprintf("files:%v", s.TotalFileCount),
|
|
fmt.Sprintf("dirs:%v", s.TotalDirCount))
|
|
if s.FatalErrorCount > 0 {
|
|
bits = append(bits, fmt.Sprintf("errors:%v", s.FatalErrorCount))
|
|
col = errorColor
|
|
}
|
|
}
|
|
}
|
|
|
|
if *snapshotListShowRetentionReasons {
|
|
if len(m.RetentionReasons) > 0 {
|
|
bits = append(bits, "("+strings.Join(m.RetentionReasons, ",")+")")
|
|
}
|
|
}
|
|
|
|
return bits, col
|
|
}
|
|
|
|
func deltaBytes(b int64) string {
|
|
if b > 0 {
|
|
return "(+" + units.BytesStringBase10(b) + ")"
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func init() {
|
|
snapshotListCommand.Action(repositoryReaderAction(runSnapshotsCommand))
|
|
}
|