Repository: tensorchord/envd (language: python)

Issue:
feat(CLI): Support push command
## Description

Push the envd environment to the docker registry.
feasibility-research(doc): Remote build with buildkit or kaniko
## Description

We should investigate how to build envd images remotely with buildkit or kaniko. And we should document it.

- [ ] Add doc for the experience

CI/CD systems to support:

- [ ] GitHub Action
- [ ] GitLab CI

---
<!-- Issue Author: Don't delete this message to encourage other users to support your issue! -->
**Message from the maintainers**:

Love this enhancement proposal? Give it a 👍. We prioritise the proposals with the most 👍.



Discussion / hints:
After a deep dive into buildkit, I do not think the issue is easy to resolve. Thus I removed the tag `difficulty/newcomer`

We need to deal with docker and auth config.json in this issue. Besides this, the expoter `client.ExporterDocker` does not support push. Thus we may need to rely on docker to push.

But, in CI/CD scenario, docker may not be installed in the environment. Thus we have to rely on buildkit to push the images directly. Thus we may need to support two expoter entries in envd: ExporterDocker and ExporterImage.
Issue #510 relies on this issue, thus I increase the priority.
/assign
It is hard to support envd with kaniko since it does not support dockerfile/1.2.0 or 1.3.0 now. `RUN --mount=cache` is hard to run.

https://github.com/GoogleContainerTools/kaniko/issues/1712
Currently, we support context in #512 . In the next PR we should support kube-pod builder in the context.
We need to decide if we should use kubernetes client-go or controller-runtime client to create the buildkitd pod on Kubernetes.

I think the client in controller-runtime wraps client-go a lot and it should be easier to use.

WDYT @hezhizhen @knight42 
> I think the client in controller-runtime wraps client-go a lot and it should be easier to use.

@gaocegege  Yeah the client provided by `controller-runtime` is a general one and is more flexible(if we need to access some custom CRs, we only need to register theire scheme).

The only limitation came to my mind is that if we need some functionalities more than objects' CRUD, such as executing a command in container, streaming container logs, port-forwarding etc, then we could only use client-go.
Thanks for the explanation! forget that the controller-runtime client does not support sub-resources. I think we should use client-go directly.

Fix patch:
diff --git a/.gitignore b/.gitignore
index caaf9059f..07e462f1d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -64,3 +64,5 @@ share/python-wheels/
 .installed.cfg
 *.egg
 MANIFEST
+
+test.tar
diff --git a/go.mod b/go.mod
index 9ff7d72dd..160d784ac 100644
--- a/go.mod
+++ b/go.mod
@@ -53,6 +53,7 @@ require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/docker/cli v20.10.13+incompatible // indirect
 	github.com/docker/distribution v2.8.1+incompatible // indirect
+	github.com/docker/docker-credential-helpers v0.6.4 // indirect
 	github.com/emirpasic/gods v1.12.0 // indirect
 	github.com/fsnotify/fsnotify v1.5.4 // indirect
 	github.com/getsentry/sentry-go v0.12.0 // indirect
diff --git a/go.sum b/go.sum
index 85c44566c..abe9e8e67 100644
--- a/go.sum
+++ b/go.sum
@@ -137,6 +137,7 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
 github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
 github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -150,6 +151,8 @@ github.com/docker/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6
 github.com/docker/distribution v2.8.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/docker v20.10.3-0.20220224222438-c78f6963a1c0+incompatible h1:Ptj2To+ezU/mCBUKdYXBQ2r3/2EJojAlOZrsgprF+is=
 github.com/docker/docker v20.10.3-0.20220224222438-c78f6963a1c0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker-credential-helpers v0.6.4 h1:axCks+yV+2MR3/kZhAmy07yC56WZ2Pwu/fKWtKuZB0o=
+github.com/docker/docker-credential-helpers v0.6.4/go.mod h1:ofX3UI0Gz1TteYBjtgs07O36Pyasyp66D2uKT7H8W1c=
 github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
 github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
 github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8=
diff --git a/pkg/app/build.go b/pkg/app/build.go
index 8667a71ee..a4e578c93 100644
--- a/pkg/app/build.go
+++ b/pkg/app/build.go
@@ -33,6 +33,12 @@ var CommandBuild = &cli.Command{
 	Name:    "build",
 	Aliases: []string{"b"},
 	Usage:   "Build the envd environment",
+	Description: `
+To build an image using build.envd:
+	$ envd build
+To build and push the image to a registry:
+	$ envd build --output type=image,name=docker.io/username/image,push=true
+`,
 	Flags: []cli.Flag{
 		&cli.StringFlag{
 			Name:        "tag",
@@ -59,11 +65,10 @@ var CommandBuild = &cli.Command{
 			Value:   sshconfig.GetPublicKey(),
 			Hidden:  true,
 		},
-		&cli.PathFlag{
+		&cli.StringFlag{
 			Name:    "output",
-			Usage:   "Output destination (format: type=tar,dest=path)",
+			Usage:   "Output destination (e.g. type=tar,dest=path,push=true)",
 			Aliases: []string{"o"},
-			Value:   "",
 		},
 	},
 
@@ -84,7 +89,7 @@ func build(clicontext *cli.Context) error {
 		return errors.New("file does not exist")
 	}
 
-	config := home.GetManager().ConfigFile()
+	cfg := home.GetManager().ConfigFile()
 
 	tag := clicontext.String("tag")
 	if tag == "" {
@@ -95,13 +100,17 @@ func build(clicontext *cli.Context) error {
 	logger := logrus.WithFields(logrus.Fields{
 		"build-context":         buildContext,
 		"build-file":            manifest,
-		"config":                config,
+		"config":                cfg,
 		"tag":                   tag,
 		flag.FlagBuildkitdImage: viper.GetString(flag.FlagBuildkitdImage),
 	})
-	logger.Debug("starting build command")
 	debug := clicontext.Bool("debug")
-	builder, err := builder.New(clicontext.Context, config, manifest, buildContext, tag, clicontext.Path("output"), debug)
+	output := clicontext.String("output")
+	logger.WithFields(logrus.Fields{
+		"output": output,
+	}).Debug("starting build command")
+	builder, err := builder.New(clicontext.Context, cfg,
+		manifest, buildContext, tag, output, debug)
 	if err != nil {
 		return errors.Wrap(err, "failed to create the builder")
 	}
diff --git a/pkg/app/up.go b/pkg/app/up.go
index a525f8729..e8a89980a 100644
--- a/pkg/app/up.go
+++ b/pkg/app/up.go
@@ -136,7 +136,9 @@ func up(clicontext *cli.Context) error {
 	})
 	logger.Debug("starting up command")
 	debug := clicontext.Bool("debug")
-	builder, err := builder.New(clicontext.Context, config, manifest, buildContext, tag, "", debug)
+	output := ""
+	builder, err := builder.New(clicontext.Context, config, manifest,
+		buildContext, tag, output, debug)
 	if err != nil {
 		return errors.Wrap(err, "failed to create the builder")
 	}
diff --git a/pkg/builder/builder.go b/pkg/builder/builder.go
index 79ce11a90..ca0321d05 100644
--- a/pkg/builder/builder.go
+++ b/pkg/builder/builder.go
@@ -22,6 +22,8 @@ import (
 	"github.com/cockroachdb/errors"
 	"github.com/moby/buildkit/client"
 	"github.com/moby/buildkit/client/llb"
+	"github.com/moby/buildkit/session"
+	"github.com/moby/buildkit/session/auth/authprovider"
 	"github.com/sirupsen/logrus"
 	"golang.org/x/sync/errgroup"
 
@@ -48,19 +50,33 @@ type generalBuilder struct {
 	progressMode     string
 	tag              string
 	buildContextDir  string
-	outputType       string
-	outputDest       string
+
+	entries []client.ExportEntry
 
 	logger *logrus.Entry
 	starlark.Interpreter
 	buildkitd.Client
 }
 
-func New(ctx context.Context, configFilePath, manifestFilePath, buildContextDir, tag, output string, debug bool) (Builder, error) {
-	outputType, outputDest, err := parseOutput(output)
+func New(ctx context.Context, configFilePath, manifestFilePath,
+	buildContextDir, tag string, output string, debug bool) (Builder, error) {
+	entries, err := parseOutput(output)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to parse output")
 	}
+
+	logrus.WithField("entry", entries).Debug("getting exporter entry")
+	// Build docker image by default
+	if len(entries) == 0 {
+		entries = []client.ExportEntry{
+			{
+				Type: client.ExporterDocker,
+			},
+		}
+	} else if len(entries) > 1 {
+		return nil, errors.New("only one output type is supported")
+	}
+
 	var mode string = "auto"
 	if debug {
 		mode = "plain"
@@ -69,12 +85,10 @@ func New(ctx context.Context, configFilePath, manifestFilePath, buildContextDir,
 	b := &generalBuilder{
 		manifestFilePath: manifestFilePath,
 		configFilePath:   configFilePath,
-		outputType:       outputType,
-		outputDest:       outputDest,
 		buildContextDir:  buildContextDir,
-		// TODO(gaocegege): Support other mode?
-		progressMode: mode,
-		tag:          tag,
+		entries:          entries,
+		progressMode:     mode,
+		tag:              tag,
 		logger: logrus.WithFields(logrus.Fields{
 			"tag": tag,
 		}),
@@ -171,39 +185,89 @@ func (b generalBuilder) build(ctx context.Context, def *llb.Definition, pw progr
 	// Create a pipe to load the image into the docker host.
 	pipeR, pipeW := io.Pipe()
 
-	eg.Go(func() error {
-		defer pipeW.Close()
-		_, err := b.Solve(ctx, def, client.SolveOpt{
-			Exports: []client.ExportEntry{
-				{
-					Type: client.ExporterDocker,
-					Attrs: map[string]string{
-						"name": b.tag,
-						// Ref https://github.com/r2d4/mockerfile/blob/140c6a912bbfdae220febe59ab535ef0acba0e1f/pkg/build/build.go#L65
-						"containerimage.config": labels,
+	for _, entry := range b.entries {
+		// Set up docker config auth.
+		attachable := []session.Attachable{authprovider.NewDockerAuthProvider(os.Stderr)}
+		switch entry.Type {
+		// Create default build.
+		case client.ExporterDocker:
+			eg.Go(func() error {
+				if entry.Attrs == nil {
+					entry = client.ExportEntry{
+						Type: client.ExporterDocker,
+						Attrs: map[string]string{
+							"name": b.tag,
+							// Ref https://github.com/r2d4/mockerfile/blob/140c6a912bbfdae220febe59ab535ef0acba0e1f/pkg/build/build.go#L65
+							"containerimage.config": labels,
+						},
+						Output: func(map[string]string) (io.WriteCloser, error) {
+							return pipeW, nil
+						},
+					}
+				}
+				defer pipeW.Close()
+				_, err := b.Solve(ctx, def, client.SolveOpt{
+					Exports: []client.ExportEntry{entry},
+					LocalDirs: map[string]string{
+						flag.FlagContextDir: b.buildContextDir,
+						flag.FlagCacheDir:   home.GetManager().CacheDir(),
 					},
-					Output: func(map[string]string) (io.WriteCloser, error) {
-						return pipeW, nil
+					Session: attachable,
+					// TODO(gaocegege): Use llb.WithProxy to implement it.
+					FrontendAttrs: map[string]string{
+						"build-arg:HTTPS_PROXY": os.Getenv("HTTPS_PROXY"),
 					},
-				},
-			},
-			LocalDirs: map[string]string{
-				flag.FlagContextDir: b.buildContextDir,
-				flag.FlagCacheDir:   home.GetManager().CacheDir(),
-			},
-			// TODO(gaocegege): Use llb.WithProxy to implement it.
-			FrontendAttrs: map[string]string{
-				"build-arg:HTTPS_PROXY": os.Getenv("HTTPS_PROXY"),
-			},
-		}, pw.Status())
-		if err != nil {
-			err = errors.Wrap(err, "failed to solve LLB")
-			b.logger.Error(err)
-			return err
+				}, pw.Status())
+
+				if err != nil {
+					err = errors.Wrap(err, "failed to solve LLB")
+					b.logger.Error(err)
+					return err
+				}
+				b.logger.Debug("llb def is solved successfully")
+				return nil
+			})
+			// Load the image to docker host.
+			eg.Go(func() error {
+				defer pipeR.Close()
+				dockerClient, err := docker.NewClient(ctx)
+				if err != nil {
+					return errors.Wrap(err, "failed to new docker client")
+				}
+				b.logger.Debug("loading image to docker host")
+				if err := dockerClient.Load(ctx, pipeR, true); err != nil {
+					err = errors.Wrap(err, "failed to load docker image")
+					b.logger.Error(err)
+					return err
+				}
+				b.logger.Debug("loaded docker image successfully")
+				return nil
+			})
+		default:
+			eg.Go(func() error {
+				_, err := b.Solve(ctx, def, client.SolveOpt{
+					Exports: []client.ExportEntry{entry},
+					LocalDirs: map[string]string{
+						flag.FlagContextDir: b.buildContextDir,
+						flag.FlagCacheDir:   home.GetManager().CacheDir(),
+					},
+					Session: attachable,
+					// TODO(gaocegege): Use llb.WithProxy to implement it.
+					FrontendAttrs: map[string]string{
+						"build-arg:HTTPS_PROXY": os.Getenv("HTTPS_PROXY"),
+					},
+				}, pw.Status())
+
+				if err != nil {
+					err = errors.Wrap(err, "failed to solve LLB")
+					b.logger.Error(err)
+					return err
+				}
+				b.logger.Debug("llb def is solved successfully")
+				return nil
+			})
 		}
-		b.logger.Debug("llb def is solved successfully")
-		return nil
-	})
+	}
 
 	// Watch the progress.
 	eg.Go(func() error {
@@ -212,45 +276,6 @@ func (b generalBuilder) build(ctx context.Context, def *llb.Definition, pw progr
 		return pw.Err()
 	})
 
-	if b.outputDest != "" {
-		// Save the image to the output file.
-		eg.Go(func() error {
-			defer pipeR.Close()
-			f, err := os.Create(b.outputDest)
-			if err != nil {
-				return err
-			}
-
-			defer f.Close()
-			_, err = io.Copy(f, pipeR)
-			if err != nil {
-				return err
-			}
-
-			b.logger.Debug("export the image successfully")
-			return nil
-		})
-	}
-
-	if b.outputDest == "" {
-		// Load the image to docker host.
-		eg.Go(func() error {
-			defer pipeR.Close()
-			dockerClient, err := docker.NewClient(ctx)
-			if err != nil {
-				return errors.Wrap(err, "failed to new docker client")
-			}
-			b.logger.Debug("loading image to docker host")
-			if err := dockerClient.Load(ctx, pipeR, true); err != nil {
-				err = errors.Wrap(err, "failed to load docker image")
-				b.logger.Error(err)
-				return err
-			}
-			b.logger.Debug("loaded docker image successfully")
-			return nil
-		})
-	}
-
 	err = eg.Wait()
 	if err != nil {
 		if errors.Is(err, context.Canceled) {
diff --git a/pkg/builder/util.go b/pkg/builder/util.go
index cdaabba06..937400e0c 100644
--- a/pkg/builder/util.go
+++ b/pkg/builder/util.go
@@ -15,13 +15,18 @@
 package builder
 
 import (
+	"encoding/csv"
 	"encoding/json"
-	"regexp"
+	"io"
+	"os"
 	"strings"
 
 	"github.com/cockroachdb/errors"
+	"github.com/containerd/console"
 	"github.com/containerd/containerd/platforms"
+	"github.com/moby/buildkit/client"
 	v1 "github.com/opencontainers/image-spec/specs-go/v1"
+	"github.com/sirupsen/logrus"
 )
 
 func ImageConfigStr(labels map[string]string) (string, error) {
@@ -63,33 +68,104 @@ func DefaultPathEnv(os string) string {
 	return DefaultPathEnvUnix
 }
 
-// parseOutput parses the output string and returns the output type and destination.
-func parseOutput(output string) (string, string, error) {
-	if output == "" {
-		return "", "", nil
+// parseOutput parses --output
+// Refer to https://github.com/moby/buildkit/blob/master/cmd/buildctl/build/output.go#L56
+func parseOutput(exports string) ([]client.ExportEntry, error) {
+	var entries []client.ExportEntry
+	if exports == "" {
+		return entries, nil
 	}
 
-	// Example: type=tar,dest=path
-	matched, err := regexp.Match(`^type=[\s\S]+,dest=[\s\S]+$`, []byte(output))
+	e, err := parseOutputCSV(exports)
 	if err != nil {
-		return "", "", errors.Errorf("failed to match output: %v", err)
-	}
-	if !matched {
-		return "", "", errors.Errorf("unsupported format: %s", output)
+		return nil, err
 	}
+	entries = append(entries, e)
+	return entries, nil
+}
 
-	fields := strings.Split(output, ",")
-	outputMap := make(map[string]string, len(fields))
+// parseOutputCSV parses a single --output CSV string
+func parseOutputCSV(s string) (client.ExportEntry, error) {
+	ex := client.ExportEntry{
+		Type:  "",
+		Attrs: map[string]string{},
+	}
+	csvReader := csv.NewReader(strings.NewReader(s))
+	fields, err := csvReader.Read()
+	if err != nil {
+		return ex, err
+	}
 	for _, field := range fields {
-		pair := strings.Split(field, "=")
-		outputMap[pair[0]] = pair[1]
+		parts := strings.SplitN(field, "=", 2)
+		if len(parts) != 2 {
+			return ex, errors.Errorf("invalid value %s", field)
+		}
+		key := strings.ToLower(parts[0])
+		value := parts[1]
+		switch key {
+		case "type":
+			logrus.WithFields(logrus.Fields{
+				"type": value,
+			}).Debug("Adding type into exporter entry")
+			ex.Type = value
+		default:
+			logrus.WithFields(logrus.Fields{
+				"key":   key,
+				"value": value,
+			}).Debug("Adding key into exporter entry")
+			ex.Attrs[key] = value
+		}
 	}
-
-	outputType := outputMap["type"]
-	outputDest := outputMap["dest"]
-	if outputType != "tar" {
-		return "", "", errors.Errorf("unsupported output type: %s", outputType)
+	if ex.Type == "" {
+		return ex, errors.New("--output requires type=<type>")
+	}
+	if v, ok := ex.Attrs["output"]; ok {
+		return ex, errors.Errorf("output=%s not supported for --output, you meant dest=%s?", v, v)
 	}
+	ex.Output, ex.OutputDir, err = resolveExporterDest(ex.Type, ex.Attrs["dest"])
+	if err != nil {
+		return ex, errors.Wrap(err, "invalid output option: output")
+	}
+	if ex.Output != nil || ex.OutputDir != "" {
+		delete(ex.Attrs, "dest")
+	}
+	return ex, nil
+}
 
-	return outputType, outputDest, nil
+// resolveExporterDest returns at most either one of io.WriteCloser (single file) or a string (directory path).
+func resolveExporterDest(exporter, dest string) (func(map[string]string) (io.WriteCloser, error), string, error) {
+	wrapWriter := func(wc io.WriteCloser) func(map[string]string) (io.WriteCloser, error) {
+		return func(m map[string]string) (io.WriteCloser, error) {
+			return wc, nil
+		}
+	}
+	switch exporter {
+	case client.ExporterLocal:
+		if dest == "" {
+			return nil, "", errors.New("output directory is required for local exporter")
+		}
+		return nil, dest, nil
+	case client.ExporterOCI, client.ExporterDocker, client.ExporterTar:
+		if dest != "" && dest != "-" {
+			fi, err := os.Stat(dest)
+			if err != nil && !errors.Is(err, os.ErrNotExist) {
+				return nil, "", errors.Wrapf(err, "invalid destination file: %s", dest)
+			}
+			if err == nil && fi.IsDir() {
+				return nil, "", errors.Errorf("destination file is a directory")
+			}
+			w, err := os.Create(dest)
+			return wrapWriter(w), "", err
+		}
+		// if no output file is specified, use stdout
+		if _, err := console.ConsoleFromFile(os.Stdout); err == nil {
+			return nil, "", errors.Errorf("output file is required for %s exporter. refusing to write to console", exporter)
+		}
+		return wrapWriter(os.Stdout), "", nil
+	default: // e.g. client.ExporterImage
+		if dest != "" {
+			return nil, "", errors.Errorf("output %s is not supported by %s exporter", dest, exporter)
+		}
+		return nil, "", nil
+	}
 }


Test patch:
diff --git a/pkg/builder/builder_test.go b/pkg/builder/builder_test.go
index e812eb8de..0affeaf12 100644
--- a/pkg/builder/builder_test.go
+++ b/pkg/builder/builder_test.go
@@ -37,20 +37,13 @@ import (
 
 var _ = Describe("Builder", func() {
 	Describe("building image", Label("buildkitd"), func() {
-		var configFilePath, manifestFilePath, buildContext, tag string
+		var configFilePath, manifestFilePath, tag string
 		BeforeEach(func() {
 			configFilePath = "config.envd"
 			manifestFilePath = "build.envd"
-			buildContext = "testdata"
 			tag = "envd-dev:test"
 			Expect(home.Initialize()).NotTo(HaveOccurred())
 		})
-		When("getting the wrong builtkitd address", func() {
-			It("should return an error", func() {
-				_, err := New(context.TODO(), configFilePath, manifestFilePath, buildContext, tag, "", false)
-				Expect(err).To(HaveOccurred())
-			})
-		})
 		When("building the manifest", func() {
 			var b *generalBuilder
 			var w compileui.Writer
diff --git a/pkg/builder/util_test.go b/pkg/builder/util_test.go
index 57fc51a29..049699c7d 100644
--- a/pkg/builder/util_test.go
+++ b/pkg/builder/util_test.go
@@ -13,7 +13,11 @@
 // limitations under the License.
 package builder
 
-import "testing"
+import (
+	"testing"
+
+	"github.com/sirupsen/logrus"
+)
 
 func Test_parseOutput(t *testing.T) {
 	type args struct {
@@ -21,18 +25,18 @@ func Test_parseOutput(t *testing.T) {
 	}
 
 	tests := []struct {
-		name       string
-		args       args
-		outputType string
-		outputDest string
-		wantErr    bool
+		name            string
+		args            args
+		outputType      string
+		expectedEntries int
+		wantErr         bool
 	}{{
 		"parsing output successfully",
 		args{
 			output: "type=tar,dest=test.tar",
 		},
 		"tar",
-		"test.tar",
+		1,
 		false,
 	}, {
 		"output without type",
@@ -40,38 +44,43 @@ func Test_parseOutput(t *testing.T) {
 			output: "type=,dest=test.tar",
 		},
 		"",
-		"",
+		0,
 		true,
 	}, {
 		"output without dest",
 		args{
 			output: "type=tar,dest=",
 		},
-		"",
-		"",
-		true,
+		"tar",
+		1,
+		false,
 	}, {
 		"no output",
 		args{
 			output: "",
 		},
 		"",
-		"",
+		0,
 		false,
 	}}
 
+	logrus.SetLevel(logrus.DebugLevel)
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got, got1, err := parseOutput(tt.args.output)
+			entries, err := parseOutput(tt.args.output)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("parseOutput() error = %v, wantErr %v", err, tt.wantErr)
 				return
 			}
-			if got != tt.outputType {
-				t.Errorf("parseOutput() outputType = %v, want %v", got, tt.outputType)
+			if len(entries) != tt.expectedEntries {
+				t.Errorf("parseOutput() expectedEntries = %v, got %v", tt.expectedEntries, len(entries))
+				return
+			}
+			if len(entries) == 0 {
+				return
 			}
-			if got1 != tt.outputDest {
-				t.Errorf("parseOutput() outputDest = %v, want %v", got1, tt.outputDest)
+			if entries[0].Type != tt.outputType {
+				t.Errorf("parseOutput() outputType = %s, want %s", entries[0].Type, tt.outputType)
 			}
 		})
 	}