Repository: openshift-pipelines/pipelines-as-code
Pull request #64 (2021-05-20 09:22:55)

Improve resolve command
Allow resolving a dir or a bunch of files with multiples -f
Allow skip some tasks inlining
Allow to generate name or not

The resolved template will be printed to stdout

command examples:

```
./bin/pipeline-as-code resolve -f .tekton/ --generateName --remoteTask=false -s=git-clone -s=buildah
```

will inline everything in .tekton not fetching from remote and skipping
the inlining of git-clone and buildah.

probably should be in it's own independent command so to to make easy to `go install` directly... maybe one day

Signed-off-by: Chmouel Boudjnah <chmouel@redhat.com>


Patch:
diff --git a/go.mod b/go.mod
index fd927bbc..b15b6ba6 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,6 @@ module github.com/openshift-pipelines/pipelines-as-code
 go 1.15
 
 require (
-	github.com/ghodss/yaml v1.0.0
 	github.com/google/go-cmp v0.5.4
 	github.com/google/go-github/v34 v34.0.0
 	github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc // indirect
@@ -16,7 +15,8 @@ require (
 	github.com/tektoncd/pipeline v0.23.0
 	go.uber.org/zap v1.16.0
 	golang.org/x/oauth2 v0.0.0-20210126194326-f9ce19ea3013
-	gotest.tools/v3 v3.0.2
+	gopkg.in/yaml.v2 v2.4.0
+	gotest.tools/v3 v3.0.3
 	k8s.io/api v0.19.7
 	k8s.io/apimachinery v0.19.7
 	k8s.io/client-go v0.19.7
diff --git a/go.sum b/go.sum
index 3fcf203d..5f2ecbad 100644
--- a/go.sum
+++ b/go.sum
@@ -1529,8 +1529,9 @@ gorm.io/gorm v1.20.2/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
 gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
 gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
-gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E=
 gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
+gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
+gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/pkg/cmd/resolve/resolve.go b/pkg/cmd/resolve/resolve.go
index d1d7e3d6..a5f64a49 100644
--- a/pkg/cmd/resolve/resolve.go
+++ b/pkg/cmd/resolve/resolve.go
@@ -4,7 +4,10 @@ import (
 	"context"
 	"fmt"
 	"io/ioutil"
+	"log"
 	"os"
+	"path/filepath"
+	"strings"
 
 	"github.com/openshift-pipelines/pipelines-as-code/pkg/cli"
 	"github.com/openshift-pipelines/pipelines-as-code/pkg/flags"
@@ -15,6 +18,13 @@ import (
 	"sigs.k8s.io/yaml"
 )
 
+var (
+	filenames    []string
+	skipInlining []string
+	generateName bool
+	remoteTask   bool
+)
+
 func Command(p cli.Params) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "resolve",
@@ -24,30 +34,86 @@ func Command(p cli.Params) *cobra.Command {
 			if err != nil {
 				return err
 			}
-			bytes, _ := ioutil.ReadAll(os.Stdin)
-			allTemplates := string(bytes)
-			// TODO: flags
-			allTemplates = pipelineascode.ReplacePlaceHoldersVariables(allTemplates, map[string]string{
-				"revision": "SHA",
-				"repo_url": "url",
-			})
-			ctx := context.Background()
-			runinfo := &webvcs.RunInfo{}
-			prun, err := resolve.Resolve(ctx, cs, runinfo, allTemplates, true)
-			if err != nil {
-				return err
-			}
-			// TODO multiples?
-			d, err := yaml.Marshal(prun[0])
-			if err != nil {
-				return err
-			}
-
-			fmt.Printf("---\n%s\n", d)
-			return nil
+			s, err := resolveFilenames(cs, filenames)
+			fmt.Println(s)
+			return err
 		},
 	}
+	cmd.Flags().StringSliceVarP(&filenames, "filename", "f", filenames,
+		"Filename, directory, or URL to files to use to create the resource")
+	cmd.Flags().StringSliceVarP(&skipInlining, "skip", "s", filenames,
+		"Which task to skip inlining")
+	cmd.Flags().BoolVar(&generateName, "generateName", false,
+		"Wether to switch name to a generateName on pipelinerun")
+	cmd.Flags().BoolVar(&remoteTask, "remoteTask", true,
+		"Wether parse annotation to fetch remote task")
 	flags.AddPacOptions(cmd)
 
 	return cmd
 }
+
+func resolveFilenames(cs *cli.Clients, filenames []string) (string, error) {
+	var ret string
+
+	allTemplates := enumerateFiles(filenames)
+	// TODO: flags
+	allTemplates = pipelineascode.ReplacePlaceHoldersVariables(allTemplates, map[string]string{
+		"revision": "SHA",
+		"repo_url": "url",
+	})
+	ctx := context.Background()
+	runinfo := &webvcs.RunInfo{}
+	ropt := &resolve.Opts{
+		GenerateName: generateName,
+		RemoteTasks:  remoteTask,
+		SkipInlining: skipInlining,
+	}
+	prun, err := resolve.Resolve(ctx, cs, runinfo, allTemplates, ropt)
+	if err != nil {
+		return "", err
+	}
+
+	for _, run := range prun {
+		d, err := yaml.Marshal(run)
+		if err != nil {
+			return "", err
+		}
+		ret += fmt.Sprintf("---\n%s\n", d)
+	}
+	return ret, nil
+}
+
+func appendYaml(filename string) string {
+	b, err := ioutil.ReadFile(filename)
+	if err != nil {
+		log.Fatal(err)
+	}
+	s := string(b)
+	if strings.HasPrefix(s, "---") {
+		return s
+	}
+	return fmt.Sprintf("---\n%s", s)
+}
+
+func enumerateFiles(filenames []string) string {
+	var yamlDoc string
+	for _, paths := range filenames {
+		if stat, err := os.Stat(paths); err == nil && !stat.IsDir() {
+			yamlDoc += appendYaml(paths)
+			continue
+		}
+
+		// walk dir getting all yamls
+		err := filepath.Walk(paths, func(path string, fi os.FileInfo, err error) error {
+			if filepath.Ext(path) == ".yaml" {
+				yamlDoc += appendYaml(path)
+			}
+			return nil
+		})
+		if err != nil {
+			log.Fatalf("Error enumerating files: %v", err)
+		}
+	}
+
+	return yamlDoc
+}
diff --git a/pkg/pipelineascode/pipelineascode.go b/pkg/pipelineascode/pipelineascode.go
index f7c31459..4fbfc673 100644
--- a/pkg/pipelineascode/pipelineascode.go
+++ b/pkg/pipelineascode/pipelineascode.go
@@ -157,8 +157,12 @@ func Run(ctx context.Context, cs *cli.Clients, k8int cli.KubeInteractionIntf, ru
 		"repo_url": runinfo.URL,
 	})
 
+	ropt := &resolve.Opts{
+		GenerateName: true,
+		RemoteTasks:  true,
+	}
 	// Merge everything (i.e: tasks/pipeline etc..) as a single pipelinerun
-	pipelineRuns, err := resolve.Resolve(ctx, cs, runinfo, allTemplates, true)
+	pipelineRuns, err := resolve.Resolve(ctx, cs, runinfo, allTemplates, ropt)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/resolve/resolve.go b/pkg/resolve/resolve.go
index b33b8a86..6dd761d9 100644
--- a/pkg/resolve/resolve.go
+++ b/pkg/resolve/resolve.go
@@ -31,7 +31,7 @@ func readTypes(cs *cli.Clients, data string) Types {
 
 		obj, _, err := decoder.Decode([]byte(doc), nil, nil)
 		if err != nil {
-			cs.Log.Info("Skipping document not looking like a kubernetes resources")
+			cs.Log.Infof("Skipping document not looking like a kubernetes resources: %v", err)
 			continue
 		}
 		switch o := obj.(type) {
@@ -67,11 +67,27 @@ func getPipelineByName(name string, tasks []*tektonv1beta1.Pipeline) (*tektonv1b
 	return &tektonv1beta1.Pipeline{}, fmt.Errorf("cannot find pipeline %s in input", name)
 }
 
+func skippingTask(taskName string, skippedTasks []string) bool {
+	for _, value := range skippedTasks {
+		if value == taskName {
+			return true
+		}
+	}
+	return false
+}
+
+type Opts struct {
+	GenerateName bool     // wether to GenerateName
+	RemoteTasks  bool     // wether to parse annotation to fetch tasks from remote
+	SkipInlining []string // task to skip inlining
+}
+
 // Resolve gets a large string which is a yaml multi documents containing
 // Pipeline/PipelineRuns/Tasks and resolve them inline as a single PipelineRun
 // generateName can be set as True to set the name as a generateName + "-" for
 // unique pipelinerun
-func Resolve(ctx context.Context, cs *cli.Clients, runinfo *webvcs.RunInfo, data string, generateName bool) ([]*tektonv1beta1.PipelineRun, error) {
+func Resolve(ctx context.Context, cs *cli.Clients, runinfo *webvcs.RunInfo, data string, ropt *Opts) (
+	[]*tektonv1beta1.PipelineRun, error) {
 	s := k8scheme.Scheme
 	if err := tektonv1beta1.AddToScheme(s); err != nil {
 		return []*tektonv1beta1.PipelineRun{}, err
@@ -84,7 +100,7 @@ func Resolve(ctx context.Context, cs *cli.Clients, runinfo *webvcs.RunInfo, data
 
 	// First resolve Annotations Tasks
 	for _, pipelinerun := range types.PipelineRuns {
-		if pipelinerun.GetObjectMeta().GetAnnotations() != nil {
+		if ropt.RemoteTasks && pipelinerun.GetObjectMeta().GetAnnotations() != nil {
 			rt := config.RemoteTasks{
 				Clients: cs,
 				Runinfo: runinfo,
@@ -102,7 +118,7 @@ func Resolve(ctx context.Context, cs *cli.Clients, runinfo *webvcs.RunInfo, data
 	for _, pipeline := range types.Pipelines {
 		var pipelineTasks []tektonv1beta1.PipelineTask
 		for _, task := range pipeline.Spec.Tasks {
-			if task.TaskRef != nil {
+			if task.TaskRef != nil && !skippingTask(task.TaskRef.Name, ropt.SkipInlining) {
 				taskResolved, err := getTaskByName(task.TaskRef.Name, types.Tasks)
 				if err != nil {
 					return []*tektonv1beta1.PipelineRun{}, err
@@ -120,7 +136,7 @@ func Resolve(ctx context.Context, cs *cli.Clients, runinfo *webvcs.RunInfo, data
 		if pipelinerun.Spec.PipelineSpec != nil {
 			var pipelineTasksResolve []tektonv1beta1.PipelineTask
 			for _, task := range pipelinerun.Spec.PipelineSpec.Tasks {
-				if task.TaskRef != nil {
+				if task.TaskRef != nil && !skippingTask(task.TaskRef.Name, ropt.SkipInlining) {
 					taskResolved, err := getTaskByName(task.TaskRef.Name, types.Tasks)
 					if err != nil {
 						return []*tektonv1beta1.PipelineRun{}, err
@@ -144,7 +160,7 @@ func Resolve(ctx context.Context, cs *cli.Clients, runinfo *webvcs.RunInfo, data
 
 		}
 		// Add a generateName based on name if we want it
-		if generateName && pipelinerun.ObjectMeta.GenerateName == "" {
+		if ropt.GenerateName && pipelinerun.ObjectMeta.GenerateName == "" {
 			pipelinerun.ObjectMeta.GenerateName = pipelinerun.ObjectMeta.Name + "-"
 			pipelinerun.ObjectMeta.Name = ""
 		}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 24281e02..46c9bc90 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -106,7 +106,6 @@ github.com/gdamore/tcell/terminfo/v/vt102
 github.com/gdamore/tcell/terminfo/v/vt220
 github.com/gdamore/tcell/terminfo/x/xterm
 # github.com/ghodss/yaml v1.0.0
-## explicit
 github.com/ghodss/yaml
 # github.com/go-logr/logr v0.4.0
 github.com/go-logr/logr
@@ -587,13 +586,17 @@ gopkg.in/evanphx/json-patch.v4
 # gopkg.in/inf.v0 v0.9.1
 gopkg.in/inf.v0
 # gopkg.in/yaml.v2 v2.4.0
+## explicit
 gopkg.in/yaml.v2
 # gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
 gopkg.in/yaml.v3
-# gotest.tools/v3 v3.0.2
+# gotest.tools/v3 v3.0.3
 ## explicit
 gotest.tools/v3/assert
 gotest.tools/v3/assert/cmp
+gotest.tools/v3/fs
+gotest.tools/v3/internal/assert
+gotest.tools/v3/internal/cleanup
 gotest.tools/v3/internal/difflib
 gotest.tools/v3/internal/format
 gotest.tools/v3/internal/source


Test patch:
diff --git a/pkg/cmd/resolve/resolve_test.go b/pkg/cmd/resolve/resolve_test.go
new file mode 100644
index 00000000..569e1d9b
--- /dev/null
+++ b/pkg/cmd/resolve/resolve_test.go
@@ -0,0 +1,73 @@
+package resolve
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/openshift-pipelines/pipelines-as-code/pkg/cli"
+	"go.uber.org/zap"
+	zapobserver "go.uber.org/zap/zaptest/observer"
+	"gotest.tools/v3/assert"
+	assertfs "gotest.tools/v3/fs"
+)
+
+var tmplSimpleNoPrefix = `
+apiVersion: tekton.dev/v1beta1
+kind: PipelineRun
+metadata:
+  name: test
+spec:
+  pipelineSpec:
+	tasks:
+	  - name: hello1
+		taskSpec:
+		  steps:
+			- name: hello-moto
+			  image: alpine:3.7
+			  script: "echo hello moto"
+`
+
+func TestResolveFilenames(t *testing.T) {
+	observer, _ := zapobserver.New(zap.InfoLevel)
+	fakelogger := zap.New(observer).Sugar()
+	cs := &cli.Clients{Log: fakelogger}
+
+	tmplSimpleWithPrefix := fmt.Sprintf("---\n%s", tmplSimpleNoPrefix)
+
+	tests := []struct {
+		name    string
+		tmpl    string
+		wantErr bool
+	}{
+		{
+			name:    "Resolve templates no prefix",
+			tmpl:    tmplSimpleNoPrefix,
+			wantErr: false,
+		},
+		{
+			name:    "Resolve templates with prefix",
+			tmpl:    tmplSimpleWithPrefix,
+			wantErr: false,
+		},
+		{
+			name:    "No pipelinerun",
+			tmpl:    `---\nfoo:bar`,
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			dir := assertfs.NewDir(t, "test-name",
+				assertfs.WithFile("file.yaml", strings.ReplaceAll(tt.tmpl, "\t", "    ")))
+			defer dir.Remove()
+			got, err := resolveFilenames(cs, []string{dir.Path()})
+			if (err != nil) != tt.wantErr {
+				t.Errorf("resolveFilenames() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			} else if !tt.wantErr {
+				assert.Assert(t, got != "")
+			}
+		})
+	}
+}
diff --git a/pkg/resolve/resolve_test.go b/pkg/resolve/resolve_test.go
index 43c74eb6..8562ba22 100644
--- a/pkg/resolve/resolve_test.go
+++ b/pkg/resolve/resolve_test.go
@@ -43,7 +43,11 @@ func readTDfile(t *testing.T, testname string, generateName bool) (*tektonv1beta
 		Log: logger,
 	}
 	runinfo := &webvcs.RunInfo{}
-	resolved, err := Resolve(ctx, cs, runinfo, string(data), generateName)
+	ropt := &Opts{
+		GenerateName: generateName,
+		RemoteTasks:  true,
+	}
+	resolved, err := Resolve(ctx, cs, runinfo, string(data), ropt)
 	if err != nil {
 		return &tektonv1beta1.PipelineRun{}, nil, err
 	}
diff --git a/vendor/gotest.tools/v3/assert/assert.go b/vendor/gotest.tools/v3/assert/assert.go
index e75d1f38..f3f805dd 100644
--- a/vendor/gotest.tools/v3/assert/assert.go
+++ b/vendor/gotest.tools/v3/assert/assert.go
@@ -49,7 +49,7 @@ The example below shows assert used with some common types.
 
 Comparisons
 
-Package http://gotest.tools/assert/cmp provides
+Package http://pkg.go.dev/gotest.tools/v3/assert/cmp provides
 many common comparisons. Additional comparisons can be written to compare
 values in other ways. See the example Assert (CustomComparison).
 
@@ -58,22 +58,16 @@ Automated migration from testify
 gty-migrate-from-testify is a command which translates Go source code from
 testify assertions to the assertions provided by this package.
 
-See http://gotest.tools/assert/cmd/gty-migrate-from-testify.
+See http://pkg.go.dev/gotest.tools/v3/assert/cmd/gty-migrate-from-testify.
 
 
 */
 package assert // import "gotest.tools/v3/assert"
 
 import (
-	"fmt"
-	"go/ast"
-	"go/token"
-	"reflect"
-
 	gocmp "github.com/google/go-cmp/cmp"
 	"gotest.tools/v3/assert/cmp"
-	"gotest.tools/v3/internal/format"
-	"gotest.tools/v3/internal/source"
+	"gotest.tools/v3/internal/assert"
 )
 
 // BoolOrComparison can be a bool, or cmp.Comparison. See Assert() for usage.
@@ -90,128 +84,6 @@ type helperT interface {
 	Helper()
 }
 
-const failureMessage = "assertion failed: "
-
-// nolint: gocyclo
-func assert(
-	t TestingT,
-	failer func(),
-	argSelector argSelector,
-	comparison BoolOrComparison,
-	msgAndArgs ...interface{},
-) bool {
-	if ht, ok := t.(helperT); ok {
-		ht.Helper()
-	}
-	var success bool
-	switch check := comparison.(type) {
-	case bool:
-		if check {
-			return true
-		}
-		logFailureFromBool(t, msgAndArgs...)
-
-	// Undocumented legacy comparison without Result type
-	case func() (success bool, message string):
-		success = runCompareFunc(t, check, msgAndArgs...)
-
-	case nil:
-		return true
-
-	case error:
-		msg := failureMsgFromError(check)
-		t.Log(format.WithCustomMessage(failureMessage+msg, msgAndArgs...))
-
-	case cmp.Comparison:
-		success = runComparison(t, argSelector, check, msgAndArgs...)
-
-	case func() cmp.Result:
-		success = runComparison(t, argSelector, check, msgAndArgs...)
-
-	default:
-		t.Log(fmt.Sprintf("invalid Comparison: %v (%T)", check, check))
-	}
-
-	if success {
-		return true
-	}
-	failer()
-	return false
-}
-
-func runCompareFunc(
-	t TestingT,
-	f func() (success bool, message string),
-	msgAndArgs ...interface{},
-) bool {
-	if ht, ok := t.(helperT); ok {
-		ht.Helper()
-	}
-	if success, message := f(); !success {
-		t.Log(format.WithCustomMessage(failureMessage+message, msgAndArgs...))
-		return false
-	}
-	return true
-}
-
-func logFailureFromBool(t TestingT, msgAndArgs ...interface{}) {
-	if ht, ok := t.(helperT); ok {
-		ht.Helper()
-	}
-	const stackIndex = 3 // Assert()/Check(), assert(), formatFailureFromBool()
-	const comparisonArgPos = 1
-	args, err := source.CallExprArgs(stackIndex)
-	if err != nil {
-		t.Log(err.Error())
-		return
-	}
-
-	msg, err := boolFailureMessage(args[comparisonArgPos])
-	if err != nil {
-		t.Log(err.Error())
-		msg = "expression is false"
-	}
-
-	t.Log(format.WithCustomMessage(failureMessage+msg, msgAndArgs...))
-}
-
-func failureMsgFromError(err error) string {
-	// Handle errors with non-nil types
-	v := reflect.ValueOf(err)
-	if v.Kind() == reflect.Ptr && v.IsNil() {
-		return fmt.Sprintf("error is not nil: error has type %T", err)
-	}
-	return "error is not nil: " + err.Error()
-}
-
-func boolFailureMessage(expr ast.Expr) (string, error) {
-	if binaryExpr, ok := expr.(*ast.BinaryExpr); ok && binaryExpr.Op == token.NEQ {
-		x, err := source.FormatNode(binaryExpr.X)
-		if err != nil {
-			return "", err
-		}
-		y, err := source.FormatNode(binaryExpr.Y)
-		if err != nil {
-			return "", err
-		}
-		return x + " is " + y, nil
-	}
-
-	if unaryExpr, ok := expr.(*ast.UnaryExpr); ok && unaryExpr.Op == token.NOT {
-		x, err := source.FormatNode(unaryExpr.X)
-		if err != nil {
-			return "", err
-		}
-		return x + " is true", nil
-	}
-
-	formatted, err := source.FormatNode(expr)
-	if err != nil {
-		return "", err
-	}
-	return "expression is false: " + formatted, nil
-}
-
 // Assert performs a comparison. If the comparison fails, the test is marked as
 // failed, a failure message is logged, and execution is stopped immediately.
 //
@@ -222,7 +94,7 @@ func boolFailureMessage(expr ast.Expr) (string, error) {
 //   cmp.Comparison
 // Uses cmp.Result.Success() to check for success of failure.
 // The comparison is responsible for producing a helpful failure message.
-// http://gotest.tools/assert/cmp provides many common comparisons.
+// http://pkg.go.dev/gotest.tools/v3/assert/cmp provides many common comparisons.
 //   error
 // A nil value is considered success.
 // A non-nil error is a failure, err.Error() is used as the failure message.
@@ -230,7 +102,9 @@ func Assert(t TestingT, comparison BoolOrComparison, msgAndArgs ...interface{})
 	if ht, ok := t.(helperT); ok {
 		ht.Helper()
 	}
-	assert(t, t.FailNow, argsFromComparisonCall, comparison, msgAndArgs...)
+	if !assert.Eval(t, assert.ArgsFromComparisonCall, comparison, msgAndArgs...) {
+		t.FailNow()
+	}
 }
 
 // Check performs a comparison. If the comparison fails the test is marked as
@@ -242,7 +116,11 @@ func Check(t TestingT, comparison BoolOrComparison, msgAndArgs ...interface{}) b
 	if ht, ok := t.(helperT); ok {
 		ht.Helper()
 	}
-	return assert(t, t.Fail, argsFromComparisonCall, comparison, msgAndArgs...)
+	if !assert.Eval(t, assert.ArgsFromComparisonCall, comparison, msgAndArgs...) {
+		t.Fail()
+		return false
+	}
+	return true
 }
 
 // NilError fails the test immediately if err is not nil.
@@ -251,7 +129,9 @@ func NilError(t TestingT, err error, msgAndArgs ...interface{}) {
 	if ht, ok := t.(helperT); ok {
 		ht.Helper()
 	}
-	assert(t, t.FailNow, argsAfterT, err, msgAndArgs...)
+	if !assert.Eval(t, assert.ArgsAfterT, err, msgAndArgs...) {
+		t.FailNow()
+	}
 }
 
 // Equal uses the == operator to assert two values are equal and fails the test
@@ -270,13 +150,15 @@ func Equal(t TestingT, x, y interface{}, msgAndArgs ...interface{}) {
 	if ht, ok := t.(helperT); ok {
 		ht.Helper()
 	}
-	assert(t, t.FailNow, argsAfterT, cmp.Equal(x, y), msgAndArgs...)
+	if !assert.Eval(t, assert.ArgsAfterT, cmp.Equal(x, y), msgAndArgs...) {
+		t.FailNow()
+	}
 }
 
 // DeepEqual uses google/go-cmp (https://godoc.org/github.com/google/go-cmp/cmp)
 // to assert two values are equal and fails the test if they are not equal.
 //
-// Package http://gotest.tools/assert/opt provides some additional
+// Package http://pkg.go.dev/gotest.tools/v3/assert/opt provides some additional
 // commonly used Options.
 //
 // This is equivalent to Assert(t, cmp.DeepEqual(x, y)).
@@ -284,7 +166,9 @@ func DeepEqual(t TestingT, x, y interface{}, opts ...gocmp.Option) {
 	if ht, ok := t.(helperT); ok {
 		ht.Helper()
 	}
-	assert(t, t.FailNow, argsAfterT, cmp.DeepEqual(x, y, opts...))
+	if !assert.Eval(t, assert.ArgsAfterT, cmp.DeepEqual(x, y, opts...)) {
+		t.FailNow()
+	}
 }
 
 // Error fails the test if err is nil, or the error message is not the expected
@@ -294,7 +178,9 @@ func Error(t TestingT, err error, message string, msgAndArgs ...interface{}) {
 	if ht, ok := t.(helperT); ok {
 		ht.Helper()
 	}
-	assert(t, t.FailNow, argsAfterT, cmp.Error(err, message), msgAndArgs...)
+	if !assert.Eval(t, assert.ArgsAfterT, cmp.Error(err, message), msgAndArgs...) {
+		t.FailNow()
+	}
 }
 
 // ErrorContains fails the test if err is nil, or the error message does not
@@ -304,7 +190,9 @@ func ErrorContains(t TestingT, err error, substring string, msgAndArgs ...interf
 	if ht, ok := t.(helperT); ok {
 		ht.Helper()
 	}
-	assert(t, t.FailNow, argsAfterT, cmp.ErrorContains(err, substring), msgAndArgs...)
+	if !assert.Eval(t, assert.ArgsAfterT, cmp.ErrorContains(err, substring), msgAndArgs...) {
+		t.FailNow()
+	}
 }
 
 // ErrorType fails the test if err is nil, or err is not the expected type.
@@ -325,5 +213,7 @@ func ErrorType(t TestingT, err error, expected interface{}, msgAndArgs ...interf
 	if ht, ok := t.(helperT); ok {
 		ht.Helper()
 	}
-	assert(t, t.FailNow, argsAfterT, cmp.ErrorType(err, expected), msgAndArgs...)
+	if !assert.Eval(t, assert.ArgsAfterT, cmp.ErrorType(err, expected), msgAndArgs...) {
+		t.FailNow()
+	}
 }
diff --git a/vendor/gotest.tools/v3/assert/cmp/compare.go b/vendor/gotest.tools/v3/assert/cmp/compare.go
index bb3b24f4..3c0e05ab 100644
--- a/vendor/gotest.tools/v3/assert/cmp/compare.go
+++ b/vendor/gotest.tools/v3/assert/cmp/compare.go
@@ -21,7 +21,7 @@ type Comparison func() Result
 // and succeeds if the values are equal.
 //
 // The comparison can be customized using comparison Options.
-// Package http://gotest.tools/assert/opt provides some additional
+// Package http://pkg.go.dev/gotest.tools/v3/assert/opt provides some additional
 // commonly used Options.
 func DeepEqual(x, y interface{}, opts ...cmp.Option) Comparison {
 	return func() (result Result) {
diff --git a/vendor/gotest.tools/v3/assert/cmp/result.go b/vendor/gotest.tools/v3/assert/cmp/result.go
index 385e1a08..3b48d9bf 100644
--- a/vendor/gotest.tools/v3/assert/cmp/result.go
+++ b/vendor/gotest.tools/v3/assert/cmp/result.go
@@ -52,13 +52,12 @@ func ResultFromError(err error) Result {
 }
 
 type templatedResult struct {
-	success  bool
 	template string
 	data     map[string]interface{}
 }
 
 func (r templatedResult) Success() bool {
-	return r.success
+	return false
 }
 
 func (r templatedResult) FailureMessage(args []ast.Expr) string {
diff --git a/vendor/gotest.tools/v3/fs/file.go b/vendor/gotest.tools/v3/fs/file.go
new file mode 100644
index 00000000..de5f4ec1
--- /dev/null
+++ b/vendor/gotest.tools/v3/fs/file.go
@@ -0,0 +1,115 @@
+/*Package fs provides tools for creating temporary files, and testing the
+contents and structure of a directory.
+*/
+package fs // import "gotest.tools/v3/fs"
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+
+	"gotest.tools/v3/assert"
+	"gotest.tools/v3/internal/cleanup"
+)
+
+// Path objects return their filesystem path. Path may be implemented by a
+// real filesystem object (such as File and Dir) or by a type which updates
+// entries in a Manifest.
+type Path interface {
+	Path() string
+	Remove()
+}
+
+var (
+	_ Path = &Dir{}
+	_ Path = &File{}
+)
+
+// File is a temporary file on the filesystem
+type File struct {
+	path string
+}
+
+type helperT interface {
+	Helper()
+}
+
+// NewFile creates a new file in a temporary directory using prefix as part of
+// the filename. The PathOps are applied to the before returning the File.
+//
+// When used with Go 1.14+ the file will be automatically removed when the test
+// ends, unless the TEST_NOCLEANUP env var is set to true.
+func NewFile(t assert.TestingT, prefix string, ops ...PathOp) *File {
+	if ht, ok := t.(helperT); ok {
+		ht.Helper()
+	}
+	tempfile, err := ioutil.TempFile("", cleanPrefix(prefix)+"-")
+	assert.NilError(t, err)
+
+	file := &File{path: tempfile.Name()}
+	cleanup.Cleanup(t, file.Remove)
+
+	assert.NilError(t, tempfile.Close())
+	assert.NilError(t, applyPathOps(file, ops))
+	return file
+}
+
+func cleanPrefix(prefix string) string {
+	// windows requires both / and \ are replaced
+	if runtime.GOOS == "windows" {
+		prefix = strings.Replace(prefix, string(os.PathSeparator), "-", -1)
+	}
+	return strings.Replace(prefix, "/", "-", -1)
+}
+
+// Path returns the full path to the file
+func (f *File) Path() string {
+	return f.path
+}
+
+// Remove the file
+func (f *File) Remove() {
+	// nolint: errcheck
+	os.Remove(f.path)
+}
+
+// Dir is a temporary directory
+type Dir struct {
+	path string
+}
+
+// NewDir returns a new temporary directory using prefix as part of the directory
+// name. The PathOps are applied before returning the Dir.
+//
+// When used with Go 1.14+ the directory will be automatically removed when the test
+// ends, unless the TEST_NOCLEANUP env var is set to true.
+func NewDir(t assert.TestingT, prefix string, ops ...PathOp) *Dir {
+	if ht, ok := t.(helperT); ok {
+		ht.Helper()
+	}
+	path, err := ioutil.TempDir("", cleanPrefix(prefix)+"-")
+	assert.NilError(t, err)
+	dir := &Dir{path: path}
+	cleanup.Cleanup(t, dir.Remove)
+
+	assert.NilError(t, applyPathOps(dir, ops))
+	return dir
+}
+
+// Path returns the full path to the directory
+func (d *Dir) Path() string {
+	return d.path
+}
+
+// Remove the directory
+func (d *Dir) Remove() {
+	// nolint: errcheck
+	os.RemoveAll(d.path)
+}
+
+// Join returns a new path with this directory as the base of the path
+func (d *Dir) Join(parts ...string) string {
+	return filepath.Join(append([]string{d.Path()}, parts...)...)
+}
diff --git a/vendor/gotest.tools/v3/fs/manifest.go b/vendor/gotest.tools/v3/fs/manifest.go
new file mode 100644
index 00000000..d3967563
--- /dev/null
+++ b/vendor/gotest.tools/v3/fs/manifest.go
@@ -0,0 +1,139 @@
+package fs
+
+import (
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
+	"github.com/pkg/errors"
+	"gotest.tools/v3/assert"
+)
+
+// Manifest stores the expected structure and properties of files and directories
+// in a filesystem.
+type Manifest struct {
+	root *directory
+}
+
+type resource struct {
+	mode os.FileMode
+	uid  uint32
+	gid  uint32
+}
+
+type file struct {
+	resource
+	content             io.ReadCloser
+	ignoreCariageReturn bool
+	compareContentFunc  func(b []byte) CompareResult
+}
+
+func (f *file) Type() string {
+	return "file"
+}
+
+type symlink struct {
+	resource
+	target string
+}
+
+func (f *symlink) Type() string {
+	return "symlink"
+}
+
+type directory struct {
+	resource
+	items         map[string]dirEntry
+	filepathGlobs map[string]*filePath
+}
+
+func (f *directory) Type() string {
+	return "directory"
+}
+
+type dirEntry interface {
+	Type() string
+}
+
+// ManifestFromDir creates a Manifest by reading the directory at path. The
+// manifest stores the structure and properties of files in the directory.
+// ManifestFromDir can be used with Equal to compare two directories.
+func ManifestFromDir(t assert.TestingT, path string) Manifest {
+	if ht, ok := t.(helperT); ok {
+		ht.Helper()
+	}
+
+	manifest, err := manifestFromDir(path)
+	assert.NilError(t, err)
+	return manifest
+}
+
+func manifestFromDir(path string) (Manifest, error) {
+	info, err := os.Stat(path)
+	switch {
+	case err != nil:
+		return Manifest{}, err
+	case !info.IsDir():
+		return Manifest{}, errors.Errorf("path %s must be a directory", path)
+	}
+
+	directory, err := newDirectory(path, info)
+	return Manifest{root: directory}, err
+}
+
+func newDirectory(path string, info os.FileInfo) (*directory, error) {
+	items := make(map[string]dirEntry)
+	children, err := ioutil.ReadDir(path)
+	if err != nil {
+		return nil, err
+	}
+	for _, child := range children {
+		fullPath := filepath.Join(path, child.Name())
+		items[child.Name()], err = getTypedResource(fullPath, child)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return &directory{
+		resource:      newResourceFromInfo(info),
+		items:         items,
+		filepathGlobs: make(map[string]*filePath),
+	}, nil
+}
+
+func getTypedResource(path string, info os.FileInfo) (dirEntry, error) {
+	switch {
+	case info.IsDir():
+		return newDirectory(path, info)
+	case info.Mode()&os.ModeSymlink != 0:
+		return newSymlink(path, info)
+	// TODO: devices, pipes?
+	default:
+		return newFile(path, info)
+	}
+}
+
+func newSymlink(path string, info os.FileInfo) (*symlink, error) {
+	target, err := os.Readlink(path)
+	if err != nil {
+		return nil, err
+	}
+	return &symlink{
+		resource: newResourceFromInfo(info),
+		target:   target,
+	}, err
+}
+
+func newFile(path string, info os.FileInfo) (*file, error) {
+	// TODO: defer file opening to reduce number of open FDs?
+	readCloser, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	return &file{
+		resource: newResourceFromInfo(info),
+		content:  readCloser,
+	}, err
+}
diff --git a/vendor/gotest.tools/v3/fs/manifest_unix.go b/vendor/gotest.tools/v3/fs/manifest_unix.go
new file mode 100644
index 00000000..bba2fcd9
--- /dev/null
+++ b/vendor/gotest.tools/v3/fs/manifest_unix.go
@@ -0,0 +1,30 @@
+// +build !windows
+
+package fs
+
+import (
+	"os"
+	"syscall"
+)
+
+const (
+	defaultRootDirMode = os.ModeDir | 0700
+	defaultSymlinkMode = os.ModeSymlink | 0777
+)
+
+func newResourceFromInfo(info os.FileInfo) resource {
+	statT := info.Sys().(*syscall.Stat_t)
+	return resource{
+		mode: info.Mode(),
+		uid:  statT.Uid,
+		gid:  statT.Gid,
+	}
+}
+
+func (p *filePath) SetMode(mode os.FileMode) {
+	p.file.mode = mode
+}
+
+func (p *directoryPath) SetMode(mode os.FileMode) {
+	p.directory.mode = mode | os.ModeDir
+}
diff --git a/vendor/gotest.tools/v3/fs/manifest_windows.go b/vendor/gotest.tools/v3/fs/manifest_windows.go
new file mode 100644
index 00000000..1c1a0936
--- /dev/null
+++ b/vendor/gotest.tools/v3/fs/manifest_windows.go
@@ -0,0 +1,22 @@
+package fs
+
+import "os"
+
+const (
+	defaultRootDirMode = os.ModeDir | 0777
+	defaultSymlinkMode = os.ModeSymlink | 0666
+)
+
+func newResourceFromInfo(info os.FileInfo) resource {
+	return resource{mode: info.Mode()}
+}
+
+func (p *filePath) SetMode(mode os.FileMode) {
+	bits := mode & 0600
+	p.file.mode = bits + bits/010 + bits/0100
+}
+
+// TODO: is mode ignored on windows?
+func (p *directoryPath) SetMode(mode os.FileMode) {
+	p.directory.mode = defaultRootDirMode
+}
diff --git a/vendor/gotest.tools/v3/fs/ops.go b/vendor/gotest.tools/v3/fs/ops.go
new file mode 100644
index 00000000..4589623f
--- /dev/null
+++ b/vendor/gotest.tools/v3/fs/ops.go
@@ -0,0 +1,275 @@
+package fs
+
+import (
+	"bytes"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/pkg/errors"
+	"gotest.tools/v3/assert"
+)
+
+const defaultFileMode = 0644
+
+// PathOp is a function which accepts a Path and performs an operation on that
+// path. When called with real filesystem objects (File or Dir) a PathOp modifies
+// the filesystem at the path. When used with a Manifest object a PathOp updates
+// the manifest to expect a value.
+type PathOp func(path Path) error
+
+type manifestResource interface {
+	SetMode(mode os.FileMode)
+	SetUID(uid uint32)
+	SetGID(gid uint32)
+}
+
+type manifestFile interface {
+	manifestResource
+	SetContent(content io.ReadCloser)
+}
+
+type manifestDirectory interface {
+	manifestResource
+	AddSymlink(path, target string) error
+	AddFile(path string, ops ...PathOp) error
+	AddDirectory(path string, ops ...PathOp) error
+}
+
+// WithContent writes content to a file at Path
+func WithContent(content string) PathOp {
+	return func(path Path) error {
+		if m, ok := path.(manifestFile); ok {
+			m.SetContent(ioutil.NopCloser(strings.NewReader(content)))
+			return nil
+		}
+		return ioutil.WriteFile(path.Path(), []byte(content), defaultFileMode)
+	}
+}
+
+// WithBytes write bytes to a file at Path
+func WithBytes(raw []byte) PathOp {
+	return func(path Path) error {
+		if m, ok := path.(manifestFile); ok {
+			m.SetContent(ioutil.NopCloser(bytes.NewReader(raw)))
+			return nil
+		}
+		return ioutil.WriteFile(path.Path(), raw, defaultFileMode)
+	}
+}
+
+// WithReaderContent copies the reader contents to the file at Path
+func WithReaderContent(r io.Reader) PathOp {
+	return func(path Path) error {
+		if m, ok := path.(manifestFile); ok {
+			m.SetContent(ioutil.NopCloser(r))
+			return nil
+		}
+		f, err := os.OpenFile(path.Path(), os.O_WRONLY, defaultFileMode)
+		if err != nil {
+			return err
+		}
+		defer f.Close()
+		_, err = io.Copy(f, r)
+		return err
+	}
+}
+
+// AsUser changes ownership of the file system object at Path
+func AsUser(uid, gid int) PathOp {
+	return func(path Path) error {
+		if m, ok := path.(manifestResource); ok {
+			m.SetUID(uint32(uid))
+			m.SetGID(uint32(gid))
+			return nil
+		}
+		return os.Chown(path.Path(), uid, gid)
+	}
+}
+
+// WithFile creates a file in the directory at path with content
+func WithFile(filename, content string, ops ...PathOp) PathOp {
+	return func(path Path) error {
+		if m, ok := path.(manifestDirectory); ok {
+			ops = append([]PathOp{WithContent(content), WithMode(defaultFileMode)}, ops...)
+			return m.AddFile(filename, ops...)
+		}
+
+		fullpath := filepath.Join(path.Path(), filepath.FromSlash(filename))
+		if err := createFile(fullpath, content); err != nil {
+			return err
+		}
+		return applyPathOps(&File{path: fullpath}, ops)
+	}
+}
+
+func createFile(fullpath string, content string) error {
+	return ioutil.WriteFile(fullpath, []byte(content), defaultFileMode)
+}
+
+// WithFiles creates all the files in the directory at path with their content
+func WithFiles(files map[string]string) PathOp {
+	return func(path Path) error {
+		if m, ok := path.(manifestDirectory); ok {
+			for filename, content := range files {
+				// TODO: remove duplication with WithFile
+				if err := m.AddFile(filename, WithContent(content), WithMode(defaultFileMode)); err != nil {
+					return err
+				}
+			}
+			return nil
+		}
+
+		for filename, content := range files {
+			fullpath := filepath.Join(path.Path(), filepath.FromSlash(filename))
+			if err := createFile(fullpath, content); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+}
+
+// FromDir copies the directory tree from the source path into the new Dir
+func FromDir(source string) PathOp {
+	return func(path Path) error {
+		if _, ok := path.(manifestDirectory); ok {
+			return errors.New("use manifest.FromDir")
+		}
+		return copyDirectory(source, path.Path())
+	}
+}
+
+// WithDir creates a subdirectory in the directory at path. Additional PathOp
+// can be used to modify the subdirectory
+func WithDir(name string, ops ...PathOp) PathOp {
+	const defaultMode = 0755
+	return func(path Path) error {
+		if m, ok := path.(manifestDirectory); ok {
+			ops = append([]PathOp{WithMode(defaultMode)}, ops...)
+			return m.AddDirectory(name, ops...)
+		}
+
+		fullpath := filepath.Join(path.Path(), filepath.FromSlash(name))
+		err := os.MkdirAll(fullpath, defaultMode)
+		if err != nil {
+			return err
+		}
+		return applyPathOps(&Dir{path: fullpath}, ops)
+	}
+}
+
+// Apply the PathOps to the File
+func Apply(t assert.TestingT, path Path, ops ...PathOp) {
+	if ht, ok := t.(helperT); ok {
+		ht.Helper()
+	}
+	assert.NilError(t, applyPathOps(path, ops))
+}
+
+func applyPathOps(path Path, ops []PathOp) error {
+	for _, op := range ops {
+		if err := op(path); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// WithMode sets the file mode on the directory or file at path
+func WithMode(mode os.FileMode) PathOp {
+	return func(path Path) error {
+		if m, ok := path.(manifestResource); ok {
+			m.SetMode(mode)
+			return nil
+		}
+		return os.Chmod(path.Path(), mode)
+	}
+}
+
+func copyDirectory(source, dest string) error {
+	entries, err := ioutil.ReadDir(source)
+	if err != nil {
+		return err
+	}
+	for _, entry := range entries {
+		sourcePath := filepath.Join(source, entry.Name())
+		destPath := filepath.Join(dest, entry.Name())
+		switch {
+		case entry.IsDir():
+			if err := os.Mkdir(destPath, 0755); err != nil {
+				return err
+			}
+			if err := copyDirectory(sourcePath, destPath); err != nil {
+				return err
+			}
+		case entry.Mode()&os.ModeSymlink != 0:
+			if err := copySymLink(sourcePath, destPath); err != nil {
+				return err
+			}
+		default:
+			if err := copyFile(sourcePath, destPath); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func copySymLink(source, dest string) error {
+	link, err := os.Readlink(source)
+	if err != nil {
+		return err
+	}
+	return os.Symlink(link, dest)
+}
+
+func copyFile(source, dest string) error {
+	content, err := ioutil.ReadFile(source)
+	if err != nil {
+		return err
+	}
+	return ioutil.WriteFile(dest, content, 0644)
+}
+
+// WithSymlink creates a symlink in the directory which links to target.
+// Target must be a path relative to the directory.
+//
+// Note: the argument order is the inverse of os.Symlink to be consistent with
+// the other functions in this package.
+func WithSymlink(path, target string) PathOp {
+	return func(root Path) error {
+		if v, ok := root.(manifestDirectory); ok {
+			return v.AddSymlink(path, target)
+		}
+		return os.Symlink(filepath.Join(root.Path(), target), filepath.Join(root.Path(), path))
+	}
+}
+
+// WithHardlink creates a link in the directory which links to target.
+// Target must be a path relative to the directory.
+//
+// Note: the argument order is the inverse of os.Link to be consistent with
+// the other functions in this package.
+func WithHardlink(path, target string) PathOp {
+	return func(root Path) error {
+		if _, ok := root.(manifestDirectory); ok {
+			return errors.New("WithHardlink not implemented for manifests")
+		}
+		return os.Link(filepath.Join(root.Path(), target), filepath.Join(root.Path(), path))
+	}
+}
+
+// WithTimestamps sets the access and modification times of the file system object
+// at path.
+func WithTimestamps(atime, mtime time.Time) PathOp {
+	return func(root Path) error {
+		if _, ok := root.(manifestDirectory); ok {
+			return errors.New("WithTimestamp not implemented for manifests")
+		}
+		return os.Chtimes(root.Path(), atime, mtime)
+	}
+}
diff --git a/vendor/gotest.tools/v3/fs/path.go b/vendor/gotest.tools/v3/fs/path.go
new file mode 100644
index 00000000..c301b904
--- /dev/null
+++ b/vendor/gotest.tools/v3/fs/path.go
@@ -0,0 +1,199 @@
+package fs
+
+import (
+	"bytes"
+	"io"
+	"io/ioutil"
+	"os"
+
+	"gotest.tools/v3/assert"
+)
+
+// resourcePath is an adaptor for resources so they can be used as a Path
+// with PathOps.
+type resourcePath struct{}
+
+func (p *resourcePath) Path() string {
+	return "manifest: not a filesystem path"
+}
+
+func (p *resourcePath) Remove() {}
+
+type filePath struct {
+	resourcePath
+	file *file
+}
+
+func (p *filePath) SetContent(content io.ReadCloser) {
+	p.file.content = content
+}
+
+func (p *filePath) SetUID(uid uint32) {
+	p.file.uid = uid
+}
+
+func (p *filePath) SetGID(gid uint32) {
+	p.file.gid = gid
+}
+
+type directoryPath struct {
+	resourcePath
+	directory *directory
+}
+
+func (p *directoryPath) SetUID(uid uint32) {
+	p.directory.uid = uid
+}
+
+func (p *directoryPath) SetGID(gid uint32) {
+	p.directory.gid = gid
+}
+
+func (p *directoryPath) AddSymlink(path, target string) error {
+	p.directory.items[path] = &symlink{
+		resource: newResource(defaultSymlinkMode),
+		target:   target,
+	}
+	return nil
+}
+
+func (p *directoryPath) AddFile(path string, ops ...PathOp) error {
+	newFile := &file{resource: newResource(0)}
+	p.directory.items[path] = newFile
+	exp := &filePath{file: newFile}
+	return applyPathOps(exp, ops)
+}
+
+func (p *directoryPath) AddGlobFiles(glob string, ops ...PathOp) error {
+	newFile := &file{resource: newResource(0)}
+	newFilePath := &filePath{file: newFile}
+	p.directory.filepathGlobs[glob] = newFilePath
+	return applyPathOps(newFilePath, ops)
+}
+
+func (p *directoryPath) AddDirectory(path string, ops ...PathOp) error {
+	newDir := newDirectoryWithDefaults()
+	p.directory.items[path] = newDir
+	exp := &directoryPath{directory: newDir}
+	return applyPathOps(exp, ops)
+}
+
+// Expected returns a Manifest with a directory structured created by ops. The
+// PathOp operations are applied to the manifest as expectations of the
+// filesystem structure and properties.
+func Expected(t assert.TestingT, ops ...PathOp) Manifest {
+	if ht, ok := t.(helperT); ok {
+		ht.Helper()
+	}
+
+	newDir := newDirectoryWithDefaults()
+	e := &directoryPath{directory: newDir}
+	assert.NilError(t, applyPathOps(e, ops))
+	return Manifest{root: newDir}
+}
+
+func newDirectoryWithDefaults() *directory {
+	return &directory{
+		resource:      newResource(defaultRootDirMode),
+		items:         make(map[string]dirEntry),
+		filepathGlobs: make(map[string]*filePath),
+	}
+}
+
+func newResource(mode os.FileMode) resource {
+	return resource{
+		mode: mode,
+		uid:  currentUID(),
+		gid:  currentGID(),
+	}
+}
+
+func currentUID() uint32 {
+	return normalizeID(os.Getuid())
+}
+
+func currentGID() uint32 {
+	return normalizeID(os.Getgid())
+}
+
+func normalizeID(id int) uint32 {
+	// ids will be -1 on windows
+	if id < 0 {
+		return 0
+	}
+	return uint32(id)
+}
+
+var anyFileContent = ioutil.NopCloser(bytes.NewReader(nil))
+
+// MatchAnyFileContent is a PathOp that updates a Manifest so that the file
+// at path may contain any content.
+func MatchAnyFileContent(path Path) error {
+	if m, ok := path.(*filePath); ok {
+		m.SetContent(anyFileContent)
+	}
+	return nil
+}
+
+// MatchContentIgnoreCarriageReturn is a PathOp that ignores cariage return
+// discrepancies.
+func MatchContentIgnoreCarriageReturn(path Path) error {
+	if m, ok := path.(*filePath); ok {
+		m.file.ignoreCariageReturn = true
+	}
+	return nil
+}
+
+const anyFile = "*"
+
+// MatchExtraFiles is a PathOp that updates a Manifest to allow a directory
+// to contain unspecified files.
+func MatchExtraFiles(path Path) error {
+	if m, ok := path.(*directoryPath); ok {
+		return m.AddFile(anyFile)
+	}
+	return nil
+}
+
+// CompareResult is the result of comparison.
+//
+// See gotest.tools/assert/cmp.StringResult for a convenient implementation of
+// this interface.
+type CompareResult interface {
+	Success() bool
+	FailureMessage() string
+}
+
+// MatchFileContent is a PathOp that updates a Manifest to use the provided
+// function to determine if a file's content matches the expectation.
+func MatchFileContent(f func([]byte) CompareResult) PathOp {
+	return func(path Path) error {
+		if m, ok := path.(*filePath); ok {
+			m.file.compareContentFunc = f
+		}
+		return nil
+	}
+}
+
+// MatchFilesWithGlob is a PathOp that updates a Manifest to match files using
+// glob pattern, and check them using the ops.
+func MatchFilesWithGlob(glob string, ops ...PathOp) PathOp {
+	return func(path Path) error {
+		if m, ok := path.(*directoryPath); ok {
+			return m.AddGlobFiles(glob, ops...)
+		}
+		return nil
+	}
+}
+
+// anyFileMode is represented by uint32_max
+const anyFileMode os.FileMode = 4294967295
+
+// MatchAnyFileMode is a PathOp that updates a Manifest so that the resource at path
+// will match any file mode.
+func MatchAnyFileMode(path Path) error {
+	if m, ok := path.(manifestResource); ok {
+		m.SetMode(anyFileMode)
+	}
+	return nil
+}
diff --git a/vendor/gotest.tools/v3/fs/report.go b/vendor/gotest.tools/v3/fs/report.go
new file mode 100644
index 00000000..0b2d73d9
--- /dev/null
+++ b/vendor/gotest.tools/v3/fs/report.go
@@ -0,0 +1,277 @@
+package fs
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"runtime"
+	"sort"
+	"strings"
+
+	"gotest.tools/v3/assert/cmp"
+	"gotest.tools/v3/internal/format"
+)
+
+// Equal compares a directory to the expected structured described by a manifest
+// and returns success if they match. If they do not match the failure message
+// will contain all the differences between the directory structure and the
+// expected structure defined by the Manifest.
+//
+// Equal is a cmp.Comparison which can be used with assert.Assert().
+func Equal(path string, expected Manifest) cmp.Comparison {
+	return func() cmp.Result {
+		actual, err := manifestFromDir(path)
+		if err != nil {
+			return cmp.ResultFromError(err)
+		}
+		failures := eqDirectory(string(os.PathSeparator), expected.root, actual.root)
+		if len(failures) == 0 {
+			return cmp.ResultSuccess
+		}
+		msg := fmt.Sprintf("directory %s does not match expected:\n", path)
+		return cmp.ResultFailure(msg + formatFailures(failures))
+	}
+}
+
+type failure struct {
+	path     string
+	problems []problem
+}
+
+type problem string
+
+func notEqual(property string, x, y interface{}) problem {
+	return problem(fmt.Sprintf("%s: expected %s got %s", property, x, y))
+}
+
+func errProblem(reason string, err error) problem {
+	return problem(fmt.Sprintf("%s: %s", reason, err))
+}
+
+func existenceProblem(filename, reason string, args ...interface{}) problem {
+	return problem(filename + ": " + fmt.Sprintf(reason, args...))
+}
+
+func eqResource(x, y resource) []problem {
+	var p []problem
+	if x.uid != y.uid {
+		p = append(p, notEqual("uid", x.uid, y.uid))
+	}
+	if x.gid != y.gid {
+		p = append(p, notEqual("gid", x.gid, y.gid))
+	}
+	if x.mode != anyFileMode && x.mode != y.mode {
+		p = append(p, notEqual("mode", x.mode, y.mode))
+	}
+	return p
+}
+
+func removeCarriageReturn(in []byte) []byte {
+	return bytes.Replace(in, []byte("\r\n"), []byte("\n"), -1)
+}
+
+// nolint: gocyclo
+func eqFile(x, y *file) []problem {
+	p := eqResource(x.resource, y.resource)
+
+	switch {
+	case x.content == nil:
+		p = append(p, existenceProblem("content", "expected content is nil"))
+		return p
+	case x.content == anyFileContent:
+		return p
+	case y.content == nil:
+		p = append(p, existenceProblem("content", "actual content is nil"))
+		return p
+	}
+
+	xContent, xErr := ioutil.ReadAll(x.content)
+	defer x.content.Close()
+	yContent, yErr := ioutil.ReadAll(y.content)
+	defer y.content.Close()
+
+	if xErr != nil {
+		p = append(p, errProblem("failed to read expected content", xErr))
+	}
+	if yErr != nil {
+		p = append(p, errProblem("failed to read actual content", xErr))
+	}
+	if xErr != nil || yErr != nil {
+		return p
+	}
+
+	if x.compareContentFunc != nil {
+		r := x.compareContentFunc(yContent)
+		if !r.Success() {
+			p = append(p, existenceProblem("content", r.FailureMessage()))
+		}
+		return p
+	}
+
+	if x.ignoreCariageReturn || y.ignoreCariageReturn {
+		xContent = removeCarriageReturn(xContent)
+		yContent = removeCarriageReturn(yContent)
+	}
+
+	if !bytes.Equal(xContent, yContent) {
+		p = append(p, diffContent(xContent, yContent))
+	}
+	return p
+}
+
+func diffContent(x, y []byte) problem {
+	diff := format.UnifiedDiff(format.DiffConfig{
+		A:    string(x),
+		B:    string(y),
+		From: "expected",
+		To:   "actual",
+	})
+	// Remove the trailing newline in the diff. A trailing newline is always
+	// added to a problem by formatFailures.
+	diff = strings.TrimSuffix(diff, "\n")
+	return problem("content:\n" + indent(diff, "    "))
+}
+
+func indent(s, prefix string) string {
+	buf := new(bytes.Buffer)
+	lines := strings.SplitAfter(s, "\n")
+	for _, line := range lines {
+		buf.WriteString(prefix + line)
+	}
+	return buf.String()
+}
+
+func eqSymlink(x, y *symlink) []problem {
+	p := eqResource(x.resource, y.resource)
+	xTarget := x.target
+	yTarget := y.target
+	if runtime.GOOS == "windows" {
+		xTarget = strings.ToLower(xTarget)
+		yTarget = strings.ToLower(yTarget)
+	}
+	if xTarget != yTarget {
+		p = append(p, notEqual("target", x.target, y.target))
+	}
+	return p
+}
+
+func eqDirectory(path string, x, y *directory) []failure {
+	p := eqResource(x.resource, y.resource)
+	var f []failure // nolint: prealloc
+	matchedFiles := make(map[string]bool)
+
+	for _, name := range sortedKeys(x.items) {
+		if name == anyFile {
+			continue
+		}
+		matchedFiles[name] = true
+		xEntry := x.items[name]
+		yEntry, ok := y.items[name]
+		if !ok {
+			p = append(p, existenceProblem(name, "expected %s to exist", xEntry.Type()))
+			continue
+		}
+
+		if xEntry.Type() != yEntry.Type() {
+			p = append(p, notEqual(name, xEntry.Type(), yEntry.Type()))
+			continue
+		}
+
+		f = append(f, eqEntry(filepath.Join(path, name), xEntry, yEntry)...)
+	}
+
+	if len(x.filepathGlobs) != 0 {
+		for _, name := range sortedKeys(y.items) {
+			m := matchGlob(name, y.items[name], x.filepathGlobs)
+			matchedFiles[name] = m.match
+			f = append(f, m.failures...)
+		}
+	}
+
+	if _, ok := x.items[anyFile]; ok {
+		return maybeAppendFailure(f, path, p)
+	}
+	for _, name := range sortedKeys(y.items) {
+		if !matchedFiles[name] {
+			p = append(p, existenceProblem(name, "unexpected %s", y.items[name].Type()))
+		}
+	}
+	return maybeAppendFailure(f, path, p)
+}
+
+func maybeAppendFailure(failures []failure, path string, problems []problem) []failure {
+	if len(problems) > 0 {
+		return append(failures, failure{path: path, problems: problems})
+	}
+	return failures
+}
+
+func sortedKeys(items map[string]dirEntry) []string {
+	keys := make([]string, 0, len(items))
+	for key := range items {
+		keys = append(keys, key)
+	}
+	sort.Strings(keys)
+	return keys
+}
+
+// eqEntry assumes x and y to be the same type
+func eqEntry(path string, x, y dirEntry) []failure {
+	resp := func(problems []problem) []failure {
+		if len(problems) == 0 {
+			return nil
+		}
+		return []failure{{path: path, problems: problems}}
+	}
+
+	switch typed := x.(type) {
+	case *file:
+		return resp(eqFile(typed, y.(*file)))
+	case *symlink:
+		return resp(eqSymlink(typed, y.(*symlink)))
+	case *directory:
+		return eqDirectory(path, typed, y.(*directory))
+	}
+	return nil
+}
+
+type globMatch struct {
+	match    bool
+	failures []failure
+}
+
+func matchGlob(name string, yEntry dirEntry, globs map[string]*filePath) globMatch {
+	m := globMatch{}
+
+	for glob, expectedFile := range globs {
+		ok, err := filepath.Match(glob, name)
+		if err != nil {
+			p := errProblem("failed to match glob pattern", err)
+			f := failure{path: name, problems: []problem{p}}
+			m.failures = append(m.failures, f)
+		}
+		if ok {
+			m.match = true
+			m.failures = eqEntry(name, expectedFile.file, yEntry)
+			return m
+		}
+	}
+	return m
+}
+
+func formatFailures(failures []failure) string {
+	sort.Slice(failures, func(i, j int) bool {
+		return failures[i].path < failures[j].path
+	})
+
+	buf := new(bytes.Buffer)
+	for _, failure := range failures {
+		buf.WriteString(failure.path + "\n")
+		for _, problem := range failure.problems {
+			buf.WriteString("  " + string(problem) + "\n")
+		}
+	}
+	return buf.String()
+}
diff --git a/vendor/gotest.tools/v3/internal/assert/assert.go b/vendor/gotest.tools/v3/internal/assert/assert.go
new file mode 100644
index 00000000..8dc01f4b
--- /dev/null
+++ b/vendor/gotest.tools/v3/internal/assert/assert.go
@@ -0,0 +1,143 @@
+package assert
+
+import (
+	"fmt"
+	"go/ast"
+	"go/token"
+	"reflect"
+
+	"gotest.tools/v3/assert/cmp"
+	"gotest.tools/v3/internal/format"
+	"gotest.tools/v3/internal/source"
+)
+
+// LogT is the subset of testing.T used by the assert package.
+type LogT interface {
+	Log(args ...interface{})
+}
+
+type helperT interface {
+	Helper()
+}
+
+const failureMessage = "assertion failed: "
+
+// Eval the comparison and print a failure messages if the comparison has failed.
+// nolint: gocyclo
+func Eval(
+	t LogT,
+	argSelector argSelector,
+	comparison interface{},
+	msgAndArgs ...interface{},
+) bool {
+	if ht, ok := t.(helperT); ok {
+		ht.Helper()
+	}
+	var success bool
+	switch check := comparison.(type) {
+	case bool:
+		if check {
+			return true
+		}
+		logFailureFromBool(t, msgAndArgs...)
+
+	// Undocumented legacy comparison without Result type
+	case func() (success bool, message string):
+		success = runCompareFunc(t, check, msgAndArgs...)
+
+	case nil:
+		return true
+
+	case error:
+		msg := failureMsgFromError(check)
+		t.Log(format.WithCustomMessage(failureMessage+msg, msgAndArgs...))
+
+	case cmp.Comparison:
+		success = RunComparison(t, argSelector, check, msgAndArgs...)
+
+	case func() cmp.Result:
+		success = RunComparison(t, argSelector, check, msgAndArgs...)
+
+	default:
+		t.Log(fmt.Sprintf("invalid Comparison: %v (%T)", check, check))
+	}
+	return success
+}
+
+func runCompareFunc(
+	t LogT,
+	f func() (success bool, message string),
+	msgAndArgs ...interface{},
+) bool {
+	if ht, ok := t.(helperT); ok {
+		ht.Helper()
+	}
+	if success, message := f(); !success {
+		t.Log(format.WithCustomMessage(failureMessage+message, msgAndArgs...))
+		return false
+	}
+	return true
+}
+
+func logFailureFromBool(t LogT, msgAndArgs ...interface{}) {
+	if ht, ok := t.(helperT); ok {
+		ht.Helper()
+	}
+	const stackIndex = 3 // Assert()/Check(), assert(), logFailureFromBool()
+	args, err := source.CallExprArgs(stackIndex)
+	if err != nil {
+		t.Log(err.Error())
+		return
+	}
+
+	const comparisonArgIndex = 1 // Assert(t, comparison)
+	if len(args) <= comparisonArgIndex {
+		t.Log(failureMessage + "but assert failed to find the expression to print")
+		return
+	}
+
+	msg, err := boolFailureMessage(args[comparisonArgIndex])
+	if err != nil {
+		t.Log(err.Error())
+		msg = "expression is false"
+	}
+
+	t.Log(format.WithCustomMessage(failureMessage+msg, msgAndArgs...))
+}
+
+func failureMsgFromError(err error) string {
+	// Handle errors with non-nil types
+	v := reflect.ValueOf(err)
+	if v.Kind() == reflect.Ptr && v.IsNil() {
+		return fmt.Sprintf("error is not nil: error has type %T", err)
+	}
+	return "error is not nil: " + err.Error()
+}
+
+func boolFailureMessage(expr ast.Expr) (string, error) {
+	if binaryExpr, ok := expr.(*ast.BinaryExpr); ok && binaryExpr.Op == token.NEQ {
+		x, err := source.FormatNode(binaryExpr.X)
+		if err != nil {
+			return "", err
+		}
+		y, err := source.FormatNode(binaryExpr.Y)
+		if err != nil {
+			return "", err
+		}
+		return x + " is " + y, nil
+	}
+
+	if unaryExpr, ok := expr.(*ast.UnaryExpr); ok && unaryExpr.Op == token.NOT {
+		x, err := source.FormatNode(unaryExpr.X)
+		if err != nil {
+			return "", err
+		}
+		return x + " is true", nil
+	}
+
+	formatted, err := source.FormatNode(expr)
+	if err != nil {
+		return "", err
+	}
+	return "expression is false: " + formatted, nil
+}
diff --git a/vendor/gotest.tools/v3/assert/result.go b/vendor/gotest.tools/v3/internal/assert/result.go
similarity index 69%
rename from vendor/gotest.tools/v3/assert/result.go
rename to vendor/gotest.tools/v3/internal/assert/result.go
index d77860b5..20cd5412 100644
--- a/vendor/gotest.tools/v3/assert/result.go
+++ b/vendor/gotest.tools/v3/internal/assert/result.go
@@ -9,8 +9,10 @@ import (
 	"gotest.tools/v3/internal/source"
 )
 
-func runComparison(
-	t TestingT,
+// RunComparison and return Comparison.Success. If the comparison fails a messages
+// will be printed using t.Log.
+func RunComparison(
+	t LogT,
 	argSelector argSelector,
 	f cmp.Comparison,
 	msgAndArgs ...interface{},
@@ -26,7 +28,7 @@ func runComparison(
 	var message string
 	switch typed := result.(type) {
 	case resultWithComparisonArgs:
-		const stackIndex = 3 // Assert/Check, assert, runComparison
+		const stackIndex = 3 // Assert/Check, assert, RunComparison
 		args, err := source.CallExprArgs(stackIndex)
 		if err != nil {
 			t.Log(err.Error())
@@ -88,15 +90,20 @@ func isShortPrintableExpr(expr ast.Expr) bool {
 
 type argSelector func([]ast.Expr) []ast.Expr
 
-func argsAfterT(args []ast.Expr) []ast.Expr {
+// ArgsAfterT selects args starting at position 1. Used when the caller has a
+// testing.T as the first argument, and the args to select should follow it.
+func ArgsAfterT(args []ast.Expr) []ast.Expr {
 	if len(args) < 1 {
 		return nil
 	}
 	return args[1:]
 }
 
-func argsFromComparisonCall(args []ast.Expr) []ast.Expr {
-	if len(args) < 1 {
+// ArgsFromComparisonCall selects args from the CallExpression at position 1.
+// Used when the caller has a testing.T as the first argument, and the args to
+// select are passed to the cmp.Comparison at position 1.
+func ArgsFromComparisonCall(args []ast.Expr) []ast.Expr {
+	if len(args) <= 1 {
 		return nil
 	}
 	if callExpr, ok := args[1].(*ast.CallExpr); ok {
@@ -104,3 +111,15 @@ func argsFromComparisonCall(args []ast.Expr) []ast.Expr {
 	}
 	return nil
 }
+
+// ArgsAtZeroIndex selects args from the CallExpression at position 1.
+// Used when the caller accepts a single cmp.Comparison argument.
+func ArgsAtZeroIndex(args []ast.Expr) []ast.Expr {
+	if len(args) == 0 {
+		return nil
+	}
+	if callExpr, ok := args[0].(*ast.CallExpr); ok {
+		return callExpr.Args
+	}
+	return nil
+}
diff --git a/vendor/gotest.tools/v3/internal/cleanup/cleanup.go b/vendor/gotest.tools/v3/internal/cleanup/cleanup.go
new file mode 100644
index 00000000..58206e57
--- /dev/null
+++ b/vendor/gotest.tools/v3/internal/cleanup/cleanup.go
@@ -0,0 +1,48 @@
+/*Package cleanup handles migration to and support for the Go 1.14+
+testing.TB.Cleanup() function.
+*/
+package cleanup
+
+import (
+	"os"
+	"strings"
+)
+
+type cleanupT interface {
+	Cleanup(f func())
+}
+
+// implemented by gotest.tools/x/subtest.TestContext
+type addCleanupT interface {
+	AddCleanup(f func())
+}
+
+type logT interface {
+	Log(...interface{})
+}
+
+type helperT interface {
+	Helper()
+}
+
+var noCleanup = strings.ToLower(os.Getenv("TEST_NOCLEANUP")) == "true"
+
+// Cleanup registers f as a cleanup function on t if any mechanisms are available.
+//
+// Skips registering f if TEST_NOCLEANUP is set to true.
+func Cleanup(t logT, f func()) {
+	if ht, ok := t.(helperT); ok {
+		ht.Helper()
+	}
+	if noCleanup {
+		t.Log("skipping cleanup because TEST_NOCLEANUP was enabled.")
+		return
+	}
+	if ct, ok := t.(cleanupT); ok {
+		ct.Cleanup(f)
+		return
+	}
+	if tc, ok := t.(addCleanupT); ok {
+		tc.AddCleanup(f)
+	}
+}
diff --git a/vendor/gotest.tools/v3/internal/source/source.go b/vendor/gotest.tools/v3/internal/source/source.go
index 134afb6b..c2eef033 100644
--- a/vendor/gotest.tools/v3/internal/source/source.go
+++ b/vendor/gotest.tools/v3/internal/source/source.go
@@ -93,8 +93,9 @@ func nodePosition(fileset *token.FileSet, node ast.Node) token.Position {
 }
 
 // GoVersionLessThan returns true if runtime.Version() is semantically less than
-// version 1.minor.
-func GoVersionLessThan(minor int64) bool {
+// version major.minor. Returns false if a release version can not be parsed from
+// runtime.Version().
+func GoVersionLessThan(major, minor int64) bool {
 	version := runtime.Version()
 	// not a release version
 	if !strings.HasPrefix(version, "go") {
@@ -105,11 +106,21 @@ func GoVersionLessThan(minor int64) bool {
 	if len(parts) < 2 {
 		return false
 	}
-	actual, err := strconv.ParseInt(parts[1], 10, 32)
-	return err == nil && parts[0] == "1" && actual < minor
+	rMajor, err := strconv.ParseInt(parts[0], 10, 32)
+	if err != nil {
+		return false
+	}
+	if rMajor != major {
+		return rMajor < major
+	}
+	rMinor, err := strconv.ParseInt(parts[1], 10, 32)
+	if err != nil {
+		return false
+	}
+	return rMinor < minor
 }
 
-var goVersionBefore19 = GoVersionLessThan(9)
+var goVersionBefore19 = GoVersionLessThan(1, 9)
 
 func getCallExprArgs(node ast.Node) ([]ast.Expr, error) {
 	visitor := &callExprVisitor{}