mirror of
https://github.com/kopia/kopia.git
synced 2026-03-12 11:16:25 -04:00
* server: repro for zero-sized snapshot bug As described in https://kopia.discourse.group/t/kopia-0-7-0-not-backing-up-any-files-repro-needed/136/5 * server: fixed zero-sized snapshots after repository is connected via API The root cause was that source manager was inheriting HTTP call context which was immediately closed after the 'connect' RPC returned thus silently killing all uploads.
514 lines
13 KiB
Go
514 lines
13 KiB
Go
// Package testenv contains Environment for use in testing.
|
|
package testenv
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
cryptorand "crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/kopia/kopia/internal/clock"
|
|
"github.com/kopia/kopia/internal/iocopy"
|
|
)
|
|
|
|
const (
|
|
// TestRepoPassword is a password for repositories created in tests.
|
|
TestRepoPassword = "qWQPJ2hiiLgWRRCr"
|
|
|
|
maxOutputLinesToLog = 40
|
|
)
|
|
|
|
// CLITest encapsulates state for a CLI-based test.
|
|
type CLITest struct {
|
|
startTime time.Time
|
|
|
|
RepoDir string
|
|
ConfigDir string
|
|
Exe string
|
|
|
|
fixedArgs []string
|
|
Environment []string
|
|
|
|
PassthroughStderr bool
|
|
|
|
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 {
|
|
exe := os.Getenv("KOPIA_EXE")
|
|
if exe == "" {
|
|
// exe = "kopia"
|
|
t.Skip()
|
|
}
|
|
|
|
configDir := t.TempDir()
|
|
logsDir := t.TempDir()
|
|
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")
|
|
}
|
|
|
|
return &CLITest{
|
|
startTime: clock.Now(),
|
|
RepoDir: t.TempDir(),
|
|
ConfigDir: configDir,
|
|
Exe: filepath.FromSlash(exe),
|
|
fixedArgs: fixedArgs,
|
|
LogsDir: logsDir,
|
|
Environment: []string{
|
|
"KOPIA_PASSWORD=" + TestRepoPassword,
|
|
"KOPIA_ADVANCED_COMMANDS=enabled",
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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, 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()
|
|
|
|
t.Logf("running 'kopia %v'", strings.Join(args, " "))
|
|
cmdArgs := append(append([]string(nil), e.fixedArgs...), args...)
|
|
|
|
c := exec.Command(e.Exe, cmdArgs...)
|
|
c.Env = append(os.Environ(), e.Environment...)
|
|
|
|
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() {
|
|
}
|
|
}()
|
|
|
|
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, 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, 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
|
|
}
|
|
|
|
// Run executes kopia with given arguments and returns the output lines.
|
|
func (e *CLITest) Run(t *testing.T, args ...string) (stdout, stderr []string, err error) {
|
|
t.Helper()
|
|
t.Logf("running '%v %v'", e.Exe, strings.Join(args, " "))
|
|
cmdArgs := append(append([]string(nil), e.fixedArgs...), args...)
|
|
|
|
c := exec.Command(e.Exe, cmdArgs...)
|
|
c.Env = append(os.Environ(), e.Environment...)
|
|
|
|
errOut := &bytes.Buffer{}
|
|
c.Stderr = errOut
|
|
|
|
if e.PassthroughStderr {
|
|
c.Stderr = os.Stderr
|
|
}
|
|
|
|
o, err := c.Output()
|
|
|
|
t.Logf("finished 'kopia %v' with err=%v and output:\n%v\nstderr:\n%v\n", strings.Join(args, " "), err, 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 {
|
|
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 {
|
|
lines := e.RunAndExpectSuccess(t, append([]string{"ls", "-l"}, 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.Error(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) {
|
|
if err := CreateRandomFile(filePath, options, counters); err != nil {
|
|
t.Error(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 {
|
|
var result []SourceInfo
|
|
|
|
var 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
|
|
}
|
|
|
|
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 hex.EncodeToString(b)[:l]
|
|
}
|
|
|
|
func mustParseSnaphotInfo(t *testing.T, l string) SnapshotInfo {
|
|
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 {
|
|
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
|
|
}
|