mirror of
https://github.com/kopia/kopia.git
synced 2026-06-25 22:35:35 -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.
630 lines
16 KiB
Go
630 lines
16 KiB
Go
// Package testenv contains Environment for use in testing.
|
|
package testenv
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
cryptorand "crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"math/rand"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/kopia/kopia/internal/clock"
|
|
"github.com/kopia/kopia/internal/iocopy"
|
|
"github.com/kopia/kopia/internal/testutil"
|
|
)
|
|
|
|
const (
|
|
// TestRepoPassword is a password for repositories created in tests.
|
|
TestRepoPassword = "qWQPJ2hiiLgWRRCr"
|
|
|
|
maxOutputLinesToLog = 4000
|
|
)
|
|
|
|
// CLITest encapsulates state for a CLI-based test.
|
|
type CLITest struct {
|
|
startTime time.Time
|
|
|
|
RepoDir string
|
|
ConfigDir string
|
|
Exe string
|
|
|
|
fixedArgs []string
|
|
Environment []string
|
|
|
|
DefaultRepositoryCreateFlags []string
|
|
|
|
PassthroughStderr bool // this is for debugging only
|
|
|
|
LogsDir string
|
|
}
|
|
|
|
// SourceInfo reprents a single source (user@host:/path) with its snapshots.
|
|
type SourceInfo struct {
|
|
User string
|
|
Host string
|
|
Path string
|
|
Snapshots []SnapshotInfo
|
|
}
|
|
|
|
// SnapshotInfo represents a single snapshot information.
|
|
type SnapshotInfo struct {
|
|
ObjectID string
|
|
SnapshotID string
|
|
Time time.Time
|
|
}
|
|
|
|
// NewCLITest creates a new instance of *CLITest.
|
|
func NewCLITest(t *testing.T) *CLITest {
|
|
t.Helper()
|
|
|
|
exe := os.Getenv("KOPIA_EXE")
|
|
if exe == "" {
|
|
// exe = "kopia"
|
|
t.Skip()
|
|
}
|
|
|
|
configDir := testutil.TempDirectory(t)
|
|
|
|
cleanName := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(
|
|
t.Name(),
|
|
"/", "_"), "\\", "_"), ":", "_")
|
|
|
|
logsDir := filepath.Join(os.TempDir(), "kopia-logs", cleanName+"."+clock.Now().Local().Format("20060102150405"))
|
|
|
|
t.Cleanup(func() {
|
|
if t.Failed() && os.Getenv("KOPIA_DISABLE_LOG_DUMP_ON_FAILURE") == "" {
|
|
dumpLogs(t, logsDir)
|
|
}
|
|
|
|
os.RemoveAll(logsDir)
|
|
})
|
|
|
|
fixedArgs := []string{
|
|
// use per-test config file, to avoid clobbering current user's setup.
|
|
"--config-file", filepath.Join(configDir, ".kopia.config"),
|
|
"--log-dir", logsDir,
|
|
}
|
|
|
|
// disable the use of keyring
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
fixedArgs = append(fixedArgs, "--no-use-keychain")
|
|
case "windows":
|
|
fixedArgs = append(fixedArgs, "--no-use-credential-manager")
|
|
case "linux":
|
|
fixedArgs = append(fixedArgs, "--no-use-keyring")
|
|
}
|
|
|
|
var formatFlags []string
|
|
|
|
switch runtime.GOARCH {
|
|
case "arm64", "arm":
|
|
formatFlags = []string{
|
|
"--encryption", "CHACHA20-POLY1305-HMAC-SHA256",
|
|
"--block-hash", "BLAKE2S-256",
|
|
}
|
|
}
|
|
|
|
return &CLITest{
|
|
startTime: clock.Now(),
|
|
RepoDir: testutil.TempDirectory(t),
|
|
ConfigDir: configDir,
|
|
Exe: filepath.FromSlash(exe),
|
|
fixedArgs: fixedArgs,
|
|
DefaultRepositoryCreateFlags: formatFlags,
|
|
LogsDir: logsDir,
|
|
Environment: []string{
|
|
"KOPIA_PASSWORD=" + TestRepoPassword,
|
|
"KOPIA_ADVANCED_COMMANDS=enabled",
|
|
},
|
|
}
|
|
}
|
|
|
|
func dumpLogs(t *testing.T, dirname string) {
|
|
t.Helper()
|
|
|
|
entries, err := ioutil.ReadDir(dirname)
|
|
if err != nil {
|
|
t.Errorf("unable to read %v: %v", dirname, err)
|
|
|
|
return
|
|
}
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
dumpLogs(t, filepath.Join(dirname, e.Name()))
|
|
continue
|
|
}
|
|
|
|
dumpLogFile(t, filepath.Join(dirname, e.Name()))
|
|
}
|
|
}
|
|
|
|
func dumpLogFile(t *testing.T, fname string) {
|
|
t.Helper()
|
|
|
|
data, err := ioutil.ReadFile(fname)
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
|
|
t.Logf("LOG FILE: %v %v", fname, trimOutput(string(data)))
|
|
}
|
|
|
|
// RemoveDefaultPassword prevents KOPIA_PASSWORD from being passed to kopia.
|
|
func (e *CLITest) RemoveDefaultPassword() {
|
|
var newEnv []string
|
|
|
|
for _, s := range e.Environment {
|
|
if !strings.HasPrefix(s, "KOPIA_PASSWORD=") {
|
|
newEnv = append(newEnv, s)
|
|
}
|
|
}
|
|
|
|
e.Environment = newEnv
|
|
}
|
|
|
|
// RunAndExpectSuccess runs the given command, expects it to succeed and returns its output lines.
|
|
func (e *CLITest) RunAndExpectSuccess(t *testing.T, args ...string) []string {
|
|
t.Helper()
|
|
|
|
stdout, _, err := e.Run(t, false, args...)
|
|
if err != nil {
|
|
t.Fatalf("'kopia %v' failed with %v", strings.Join(args, " "), err)
|
|
}
|
|
|
|
return stdout
|
|
}
|
|
|
|
// RunAndProcessStderr runs the given command, and streams its output line-by-line to a given function until it returns false.
|
|
func (e *CLITest) RunAndProcessStderr(t *testing.T, callback func(line string) bool, args ...string) *exec.Cmd {
|
|
t.Helper()
|
|
|
|
c := exec.Command(e.Exe, e.cmdArgs(args)...)
|
|
c.Env = append(os.Environ(), e.Environment...)
|
|
t.Logf("running '%v %v'", c.Path, c.Args)
|
|
|
|
stderrPipe, err := c.StderrPipe()
|
|
if err != nil {
|
|
t.Fatalf("can't set up stderr pipe reader")
|
|
}
|
|
|
|
if err := c.Start(); err != nil {
|
|
t.Fatalf("unable to start")
|
|
}
|
|
|
|
scanner := bufio.NewScanner(stderrPipe)
|
|
for scanner.Scan() {
|
|
if !callback(scanner.Text()) {
|
|
break
|
|
}
|
|
}
|
|
|
|
// complete the scan in background without processing lines.
|
|
go func() {
|
|
for scanner.Scan() {
|
|
t.Logf("[stderr] %v", scanner.Text())
|
|
}
|
|
}()
|
|
|
|
return c
|
|
}
|
|
|
|
// RunAndExpectSuccessWithErrOut runs the given command, expects it to succeed and returns its stdout and stderr lines.
|
|
func (e *CLITest) RunAndExpectSuccessWithErrOut(t *testing.T, args ...string) (stdout, stderr []string) {
|
|
t.Helper()
|
|
|
|
stdout, stderr, err := e.Run(t, false, args...)
|
|
if err != nil {
|
|
t.Fatalf("'kopia %v' failed with %v", strings.Join(args, " "), err)
|
|
}
|
|
|
|
return stdout, stderr
|
|
}
|
|
|
|
// RunAndExpectFailure runs the given command, expects it to fail and returns its output lines.
|
|
func (e *CLITest) RunAndExpectFailure(t *testing.T, args ...string) []string {
|
|
t.Helper()
|
|
|
|
stdout, _, err := e.Run(t, true, args...)
|
|
if err == nil {
|
|
t.Fatalf("'kopia %v' succeeded, but expected failure", strings.Join(args, " "))
|
|
}
|
|
|
|
return stdout
|
|
}
|
|
|
|
// RunAndVerifyOutputLineCount runs the given command and asserts it returns the given number of output lines, then returns them.
|
|
func (e *CLITest) RunAndVerifyOutputLineCount(t *testing.T, wantLines int, args ...string) []string {
|
|
t.Helper()
|
|
|
|
lines := e.RunAndExpectSuccess(t, args...)
|
|
if len(lines) != wantLines {
|
|
t.Errorf("unexpected list of results of 'kopia %v': %v (%v lines), wanted %v", strings.Join(args, " "), lines, len(lines), wantLines)
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
func (e *CLITest) cmdArgs(args []string) []string {
|
|
var suffix []string
|
|
|
|
// detect repository creation and override DefaultRepositoryCreateFlags for best
|
|
// performance on the current platform.
|
|
if len(args) >= 2 && (args[0] == "repo" && args[1] == "create") {
|
|
suffix = e.DefaultRepositoryCreateFlags
|
|
}
|
|
|
|
return append(append(append([]string(nil), e.fixedArgs...), args...), suffix...)
|
|
}
|
|
|
|
// Run executes kopia with given arguments and returns the output lines.
|
|
func (e *CLITest) Run(t *testing.T, expectedError bool, args ...string) (stdout, stderr []string, err error) {
|
|
t.Helper()
|
|
|
|
c := exec.Command(e.Exe, e.cmdArgs(args)...)
|
|
c.Env = append(os.Environ(), e.Environment...)
|
|
|
|
t.Logf("running '%v %v'", c.Path, c.Args)
|
|
|
|
errOut := &bytes.Buffer{}
|
|
c.Stderr = errOut
|
|
|
|
if e.PassthroughStderr {
|
|
c.Stderr = os.Stderr
|
|
}
|
|
|
|
o, err := c.Output()
|
|
|
|
if err != nil && !expectedError {
|
|
t.Logf("finished 'kopia %v' with err=%v (expected=%v) and output:\n%v\nstderr:\n%v\n", strings.Join(args, " "), err, expectedError, trimOutput(string(o)), trimOutput(errOut.String()))
|
|
}
|
|
|
|
return splitLines(string(o)), splitLines(errOut.String()), err
|
|
}
|
|
|
|
func trimOutput(s string) string {
|
|
lines := splitLines(s)
|
|
if len(lines) <= maxOutputLinesToLog {
|
|
return s
|
|
}
|
|
|
|
lines2 := append([]string(nil), lines[0:(maxOutputLinesToLog/2)]...)
|
|
lines2 = append(lines2, fmt.Sprintf("/* %v lines removed */", len(lines)-maxOutputLinesToLog))
|
|
lines2 = append(lines2, lines[len(lines)-(maxOutputLinesToLog/2):]...)
|
|
|
|
return strings.Join(lines2, "\n")
|
|
}
|
|
|
|
// ListSnapshotsAndExpectSuccess lists given snapshots and parses the output.
|
|
func (e *CLITest) ListSnapshotsAndExpectSuccess(t *testing.T, targets ...string) []SourceInfo {
|
|
t.Helper()
|
|
|
|
lines := e.RunAndExpectSuccess(t, append([]string{"snapshot", "list", "-l", "--manifest-id"}, targets...)...)
|
|
|
|
return mustParseSnapshots(t, lines)
|
|
}
|
|
|
|
// DirEntry represents directory entry.
|
|
type DirEntry struct {
|
|
Name string
|
|
ObjectID string
|
|
}
|
|
|
|
// ListDirectory lists a given directory and returns directory entries.
|
|
func (e *CLITest) ListDirectory(t *testing.T, targets ...string) []DirEntry {
|
|
t.Helper()
|
|
|
|
lines := e.RunAndExpectSuccess(t, append([]string{"ls", "-l"}, targets...)...)
|
|
|
|
return mustParseDirectoryEntries(lines)
|
|
}
|
|
|
|
// ListDirectoryRecursive lists a given directory recursively and returns directory entries.
|
|
func (e *CLITest) ListDirectoryRecursive(t *testing.T, targets ...string) []DirEntry {
|
|
t.Helper()
|
|
|
|
lines := e.RunAndExpectSuccess(t, append([]string{"ls", "-lr"}, targets...)...)
|
|
|
|
return mustParseDirectoryEntries(lines)
|
|
}
|
|
|
|
func mustParseDirectoryEntries(lines []string) []DirEntry {
|
|
var result []DirEntry
|
|
|
|
for _, l := range lines {
|
|
parts := strings.Fields(l)
|
|
|
|
result = append(result, DirEntry{
|
|
Name: parts[6],
|
|
ObjectID: parts[5],
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// DirectoryTreeOptions lists options for CreateDirectoryTree.
|
|
type DirectoryTreeOptions struct {
|
|
Depth int
|
|
MaxSubdirsPerDirectory int
|
|
MaxFilesPerDirectory int
|
|
MaxSymlinksPerDirectory int
|
|
MaxFileSize int
|
|
MinNameLength int
|
|
MaxNameLength int
|
|
NonExistingSymlinkTargetPercentage int // 0..100
|
|
}
|
|
|
|
// DirectoryTreeCounters stores stats about files and directories created by CreateDirectoryTree.
|
|
type DirectoryTreeCounters struct {
|
|
Files int
|
|
Directories int
|
|
Symlinks int
|
|
TotalFileSize int64
|
|
MaxFileSize int64
|
|
}
|
|
|
|
// MustCreateDirectoryTree creates a directory tree of a given depth with random files.
|
|
func MustCreateDirectoryTree(t *testing.T, dirname string, options DirectoryTreeOptions) {
|
|
t.Helper()
|
|
|
|
var counters DirectoryTreeCounters
|
|
if err := createDirectoryTreeInternal(dirname, options, &counters); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Logf("created directory tree %#v", counters)
|
|
}
|
|
|
|
// CreateDirectoryTree creates a directory tree of a given depth with random files.
|
|
func CreateDirectoryTree(dirname string, options DirectoryTreeOptions, counters *DirectoryTreeCounters) error {
|
|
if counters == nil {
|
|
counters = &DirectoryTreeCounters{}
|
|
}
|
|
|
|
return createDirectoryTreeInternal(dirname, options, counters)
|
|
}
|
|
|
|
// MustCreateRandomFile creates a new file at the provided path with randomized contents.
|
|
// It will fail with a test error if the creation does not succeed.
|
|
func MustCreateRandomFile(t *testing.T, filePath string, options DirectoryTreeOptions, counters *DirectoryTreeCounters) {
|
|
t.Helper()
|
|
|
|
if err := CreateRandomFile(filePath, options, counters); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// CreateRandomFile creates a new file at the provided path with randomized contents.
|
|
func CreateRandomFile(filePath string, options DirectoryTreeOptions, counters *DirectoryTreeCounters) error {
|
|
if counters == nil {
|
|
counters = &DirectoryTreeCounters{}
|
|
}
|
|
|
|
return createRandomFile(filePath, options, counters)
|
|
}
|
|
|
|
// createDirectoryTreeInternal creates a directory tree of a given depth with random files.
|
|
func createDirectoryTreeInternal(dirname string, options DirectoryTreeOptions, counters *DirectoryTreeCounters) error {
|
|
if err := os.MkdirAll(dirname, 0o700); err != nil {
|
|
return errors.Wrapf(err, "unable to create directory %v", dirname)
|
|
}
|
|
|
|
counters.Directories++
|
|
|
|
if options.Depth > 0 && options.MaxSubdirsPerDirectory > 0 {
|
|
childOptions := options
|
|
childOptions.Depth--
|
|
|
|
numSubDirs := rand.Intn(options.MaxSubdirsPerDirectory) + 1
|
|
for i := 0; i < numSubDirs; i++ {
|
|
subdirName := randomName(options)
|
|
|
|
if err := createDirectoryTreeInternal(filepath.Join(dirname, subdirName), childOptions, counters); err != nil {
|
|
return errors.Wrap(err, "unable to create subdirectory")
|
|
}
|
|
}
|
|
}
|
|
|
|
var fileNames []string
|
|
|
|
if options.MaxFilesPerDirectory > 0 {
|
|
numFiles := rand.Intn(options.MaxFilesPerDirectory) + 1
|
|
for i := 0; i < numFiles; i++ {
|
|
fileName := randomName(options)
|
|
|
|
if err := createRandomFile(filepath.Join(dirname, fileName), options, counters); err != nil {
|
|
return errors.Wrap(err, "unable to create random file")
|
|
}
|
|
|
|
fileNames = append(fileNames, fileName)
|
|
}
|
|
}
|
|
|
|
if options.MaxSymlinksPerDirectory > 0 {
|
|
numSymlinks := rand.Intn(options.MaxSymlinksPerDirectory) + 1
|
|
for i := 0; i < numSymlinks; i++ {
|
|
fileName := randomName(options)
|
|
|
|
if err := createRandomSymlink(filepath.Join(dirname, fileName), fileNames, options, counters); err != nil {
|
|
return errors.Wrap(err, "unable to create random symlink")
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func createRandomFile(filename string, options DirectoryTreeOptions, counters *DirectoryTreeCounters) error {
|
|
f, err := os.Create(filename)
|
|
if err != nil {
|
|
return errors.Wrap(err, "unable to create random file")
|
|
}
|
|
defer f.Close()
|
|
|
|
maxFileSize := int64(intOrDefault(options.MaxFileSize, 100000))
|
|
|
|
length := rand.Int63n(maxFileSize)
|
|
|
|
_, err = iocopy.Copy(f, io.LimitReader(rand.New(rand.NewSource(clock.Now().UnixNano())), length))
|
|
if err != nil {
|
|
return errors.Wrap(err, "file create error")
|
|
}
|
|
|
|
counters.Files++
|
|
counters.TotalFileSize += length
|
|
|
|
if length > counters.MaxFileSize {
|
|
counters.MaxFileSize = length
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func createRandomSymlink(filename string, existingFiles []string, options DirectoryTreeOptions, counters *DirectoryTreeCounters) error {
|
|
counters.Symlinks++
|
|
|
|
if len(existingFiles) == 0 || rand.Intn(100) < options.NonExistingSymlinkTargetPercentage {
|
|
return os.Symlink(randomName(options), filename)
|
|
}
|
|
|
|
return os.Symlink(existingFiles[rand.Intn(len(existingFiles))], filename)
|
|
}
|
|
|
|
func mustParseSnapshots(t *testing.T, lines []string) []SourceInfo {
|
|
t.Helper()
|
|
|
|
var (
|
|
result []SourceInfo
|
|
currentSource *SourceInfo
|
|
)
|
|
|
|
for _, l := range lines {
|
|
if l == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(l, " ") {
|
|
if currentSource == nil {
|
|
t.Errorf("snapshot without a source: %q", l)
|
|
return nil
|
|
}
|
|
|
|
currentSource.Snapshots = append(currentSource.Snapshots, mustParseSnaphotInfo(t, l[2:]))
|
|
|
|
continue
|
|
}
|
|
|
|
s := mustParseSourceInfo(t, l)
|
|
result = append(result, s)
|
|
currentSource = &result[len(result)-1]
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
var globalRandomNameCounter = new(int32)
|
|
|
|
func randomName(opt DirectoryTreeOptions) string {
|
|
maxNameLength := intOrDefault(opt.MaxNameLength, 15)
|
|
minNameLength := intOrDefault(opt.MinNameLength, 3)
|
|
|
|
l := rand.Intn(maxNameLength-minNameLength+1) + minNameLength
|
|
b := make([]byte, (l+1)/2)
|
|
|
|
cryptorand.Read(b)
|
|
|
|
return fmt.Sprintf("%v.%v", hex.EncodeToString(b)[:l], atomic.AddInt32(globalRandomNameCounter, 1))
|
|
}
|
|
|
|
func mustParseSnaphotInfo(t *testing.T, l string) SnapshotInfo {
|
|
t.Helper()
|
|
|
|
parts := strings.Split(l, " ")
|
|
|
|
ts, err := time.Parse("2006-01-02 15:04:05 MST", strings.Join(parts[0:3], " "))
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
|
|
manifestField := parts[7]
|
|
snapID := strings.TrimPrefix(manifestField, "manifest:")
|
|
|
|
return SnapshotInfo{
|
|
Time: ts,
|
|
ObjectID: parts[3],
|
|
SnapshotID: snapID,
|
|
}
|
|
}
|
|
|
|
func mustParseSourceInfo(t *testing.T, l string) SourceInfo {
|
|
t.Helper()
|
|
|
|
p1 := strings.Index(l, "@")
|
|
|
|
p2 := strings.Index(l, ":")
|
|
|
|
if p1 >= 0 && p2 > p1 {
|
|
return SourceInfo{User: l[0:p1], Host: l[p1+1 : p2], Path: l[p2+1:]}
|
|
}
|
|
|
|
t.Fatalf("can't parse source info: %q", l)
|
|
|
|
return SourceInfo{}
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
|
|
var result []string
|
|
for _, l := range strings.Split(s, "\n") {
|
|
result = append(result, strings.TrimRight(l, "\r"))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// AssertNoError fails the test if a given error is not nil.
|
|
func AssertNoError(t *testing.T, err error) {
|
|
t.Helper()
|
|
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
}
|
|
|
|
// CheckNoError fails the test if a given error is not nil.
|
|
func CheckNoError(t *testing.T, err error) {
|
|
t.Helper()
|
|
|
|
if err != nil {
|
|
t.Errorf("err: %v", err)
|
|
}
|
|
}
|
|
|
|
func intOrDefault(a, b int) int {
|
|
if a > 0 {
|
|
return a
|
|
}
|
|
|
|
return b
|
|
}
|