Files
kopia/cli/command_snapshot_create.go
Jarek Kowalski 23273af1cd snapshot: reworked error handling and added fail-fast option (#840)
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.
2021-02-17 10:29:01 -08:00

339 lines
10 KiB
Go

package cli
import (
"context"
"path/filepath"
"strings"
"time"
"github.com/pkg/errors"
"github.com/kopia/kopia/repo"
"github.com/kopia/kopia/snapshot"
"github.com/kopia/kopia/snapshot/policy"
"github.com/kopia/kopia/snapshot/snapshotfs"
)
const (
maxSnapshotDescriptionLength = 1024
timeFormat = "2006-01-02 15:04:05 MST"
)
var (
snapshotCreateCommand = snapshotCommands.Command("create", "Creates a snapshot of local directory or file.").Default()
snapshotCreateSources = snapshotCreateCommand.Arg("source", "Files or directories to create snapshot(s) of.").ExistingFilesOrDirs()
snapshotCreateAll = snapshotCreateCommand.Flag("all", "Create snapshots for files or directories previously backed up by this user on this computer").Bool()
snapshotCreateCheckpointUploadLimitMB = snapshotCreateCommand.Flag("upload-limit-mb", "Stop the backup process after the specified amount of data (in MB) has been uploaded.").PlaceHolder("MB").Default("0").Int64()
snapshotCreateCheckpointInterval = snapshotCreateCommand.Flag("checkpoint-interval", "Frequency for creating periodic checkpoint.").Duration()
snapshotCreateDescription = snapshotCreateCommand.Flag("description", "Free-form snapshot description.").String()
snapshotCreateFailFast = snapshotCreateCommand.Flag("fail-fast", "Fail fast when creating snapshot.").Envar("KOPIA_SNAPSHOT_FAIL_FAST").Bool()
snapshotCreateForceHash = snapshotCreateCommand.Flag("force-hash", "Force hashing of source files for a given percentage of files [0..100]").Default("0").Int()
snapshotCreateParallelUploads = snapshotCreateCommand.Flag("parallel", "Upload N files in parallel").PlaceHolder("N").Default("0").Int()
snapshotCreateStartTime = snapshotCreateCommand.Flag("start-time", "Override snapshot start timestamp.").String()
snapshotCreateEndTime = snapshotCreateCommand.Flag("end-time", "Override snapshot end timestamp.").String()
snapshotCreateForceEnableActions = snapshotCreateCommand.Flag("force-enable-actions", "Enable snapshot actions even if globally disabled on this client").Hidden().Bool()
snapshotCreateForceDisableActions = snapshotCreateCommand.Flag("force-disable-actions", "Disable snapshot actions even if globally enabled on this client").Hidden().Bool()
)
func runSnapshotCommand(ctx context.Context, rep repo.RepositoryWriter) error {
sources := *snapshotCreateSources
if err := maybeAutoUpgradeRepository(ctx, rep); err != nil {
return errors.Wrap(err, "error upgrading repository")
}
if *snapshotCreateAll {
local, err := getLocalBackupPaths(ctx, rep)
if err != nil {
return err
}
sources = append(sources, local...)
}
if len(sources) == 0 {
return errors.New("no snapshot sources")
}
if err := validateStartEndTime(*snapshotCreateStartTime, *snapshotCreateEndTime); err != nil {
return err
}
if len(*snapshotCreateDescription) > maxSnapshotDescriptionLength {
return errors.New("description too long")
}
u := setupUploader(rep)
var finalErrors []string
for _, snapshotDir := range sources {
if u.IsCanceled() {
log(ctx).Infof("Upload canceled")
break
}
dir, err := filepath.Abs(snapshotDir)
if err != nil {
return errors.Errorf("invalid source: '%s': %s", snapshotDir, err)
}
sourceInfo := snapshot.SourceInfo{
Path: filepath.Clean(dir),
Host: rep.ClientOptions().Hostname,
UserName: rep.ClientOptions().Username,
}
if err := snapshotSingleSource(ctx, rep, u, sourceInfo); err != nil {
finalErrors = append(finalErrors, err.Error())
}
}
if len(finalErrors) == 0 {
return nil
}
if len(finalErrors) == 1 {
return errors.New(finalErrors[0])
}
return errors.Errorf("encountered %v errors:\n%v", len(finalErrors), strings.Join(finalErrors, "\n"))
}
func validateStartEndTime(st, et string) error {
startTime, err := parseTimestamp(st)
if err != nil {
return errors.Wrap(err, "could not parse start-time")
}
endTime, err := parseTimestamp(et)
if err != nil {
return errors.Wrap(err, "could not parse end-time")
}
if startTimeAfterEndTime(startTime, endTime) {
return errors.New("start time override cannot be after the end time override")
}
return nil
}
func setupUploader(rep repo.RepositoryWriter) *snapshotfs.Uploader {
u := snapshotfs.NewUploader(rep)
u.MaxUploadBytes = *snapshotCreateCheckpointUploadLimitMB << 20 //nolint:gomnd
if *snapshotCreateForceEnableActions {
u.EnableActions = true
}
if *snapshotCreateForceDisableActions {
u.EnableActions = false
}
if interval := *snapshotCreateCheckpointInterval; interval != 0 {
u.CheckpointInterval = interval
}
onCtrlC(u.Cancel)
u.ForceHashPercentage = *snapshotCreateForceHash
u.ParallelUploads = *snapshotCreateParallelUploads
u.FailFast = *snapshotCreateFailFast
u.Progress = progress
return u
}
func parseTimestamp(timestamp string) (time.Time, error) {
if timestamp == "" {
return time.Time{}, nil
}
return time.Parse(timeFormat, timestamp)
}
func startTimeAfterEndTime(startTime, endTime time.Time) bool {
return !startTime.IsZero() &&
!endTime.IsZero() &&
startTime.After(endTime)
}
func snapshotSingleSource(ctx context.Context, rep repo.RepositoryWriter, u *snapshotfs.Uploader, sourceInfo snapshot.SourceInfo) error {
log(ctx).Infof("Snapshotting %v ...", sourceInfo)
localEntry, err := getLocalFSEntry(ctx, sourceInfo.Path)
if err != nil {
return errors.Wrap(err, "unable to get local filesystem entry")
}
previous, err := findPreviousSnapshotManifest(ctx, rep, sourceInfo, nil)
if err != nil {
return err
}
policyTree, err := policy.TreeForSource(ctx, rep, sourceInfo)
if err != nil {
return errors.Wrap(err, "unable to get policy tree")
}
log(ctx).Debugf("uploading %v using %v previous manifests", sourceInfo, len(previous))
manifest, err := u.Upload(ctx, localEntry, policyTree, sourceInfo, previous...)
if err != nil {
// fail-fast uploads will fail here without recording a manifest, other uploads will
// possibly fail later.
return errors.Wrap(err, "upload error")
}
manifest.Description = *snapshotCreateDescription
startTimeOverride, _ := parseTimestamp(*snapshotCreateStartTime)
endTimeOverride, _ := parseTimestamp(*snapshotCreateEndTime)
if !startTimeOverride.IsZero() {
if endTimeOverride.IsZero() {
// Calculate the correct end time based on current duration if they're not specified
duration := manifest.EndTime.Sub(manifest.StartTime)
manifest.EndTime = startTimeOverride.Add(duration)
}
manifest.StartTime = startTimeOverride
}
if !endTimeOverride.IsZero() {
if startTimeOverride.IsZero() {
inverseDuration := manifest.StartTime.Sub(manifest.EndTime)
manifest.StartTime = endTimeOverride.Add(inverseDuration)
}
manifest.EndTime = endTimeOverride
}
if _, err = snapshot.SaveSnapshot(ctx, rep, manifest); err != nil {
return errors.Wrap(err, "cannot save manifest")
}
if _, err = policy.ApplyRetentionPolicy(ctx, rep, sourceInfo, true); err != nil {
return errors.Wrap(err, "unable to apply retention policy")
}
if ferr := rep.Flush(ctx); ferr != nil {
return errors.Wrap(ferr, "flush error")
}
progress.Finish()
return reportSnapshotStatus(ctx, manifest)
}
func reportSnapshotStatus(ctx context.Context, manifest *snapshot.Manifest) error {
var maybePartial string
if manifest.IncompleteReason != "" {
maybePartial = " partial"
}
sourceInfo := manifest.Source
snapID := manifest.ID
log(ctx).Infof("Created%v snapshot with root %v and ID %v in %v", maybePartial, manifest.RootObjectID(), snapID, manifest.EndTime.Sub(manifest.StartTime).Truncate(time.Second))
if ds := manifest.RootEntry.DirSummary; ds != nil {
if ds.IgnoredErrorCount > 0 {
log(ctx).Warningf("Ignored %v error(s) while snapshotting %v.", ds.IgnoredErrorCount, sourceInfo)
}
if ds.FatalErrorCount > 0 {
return errors.Errorf("Found %v fatal error(s) while snapshotting %v.", ds.FatalErrorCount, sourceInfo)
}
}
return nil
}
// findPreviousSnapshotManifest returns the list of previous snapshots for a given source, including
// last complete snapshot and possibly some number of incomplete snapshots following it.
func findPreviousSnapshotManifest(ctx context.Context, rep repo.Repository, sourceInfo snapshot.SourceInfo, noLaterThan *time.Time) ([]*snapshot.Manifest, error) {
man, err := snapshot.ListSnapshots(ctx, rep, sourceInfo)
if err != nil {
return nil, errors.Wrap(err, "error listing previous snapshots")
}
// phase 1 - find latest complete snapshot.
var previousComplete *snapshot.Manifest
var previousCompleteStartTime time.Time
var result []*snapshot.Manifest
for _, p := range man {
if noLaterThan != nil && p.StartTime.After(*noLaterThan) {
continue
}
if p.IncompleteReason == "" && (previousComplete == nil || p.StartTime.After(previousComplete.StartTime)) {
previousComplete = p
previousCompleteStartTime = p.StartTime
}
}
if previousComplete != nil {
result = append(result, previousComplete)
}
// add all incomplete snapshots after that
for _, p := range man {
if noLaterThan != nil && p.StartTime.After(*noLaterThan) {
continue
}
if p.IncompleteReason != "" && p.StartTime.After(previousCompleteStartTime) {
result = append(result, p)
}
}
return result, nil
}
func getLocalBackupPaths(ctx context.Context, rep repo.Repository) ([]string, error) {
log(ctx).Debugf("Looking for previous backups of '%v@%v'...", rep.ClientOptions().Hostname, rep.ClientOptions().Username)
sources, err := snapshot.ListSources(ctx, rep)
if err != nil {
return nil, errors.Wrap(err, "unable to list sources")
}
var result []string
for _, src := range sources {
// add all sources belonging to the repository user@host
// ignore sources that have Manual field set to true in the SchedulingPolicy
includeSource, err := shouldSnapshotSource(ctx, src, rep)
if err != nil {
return nil, err
}
if includeSource {
result = append(result, src.Path)
}
}
return result, nil
}
func shouldSnapshotSource(ctx context.Context, src snapshot.SourceInfo, rep repo.Repository) (bool, error) {
policyTree, err := policy.TreeForSource(ctx, rep, src)
if err != nil {
return false, errors.Wrapf(err, "unable to get policy tree for source %v", src)
}
return src.Host == rep.ClientOptions().Hostname &&
src.UserName == rep.ClientOptions().Username &&
!policy.IsManualSnapshot(policyTree), nil
}
func init() {
snapshotCreateCommand.Action(repositoryWriterAction(runSnapshotCommand))
}