restic/cmd/restic/cmd_restore.go
Michael Eischer 3b854d9c04
Some checks are pending
Create and publish a Docker image / build-and-push-image (push) Waiting to run
Create and publish a Docker image / provenance (push) Blocked by required conditions
test / Linux Go 1.23.x (push) Waiting to run
test / Linux Go 1.24.x (push) Waiting to run
test / Linux (race) Go 1.25.x (push) Waiting to run
test / Windows Go 1.25.x (push) Waiting to run
test / macOS Go 1.25.x (push) Waiting to run
test / Linux Go 1.25.x (push) Waiting to run
test / Cross Compile for subset 0/3 (push) Waiting to run
test / Cross Compile for subset 1/3 (push) Waiting to run
test / Cross Compile for subset 2/3 (push) Waiting to run
test / lint (push) Waiting to run
test / Analyze results (push) Blocked by required conditions
test / docker (push) Waiting to run
Merge pull request #5449 from provokateurin/restore-ownership-by-name
feat(internal/fs/node): Restore ownership by name
2025-11-16 16:50:36 +01:00

320 lines
9.4 KiB
Go

package main
import (
"context"
"path/filepath"
"runtime"
"time"
"github.com/restic/restic/internal/data"
"github.com/restic/restic/internal/debug"
"github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/filter"
"github.com/restic/restic/internal/global"
"github.com/restic/restic/internal/restorer"
"github.com/restic/restic/internal/ui"
"github.com/restic/restic/internal/ui/progress"
restoreui "github.com/restic/restic/internal/ui/restore"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func newRestoreCommand(globalOptions *global.Options) *cobra.Command {
var opts RestoreOptions
cmd := &cobra.Command{
Use: "restore [flags] snapshotID",
Short: "Extract the data from a snapshot",
Long: `
The "restore" command extracts the data from a snapshot from the repository to
a directory.
The special snapshotID "latest" can be used to restore the latest snapshot in the
repository.
To only restore a specific subfolder, you can use the "snapshotID:subfolder"
syntax, where "subfolder" is a path within the snapshot.
POSIX ACLs are always restored by their numeric value, while file ownership can optionally be restored by name instead of numeric value.
EXIT STATUS
===========
Exit status is 0 if the command was successful.
Exit status is 1 if there was any error.
Exit status is 10 if the repository does not exist.
Exit status is 11 if the repository is already locked.
Exit status is 12 if the password is incorrect.
`,
GroupID: cmdGroupDefault,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
finalizeSnapshotFilter(&opts.SnapshotFilter)
return runRestore(cmd.Context(), opts, *globalOptions, globalOptions.Term, args)
},
}
opts.AddFlags(cmd.Flags())
return cmd
}
// RestoreOptions collects all options for the restore command.
type RestoreOptions struct {
filter.ExcludePatternOptions
filter.IncludePatternOptions
Target string
data.SnapshotFilter
DryRun bool
Sparse bool
Verify bool
Overwrite restorer.OverwriteBehavior
Delete bool
ExcludeXattrPattern []string
IncludeXattrPattern []string
OwnershipByName bool
}
func (opts *RestoreOptions) AddFlags(f *pflag.FlagSet) {
f.StringVarP(&opts.Target, "target", "t", "", "directory to extract data to")
opts.ExcludePatternOptions.Add(f)
opts.IncludePatternOptions.Add(f)
f.StringArrayVar(&opts.ExcludeXattrPattern, "exclude-xattr", nil, "exclude xattr by `pattern` (can be specified multiple times)")
f.StringArrayVar(&opts.IncludeXattrPattern, "include-xattr", nil, "include xattr by `pattern` (can be specified multiple times)")
initSingleSnapshotFilter(f, &opts.SnapshotFilter)
f.BoolVar(&opts.DryRun, "dry-run", false, "do not write any data, just show what would be done")
f.BoolVar(&opts.Sparse, "sparse", false, "restore files as sparse")
f.BoolVar(&opts.Verify, "verify", false, "verify restored files content")
f.Var(&opts.Overwrite, "overwrite", "overwrite behavior, one of (always|if-changed|if-newer|never)")
f.BoolVar(&opts.Delete, "delete", false, "delete files from target directory if they do not exist in snapshot. Use '--dry-run -vv' to check what would be deleted")
if runtime.GOOS != "windows" {
f.BoolVar(&opts.OwnershipByName, "ownership-by-name", false, "restore file ownership by user name and group name (except POSIX ACLs)")
}
}
func runRestore(ctx context.Context, opts RestoreOptions, gopts global.Options,
term ui.Terminal, args []string) error {
var printer restoreui.ProgressPrinter
if gopts.JSON {
printer = restoreui.NewJSONProgress(term, gopts.Verbosity)
} else {
printer = restoreui.NewTextProgress(term, gopts.Verbosity)
}
excludePatternFns, err := opts.ExcludePatternOptions.CollectPatterns(printer.E)
if err != nil {
return err
}
includePatternFns, err := opts.IncludePatternOptions.CollectPatterns(printer.E)
if err != nil {
return err
}
hasExcludes := len(excludePatternFns) > 0
hasIncludes := len(includePatternFns) > 0
switch {
case len(args) == 0:
return errors.Fatal("no snapshot ID specified")
case len(args) > 1:
return errors.Fatalf("more than one snapshot ID specified: %v", args)
}
if opts.Target == "" {
return errors.Fatal("please specify a directory to restore to (--target)")
}
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")
}
if opts.Delete && filepath.Clean(opts.Target) == "/" && !hasExcludes && !hasIncludes {
return errors.Fatal("'--target / --delete' must be combined with an include or exclude filter")
}
snapshotIDString := args[0]
debug.Log("restore %v to %v", snapshotIDString, opts.Target)
ctx, repo, unlock, err := openWithReadLock(ctx, gopts, gopts.NoLock, printer)
if err != nil {
return err
}
defer unlock()
sn, subfolder, err := (&data.SnapshotFilter{
Hosts: opts.Hosts,
Paths: opts.Paths,
Tags: opts.Tags,
}).FindLatest(ctx, repo, repo, snapshotIDString)
if err != nil {
return errors.Fatalf("failed to find snapshot: %v", err)
}
err = repo.LoadIndex(ctx, printer)
if err != nil {
return err
}
sn.Tree, err = data.FindTreeDirectory(ctx, repo, sn.Tree, subfolder)
if err != nil {
return err
}
progress := restoreui.NewProgress(printer, ui.CalculateProgressInterval(!gopts.Quiet, gopts.JSON, term.CanUpdateStatus()))
res := restorer.NewRestorer(repo, sn, restorer.Options{
DryRun: opts.DryRun,
Sparse: opts.Sparse,
Progress: progress,
Overwrite: opts.Overwrite,
Delete: opts.Delete,
OwnershipByName: opts.OwnershipByName,
})
totalErrors := 0
res.Error = func(location string, err error) error {
totalErrors++
return progress.Error(location, err)
}
res.Warn = func(message string) {
printer.E("Warning: %s\n", message)
}
res.Info = func(message string) {
if gopts.JSON {
return
}
printer.P("Info: %s\n", message)
}
selectExcludeFilter := func(item string, isDir bool) (selectedForRestore bool, childMayBeSelected bool) {
matched := false
for _, rejectFn := range excludePatternFns {
matched = matched || rejectFn(item)
// implementing a short-circuit here to improve the performance
// to prevent additional pattern matching once the first pattern
// matches.
if matched {
break
}
}
// An exclude filter is basically a 'wildcard but foo',
// so even if a childMayMatch, other children of a dir may not,
// therefore childMayMatch does not matter, but we should not go down
// unless the dir is selected for restore
selectedForRestore = !matched
childMayBeSelected = selectedForRestore && isDir
return selectedForRestore, childMayBeSelected
}
selectIncludeFilter := func(item string, isDir bool) (selectedForRestore bool, childMayBeSelected bool) {
selectedForRestore = false
childMayBeSelected = false
for _, includeFn := range includePatternFns {
matched, childMayMatch := includeFn(item)
selectedForRestore = selectedForRestore || matched
childMayBeSelected = childMayBeSelected || childMayMatch
if selectedForRestore && childMayBeSelected {
break
}
}
childMayBeSelected = childMayBeSelected && isDir
return selectedForRestore, childMayBeSelected
}
if hasExcludes {
res.SelectFilter = selectExcludeFilter
} else if hasIncludes {
res.SelectFilter = selectIncludeFilter
}
res.XattrSelectFilter, err = getXattrSelectFilter(opts, printer)
if err != nil {
return err
}
if !gopts.JSON {
printer.P("restoring %s to %s\n", res.Snapshot(), opts.Target)
}
countRestoredFiles, err := res.RestoreTo(ctx, opts.Target)
if err != nil {
return err
}
progress.Finish()
if totalErrors > 0 {
return errors.Fatalf("There were %d errors", totalErrors)
}
if opts.Verify {
if !gopts.JSON {
printer.P("verifying files in %s\n", opts.Target)
}
var count int
t0 := time.Now()
bar := printer.NewCounterTerminalOnly("files verified")
count, err = res.VerifyFiles(ctx, opts.Target, countRestoredFiles, bar)
if err != nil {
return err
}
if totalErrors > 0 {
return errors.Fatalf("There were %d errors", totalErrors)
}
if !gopts.JSON {
printer.P("finished verifying %d files in %s (took %s)\n", count, opts.Target,
time.Since(t0).Round(time.Millisecond))
}
}
return nil
}
func getXattrSelectFilter(opts RestoreOptions, printer progress.Printer) (func(xattrName string) bool, error) {
hasXattrExcludes := len(opts.ExcludeXattrPattern) > 0
hasXattrIncludes := len(opts.IncludeXattrPattern) > 0
if hasXattrExcludes && hasXattrIncludes {
return nil, errors.Fatal("exclude and include xattr patterns are mutually exclusive")
}
if hasXattrExcludes {
if err := filter.ValidatePatterns(opts.ExcludeXattrPattern); err != nil {
return nil, errors.Fatalf("--exclude-xattr: %s", err)
}
return func(xattrName string) bool {
shouldReject := filter.RejectByPattern(opts.ExcludeXattrPattern, printer.E)(xattrName)
return !shouldReject
}, nil
}
if hasXattrIncludes {
// User has either input include xattr pattern(s) or we're using our default include pattern
if err := filter.ValidatePatterns(opts.IncludeXattrPattern); err != nil {
return nil, errors.Fatalf("--include-xattr: %s", err)
}
return func(xattrName string) bool {
shouldInclude, _ := filter.IncludeByPattern(opts.IncludeXattrPattern, printer.E)(xattrName)
return shouldInclude
}, nil
}
// default to including all xattrs
return func(_ string) bool { return true }, nil
}