From 09d931a3e3982f2eaf2042cafcadd8ffdb749bdd Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 26 Nov 2019 19:55:29 +0100 Subject: [PATCH] Implement multi tag for build Signed-off-by: Ulysses Souza --- e2e/build_test.go | 37 ++++++++++ internal/commands/build/build.go | 93 +++++++++++++++++++------ internal/commands/build/compose.go | 2 +- internal/commands/build/compose_test.go | 2 +- 4 files changed, 109 insertions(+), 25 deletions(-) diff --git a/e2e/build_test.go b/e2e/build_test.go index 45e1cf363..7fae2b16f 100644 --- a/e2e/build_test.go +++ b/e2e/build_test.go @@ -58,6 +58,43 @@ func TestBuild(t *testing.T) { }) } +func TestBuildMultiTag(t *testing.T) { + runWithDindSwarmAndRegistry(t, func(info dindSwarmAndRegistryInfo) { + cmd := info.configuredCmd + tmp := fs.NewDir(t, "TestBuild") + testDir := path.Join("testdata", "build") + iidfile := tmp.Join("iidfile") + tags := []string{"1.0.0", "latest"} + cmd.Command = dockerCli.Command("app", "build", "--tag", "single:"+tags[0], "--tag", "single:"+tags[1], "--iidfile", iidfile, "-f", path.Join(testDir, "single.dockerapp"), testDir) + icmd.RunCmd(cmd).Assert(t, icmd.Success) + + cfg := getDockerConfigDir(t, cmd) + + for _, tag := range tags { + f := path.Join(cfg, "app", "bundles", "docker.io", "library", "single", "_tags", tag, relocated.BundleFilename) + bndl, err := relocated.BundleFromFile(f) + assert.NilError(t, err) + built := []string{bndl.InvocationImages[0].Digest, bndl.Images["web"].Digest, bndl.Images["worker"].Digest} + for _, ref := range built { + cmd.Command = dockerCli.Command("inspect", ref) + icmd.RunCmd(cmd).Assert(t, icmd.Success) + } + for _, img := range bndl.Images { + // Check all image not being built locally get a fixed reference + assert.Assert(t, img.Image == "" || strings.Contains(img.Image, "@sha256:")) + } + _, err = os.Stat(iidfile) + assert.NilError(t, err) + bytes, err := ioutil.ReadFile(iidfile) + assert.NilError(t, err) + iid := string(bytes) + actualID, err := store.FromBundle(bndl) + assert.NilError(t, err) + assert.Equal(t, iid, fmt.Sprintf("sha256:%s", actualID.String())) + } + }) +} + func TestQuietBuild(t *testing.T) { runWithDindSwarmAndRegistry(t, func(info dindSwarmAndRegistryInfo) { cmd := info.configuredCmd diff --git a/internal/commands/build/build.go b/internal/commands/build/build.go index c6fade7a6..5b29d7077 100644 --- a/internal/commands/build/build.go +++ b/internal/commands/build/build.go @@ -5,11 +5,13 @@ import ( "context" "encoding/json" "fmt" + "io" "io/ioutil" "os" "path/filepath" "strconv" "strings" + "sync" "github.com/deislabs/cnab-go/bundle" cnab "github.com/deislabs/cnab-go/driver" @@ -24,6 +26,7 @@ import ( "github.com/docker/cli/cli/command" compose "github.com/docker/cli/cli/compose/types" "github.com/docker/cli/cli/streams" + cliOpts "github.com/docker/cli/opts" "github.com/docker/cnab-to-oci/remotes" "github.com/docker/distribution/reference" "github.com/moby/buildkit/client" @@ -39,19 +42,26 @@ type buildOptions struct { noCache bool progress string pull bool - tag string + tags cliOpts.ListOpts folder string imageIDFile string - args []string + args cliOpts.ListOpts quiet bool noResolveImage bool } const buildExample = `- $ docker app build . -- $ docker app build --file myapp.dockerapp --tag myrepo/myapp:1.0.0 .` +- $ docker app build --file myapp.dockerapp --tag myrepo/myapp:1.0.0 --tag myrepo/myapp:latest .` + +func newBuildOptions() buildOptions { + return buildOptions{ + tags: cliOpts.NewListOpts(validateTag), + args: cliOpts.NewListOpts(nil), + } +} func Cmd(dockerCli command.Cli) *cobra.Command { - var opts buildOptions + opts := newBuildOptions() cmd := &cobra.Command{ Use: "build [OPTIONS] BUILD_PATH", Short: "Build an App image from an App definition (.dockerapp)", @@ -66,10 +76,10 @@ func Cmd(dockerCli command.Cli) *cobra.Command { flags.BoolVar(&opts.noCache, "no-cache", false, "Do not use cache when building the App image") flags.StringVar(&opts.progress, "progress", "auto", "Set type of progress output (auto, plain, tty). Use plain to show container output") flags.BoolVar(&opts.noResolveImage, "no-resolve-image", false, "Do not query the registry to resolve image digest") - flags.StringVarP(&opts.tag, "tag", "t", "", "App image tag, optionally in the 'repo:tag' format") + flags.VarP(&opts.tags, "tag", "t", "Name and optionally a tag in the 'name:tag' format") flags.StringVarP(&opts.folder, "file", "f", "", "App definition as a .dockerapp directory") flags.BoolVar(&opts.pull, "pull", false, "Always attempt to pull a newer version of the App image") - flags.StringArrayVar(&opts.args, "build-arg", []string{}, "Set build-time variables") + flags.Var(&opts.args, "build-arg", "Set build-time variables") flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress the build output and print App image ID on success") flags.StringVar(&opts.imageIDFile, "iidfile", "", "Write the App image ID to the file") @@ -128,37 +138,65 @@ func runBuild(dockerCli command.Cli, contextPath string, opt buildOptions) error } defer app.Cleanup() - bundle, err := buildImageUsingBuildx(app, contextPath, opt, dockerCli) + bndl, err := buildImageUsingBuildx(app, contextPath, opt, dockerCli) if err != nil { return err } - var ref reference.Reference - ref, err = packager.GetNamedTagged(opt.tag) + out, err := getOutputFile(dockerCli.Out(), opt.quiet) if err != nil { return err } - id, err := packager.PersistInBundleStore(ref, bundle) + id, err := persistTags(bndl, opt.tags, opt.imageIDFile, out, dockerCli.Err()) if err != nil { return err } - if opt.imageIDFile != "" { - if err = ioutil.WriteFile(opt.imageIDFile, []byte(id.Digest().String()), 0644); err != nil { - fmt.Fprintf(dockerCli.Err(), "Failed to write App image ID in %s: %s", opt.imageIDFile, err) - } + if opt.quiet { + _, err = fmt.Fprintln(dockerCli.Out(), id.Digest().String()) } + return err +} - if opt.quiet { - fmt.Fprintln(dockerCli.Out(), id.Digest().String()) - return err +func persistTags(bndl *bundle.Bundle, tags cliOpts.ListOpts, iidFile string, outWriter io.Writer, errWriter io.Writer) (reference.Digested, error) { + var ( + id reference.Digested + onceWriteIIDFile sync.Once + ) + if tags.Len() == 0 { + return persistInBundleStore(&onceWriteIIDFile, outWriter, errWriter, bndl, nil, iidFile) + } + for _, tag := range tags.GetAll() { + ref, err := packager.GetNamedTagged(tag) + if err != nil { + return nil, err + } + id, err = persistInBundleStore(&onceWriteIIDFile, outWriter, errWriter, bndl, ref, iidFile) + if err != nil { + return nil, err + } + if tag != "" { + fmt.Fprintf(outWriter, "Successfully tagged app image %s\n", ref.String()) + } } - fmt.Fprintf(dockerCli.Out(), "Successfully built %s\n", id.String()) - if ref != nil { - fmt.Fprintf(dockerCli.Out(), "Successfully tagged %s\n", ref.String()) + return id, nil +} + +func persistInBundleStore(once *sync.Once, outWriter io.Writer, errWriter io.Writer, b *bundle.Bundle, ref reference.Reference, iidFileName string) (reference.Digested, error) { + id, err := packager.PersistInBundleStore(ref, b) + if err != nil { + return nil, err } - return err + once.Do(func() { + fmt.Fprintf(outWriter, "Successfully built app image %s\n", id.String()) + if iidFileName != "" { + if err := ioutil.WriteFile(iidFileName, []byte(id.Digest().String()), 0644); err != nil { + fmt.Fprintf(errWriter, "Failed to write App image ID in %s: %s", iidFileName, err) + } + } + }) + return id, nil } func buildImageUsingBuildx(app *types.App, contextPath string, opt buildOptions, dockerCli command.Cli) (*bundle.Bundle, error) { @@ -350,9 +388,9 @@ func debugSolveResponses(resp map[string]*client.SolveResponse) { } } -func checkBuildArgsUniqueness(args []string) error { +func checkBuildArgsUniqueness(args cliOpts.ListOpts) error { set := make(map[string]bool) - for _, value := range args { + for _, value := range args.GetAllOrEmpty() { key := strings.Split(value, "=")[0] if _, ok := set[key]; ok { return fmt.Errorf("'--build-arg %s' is defined twice", key) @@ -361,3 +399,12 @@ func checkBuildArgsUniqueness(args []string) error { } return nil } + +// validateTag checks if the given image name can be resolved. +func validateTag(rawRepo string) (string, error) { + _, err := reference.ParseNormalizedNamed(rawRepo) + if err != nil { + return "", err + } + return rawRepo, nil +} diff --git a/internal/commands/build/compose.go b/internal/commands/build/compose.go index b521a0528..f175f6326 100644 --- a/internal/commands/build/compose.go +++ b/internal/commands/build/compose.go @@ -21,7 +21,7 @@ func parseCompose(app *types.App, contextPath string, options buildOptions) (map return nil, nil, err } - buildArgs := buildArgsToMap(options.args) + buildArgs := buildArgsToMap(options.args.GetAllOrEmpty()) pulledServices := []compose.ServiceConfig{} opts := map[string]build.Options{} diff --git a/internal/commands/build/compose_test.go b/internal/commands/build/compose_test.go index ebd98630b..5f5930bc4 100644 --- a/internal/commands/build/compose_test.go +++ b/internal/commands/build/compose_test.go @@ -51,7 +51,7 @@ func Test_parseCompose(t *testing.T) { t.Run(tt.name, func(t *testing.T) { app, err := packager.Extract("testdata/" + tt.name) assert.NilError(t, err) - got, _, err := parseCompose(app, "testdata", buildOptions{}) + got, _, err := parseCompose(app, "testdata", newBuildOptions()) assert.NilError(t, err) _, ok := got["dontwant"] assert.Assert(t, !ok, "parseCompose() should have excluded 'dontwant' service")