Files
kopia/tests/testenv/cli_test_env.go
Jarek Kowalski 1a8fcb086c Added endurance test which tests kopia over long time scale (#558)
Globally replaced all use of time with internal 'clock' package
which provides indirection to time.Now()

Added support for faking clock in Kopia via KOPIA_FAKE_CLOCK_ENDPOINT

logfile: squelch annoying log message

testenv: added faketimeserver which serves time over HTTP

testing: added endurance test which tests kopia over long time scale

This creates kopia repository and simulates usage of Kopia over multiple
months (using accelerated fake time) to trigger effects that are only
visible after long time passage (maintenance, compactions, expirations).

The test is not used part of any test suite yet but will run in
post-submit mode only, preferably 24/7.

testing: refactored internal/clock to only support injection when
'testing' build tag is present
2020-08-26 23:03:46 -07:00

500 lines
12 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"
"testing"
"time"
"github.com/pkg/errors"
"github.com/kopia/kopia/internal/clock"
"github.com/kopia/kopia/internal/iocopy"
)
const (
repoPassword = "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
}
// 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()
}
RepoDir, err := ioutil.TempDir("", "kopia-repo")
if err != nil {
t.Fatalf("can't create temp directory: %v", err)
}
ConfigDir, err := ioutil.TempDir("", "kopia-config")
if err != nil {
t.Fatalf("can't create temp directory: %v", err)
}
fixedArgs := []string{
// use per-test config file, to avoid clobbering current user's setup.
"--config-file", filepath.Join(ConfigDir, ".kopia.config"),
}
// 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: RepoDir,
ConfigDir: ConfigDir,
Exe: filepath.FromSlash(exe),
fixedArgs: fixedArgs,
Environment: []string{"KOPIA_PASSWORD=" + repoPassword},
}
}
// Cleanup cleans up the test Environment unless the test has failed.
func (e *CLITest) Cleanup(t *testing.T) {
if t.Failed() {
t.Logf("skipped cleanup for failed test, examine repository: %v", e.RepoDir)
return
}
if e.RepoDir != "" {
os.RemoveAll(e.RepoDir)
}
if e.ConfigDir != "" {
os.RemoveAll(e.ConfigDir)
}
}
// 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
MaxFileSize int
MinNameLength int
MaxNameLength int
}
// DirectoryTreeCounters stores stats about files and directories created by CreateDirectoryTree.
type DirectoryTreeCounters struct {
Files int
Directories 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)
}
}
// 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")
}
}
}
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")
}
}
}
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 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
}