diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 553fc2778c..127c15de47 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -89,3 +89,7 @@ jobs: # Check that --debug adds dlv to the image, and that dlv is runnable. docker run --entrypoint="dlv" $(go run ./ build ./test/ --platform=${PLATFORM} --debug) version | grep "Delve Debugger" fi + + # Build and run tests in the test/ folder + testimg=$(go run ./ test ./test/test/ --platform=${PLATFORM}) + docker run ${testimg} | grep "PASS" diff --git a/docs/reference/ko.md b/docs/reference/ko.md index a0d687c776..5af4515e1b 100644 --- a/docs/reference/ko.md +++ b/docs/reference/ko.md @@ -22,5 +22,6 @@ ko [flags] * [ko login](ko_login.md) - Log in to a registry * [ko resolve](ko_resolve.md) - Print the input files with image references resolved to built/pushed image digests. * [ko run](ko_run.md) - A variant of `kubectl run` that containerizes IMPORTPATH first. +* [ko test](ko_test.md) - Build and publish container images with go test from the given importpaths. * [ko version](ko_version.md) - Print ko version. diff --git a/docs/reference/ko_test.md b/docs/reference/ko_test.md new file mode 100644 index 0000000000..a8357bafd0 --- /dev/null +++ b/docs/reference/ko_test.md @@ -0,0 +1,75 @@ +## ko test + +Build and publish container images with go test from the given importpaths. + +### Synopsis + +This sub-command builds the provided import paths into Go test binaries, containerizes them, and publishes them. + +``` +ko test IMPORTPATH... [flags] +``` + +### Examples + +``` + + # Build and publish tests from import path references to a Docker Registry as: + # ${KO_DOCKER_REPO}/- + # When KO_DOCKER_REPO is ko.local, it is the same as if --local and + # --preserve-import-paths were passed. + # If the import path is not provided, the current working directory is the + # default. + ko test github.com/foo/bar/cmd/baz github.com/foo/bar/cmd/blah + + # Build and publish tests from a relative import path as: + # ${KO_DOCKER_REPO}/- + # When KO_DOCKER_REPO is ko.local, it is the same as if --local and + # --preserve-import-paths were passed. + ko test ./cmd/blah + + # Build and publish tests from a relative import path as: + # ${KO_DOCKER_REPO}/ + # When KO_DOCKER_REPO is ko.local, it is the same as if --local was passed. + ko test --preserve-import-paths ./cmd/blah + + # Build and publish tests from import path references to a Docker daemon as: + # ko.local/ + # This always preserves import paths. + ko test --local github.com/foo/bar/cmd/baz github.com/foo/bar/cmd/blah +``` + +### Options + +``` + --bare Whether to just use KO_DOCKER_REPO without additional context (may not work properly with --tags). + -B, --base-import-paths Whether to use the base path without MD5 hash after KO_DOCKER_REPO (may not work properly with --tags). + --debug Include Delve debugger into image and wrap around ko-app. This debugger will listen to port 40000. + --disable-optimizations Disable optimizations when building Go code. Useful when you want to interactively debug the created container. + -h, --help help for test + --image-label strings Which labels (key=value) to add to the image. + --image-refs string Path to file where a list of the published image references will be written. + --insecure-registry Whether to skip TLS verification on the registry + -j, --jobs int The maximum number of concurrent builds (default GOMAXPROCS) + -L, --local Load into images to local docker daemon. + --oci-layout-path string Path to save the OCI image layout of the built images + --platform strings Which platform to use when pulling a multi-platform base. Format: all | [/[/]][,platform]* + -P, --preserve-import-paths Whether to preserve the full import path after KO_DOCKER_REPO. + --push Push images to KO_DOCKER_REPO (default true) + --sbom string The SBOM media type to use (none will disable SBOM synthesis and upload). (default "spdx") + --sbom-dir string Path to file where the SBOM will be written. + --tag-only Include tags but not digests in resolved image references. Useful when digests are not preserved when images are repopulated. + -t, --tags strings Which tags to use for the produced image instead of the default 'latest' tag (may not work properly with --base-import-paths or --bare). (default [latest]) + --tarball string File to save images tarballs +``` + +### Options inherited from parent commands + +``` + -v, --verbose Enable debug logs +``` + +### SEE ALSO + +* [ko](ko.md) - Rapidly iterate with Go, Containers, and Kubernetes. + diff --git a/pkg/build/config.go b/pkg/build/config.go index 7218d9fb4e..e6749743cf 100644 --- a/pkg/build/config.go +++ b/pkg/build/config.go @@ -76,6 +76,10 @@ type Config struct { Ldflags StringArray `yaml:",omitempty"` Flags FlagArray `yaml:",omitempty"` + // TestLdflags and TestFlags will be used for the Go test command line arguments + TestLdflags StringArray `yaml:",omitempty"` + TestFlags FlagArray `yaml:",omitempty"` + // Env allows setting environment variables for `go build` Env []string `yaml:",omitempty"` diff --git a/pkg/build/gobuild.go b/pkg/build/gobuild.go index 124638bc06..2b8988dc89 100644 --- a/pkg/build/gobuild.go +++ b/pkg/build/gobuild.go @@ -73,6 +73,7 @@ type buildContext struct { flags []string ldflags []string platform v1.Platform + goTest bool } type builder func(context.Context, buildContext) (string, error) @@ -102,6 +103,7 @@ type gobuild struct { dir string labels map[string]string debug bool + goTest bool semaphore *semaphore.Weighted cache *layerCache @@ -129,6 +131,7 @@ type gobuildOpener struct { dir string jobs int debug bool + goTest bool } func (gbo *gobuildOpener) Open() (Interface, error) { @@ -159,6 +162,7 @@ func (gbo *gobuildOpener) Open() (Interface, error) { labels: gbo.labels, dir: gbo.dir, debug: gbo.debug, + goTest: gbo.goTest, platformMatcher: matcher, cache: &layerCache{ buildToDiff: map[string]buildIDToDiffID{}, @@ -237,10 +241,16 @@ func (g *gobuild) IsSupportedReference(s string) error { if dir == "." { dir = "" } - pkgs, err := packages.Load(&packages.Config{Dir: dir, Mode: packages.NeedName}, ref.Path()) + pkgs, err := packages.Load(&packages.Config{Dir: dir, Mode: packages.NeedName, Tests: g.goTest}, ref.Path()) if err != nil { return fmt.Errorf("error loading package from %s: %w", ref.Path(), err) } + if g.goTest { + if len(pkgs) == 0 { + return errors.New("no package found in importpath") + } + return nil + } if len(pkgs) != 1 { return fmt.Errorf("found %d local packages, expected 1", len(pkgs)) } @@ -356,8 +366,12 @@ func build(ctx context.Context, buildCtx buildContext) (string, error) { return "", err } - args := make([]string, 0, 4+len(buildArgs)) - args = append(args, "build") + args := make([]string, 0, 5+len(buildArgs)) + if buildCtx.goTest { + args = append(args, "test", "-c") + } else { + args = append(args, "build") + } args = append(args, buildArgs...) tmpDir := "" @@ -659,10 +673,18 @@ func (g *gobuild) kodataPath(ref reference) (string, error) { if dir == "." { dir = "" } - pkgs, err := packages.Load(&packages.Config{Dir: dir, Mode: packages.NeedFiles}, ref.Path()) + pkgs, err := packages.Load(&packages.Config{Dir: dir, Mode: packages.NeedFiles, Tests: g.goTest}, ref.Path()) if err != nil { return "", fmt.Errorf("error loading package from %s: %w", ref.Path(), err) } + if g.goTest { + for i, p := range pkgs { + if len(p.GoFiles) != 0 { + return filepath.Join(filepath.Dir(pkgs[i].GoFiles[0]), "kodata"), nil + } + } + return "", fmt.Errorf("package loaded from %s contains no Go files", ref.path) + } if len(pkgs) != 1 { return "", fmt.Errorf("found %d local packages, expected 1", len(pkgs)) } @@ -990,6 +1012,9 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, base v1.Image, pl // Get the build flags. flags := config.Flags + if g.goTest { + flags = config.TestFlags + } if len(flags) == 0 { // Use the default, if any. flags = g.defaultFlags @@ -997,6 +1022,9 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, base v1.Image, pl // Get the build ldflags. ldflags := config.Ldflags + if g.goTest { + ldflags = config.TestLdflags + } if len(ldflags) == 0 { // Use the default, if any ldflags = g.defaultLdflags @@ -1011,6 +1039,7 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, base v1.Image, pl flags: flags, ldflags: ldflags, platform: *platform, + goTest: g.goTest, }) if err != nil { return nil, fmt.Errorf("build: %w", err) @@ -1045,6 +1074,9 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, base v1.Image, pl appDir := "/ko-app" appFileName := appFilename(ref.Path()) + if g.goTest { + appFileName += ".test" + } appPath := path.Join(appDir, appFileName) var lo layerOptions @@ -1069,6 +1101,11 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, base v1.Image, pl return nil, fmt.Errorf("cache.get(%q): %w", file, err) } + comment := "go build output, at " + appPath + if g.goTest { + comment = "go test -c output, at " + appPath + } + layers = append(layers, mutate.Addendum{ Layer: binaryLayer, MediaType: layerMediaType, @@ -1076,7 +1113,7 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, base v1.Image, pl Author: "ko", Created: g.creationTime, CreatedBy: "ko build " + ref.String(), - Comment: "go build output, at " + appPath, + Comment: comment, }, }) diff --git a/pkg/build/gobuild_test.go b/pkg/build/gobuild_test.go index 2a0b894231..07cf65b7ce 100644 --- a/pkg/build/gobuild_test.go +++ b/pkg/build/gobuild_test.go @@ -1509,3 +1509,66 @@ func TestDebugger(t *testing.T) { } } } + +func TestGoTest(t *testing.T) { + base, err := random.Image(1024, 3) + if err != nil { + t.Fatalf("random.Image() = %v", err) + } + + ng, err := NewGo( + context.Background(), + "", + WithBaseImages(func(context.Context, string) (name.Reference, Result, error) { return baseRef, base, nil }), + WithPlatforms("linux/amd64"), + WithGoTest(), + ) + if err != nil { + t.Fatalf("NewGo() = %v", err) + } + + for _, c := range []struct { + importpath string + expectedName string + }{ + { + importpath: "github.com/google/ko/test/test/", + expectedName: "test.test", + }, { + importpath: "github.com/google/ko/pkg/build", + expectedName: "build.test", + }, + } { + t.Run(c.importpath, func(t *testing.T) { + result, err := ng.Build(context.Background(), StrictScheme+c.importpath) + if err != nil { + t.Fatalf("Build() = %v", err) + } + + img, ok := result.(v1.Image) + if !ok { + t.Fatalf("Build() not an Image: %T", result) + } + + // Check that the entrypoint of the image is not overwritten + cfg, err := img.ConfigFile() + if err != nil { + t.Errorf("ConfigFile() = %v", err) + } + gotEntrypoint := cfg.Config.Entrypoint + wantEntrypoint := []string{ + "/ko-app/" + c.expectedName, + } + + if got, want := len(gotEntrypoint), len(wantEntrypoint); got != want { + t.Fatalf("len(entrypoint) = %v, want %v", got, want) + } + + for i := range wantEntrypoint { + if got, want := gotEntrypoint[i], wantEntrypoint[i]; got != want { + t.Errorf("entrypoint[%d] = %v, want %v", i, got, want) + } + } + }) + } +} diff --git a/pkg/build/options.go b/pkg/build/options.go index d773badc56..b01fdb5b4d 100644 --- a/pkg/build/options.go +++ b/pkg/build/options.go @@ -191,3 +191,10 @@ func WithDebugger() Option { return nil } } + +func WithGoTest() Option { + return func(gbo *gobuildOpener) error { + gbo.goTest = true + return nil + } +} diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index da92ac21a6..fc4f548a2c 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -32,6 +32,7 @@ func AddKubeCommands(topLevel *cobra.Command) { addResolve(topLevel) addBuild(topLevel) addRun(topLevel) + addTest(topLevel) } // check if kubectl is installed diff --git a/pkg/commands/options/build.go b/pkg/commands/options/build.go index b699293820..1b3e477d05 100644 --- a/pkg/commands/options/build.go +++ b/pkg/commands/options/build.go @@ -66,6 +66,7 @@ type BuildOptions struct { Platforms []string Labels []string Debug bool + GoTest bool // UserAgent enables overriding the default value of the `User-Agent` HTTP // request header used when retrieving the base image. UserAgent string diff --git a/pkg/commands/resolver.go b/pkg/commands/resolver.go index c55b961c0d..919777021e 100644 --- a/pkg/commands/resolver.go +++ b/pkg/commands/resolver.go @@ -105,6 +105,9 @@ func gobuildOptions(bo *options.BuildOptions) ([]build.Option, error) { opts = append(opts, build.WithDebugger()) opts = append(opts, build.WithDisabledOptimizations()) // also needed for Delve } + if bo.GoTest { + opts = append(opts, build.WithGoTest()) + } switch bo.SBOM { case "none": opts = append(opts, build.WithDisabledSBOM()) diff --git a/pkg/commands/test.go b/pkg/commands/test.go new file mode 100644 index 0000000000..57fc9adbb3 --- /dev/null +++ b/pkg/commands/test.go @@ -0,0 +1,94 @@ +// Copyright 2018 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package commands + +import ( + "fmt" + + "github.com/google/ko/pkg/commands/options" + "github.com/spf13/cobra" +) + +// addBuild augments our CLI surface with build. +func addTest(topLevel *cobra.Command) { + po := &options.PublishOptions{} + bo := &options.BuildOptions{} + + build := &cobra.Command{ + Use: "test IMPORTPATH...", + Short: "Build and publish container images with go test from the given importpaths.", + Long: `This sub-command builds the provided import paths into Go test binaries, containerizes them, and publishes them.`, + Aliases: []string{"publish"}, + Example: ` + # Build and publish tests from import path references to a Docker Registry as: + # ${KO_DOCKER_REPO}/- + # When KO_DOCKER_REPO is ko.local, it is the same as if --local and + # --preserve-import-paths were passed. + # If the import path is not provided, the current working directory is the + # default. + ko test github.com/foo/bar/cmd/baz github.com/foo/bar/cmd/blah + + # Build and publish tests from a relative import path as: + # ${KO_DOCKER_REPO}/- + # When KO_DOCKER_REPO is ko.local, it is the same as if --local and + # --preserve-import-paths were passed. + ko test ./cmd/blah + + # Build and publish tests from a relative import path as: + # ${KO_DOCKER_REPO}/ + # When KO_DOCKER_REPO is ko.local, it is the same as if --local was passed. + ko test --preserve-import-paths ./cmd/blah + + # Build and publish tests from import path references to a Docker daemon as: + # ko.local/ + # This always preserves import paths. + ko test --local github.com/foo/bar/cmd/baz github.com/foo/bar/cmd/blah`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := options.Validate(po, bo); err != nil { + return fmt.Errorf("validating options: %w", err) + } + + if len(args) == 0 { + // Build the current directory by default. + args = []string{"."} + } + + ctx := cmd.Context() + + bo.GoTest = true + bo.InsecureRegistry = po.InsecureRegistry + builder, err := makeBuilder(ctx, bo) + if err != nil { + return fmt.Errorf("error creating builder: %w", err) + } + publisher, err := makePublisher(po) + if err != nil { + return fmt.Errorf("error creating publisher: %w", err) + } + defer publisher.Close() + images, err := publishImages(ctx, args, publisher, builder) + if err != nil { + return fmt.Errorf("failed to publish images: %w", err) + } + for _, img := range images { + fmt.Println(img) + } + return nil + }, + } + options.AddPublishArg(build, po) + options.AddBuildOptions(build, bo) + topLevel.AddCommand(build) +} diff --git a/test/test/dummy_test.go b/test/test/dummy_test.go new file mode 100644 index 0000000000..a009436633 --- /dev/null +++ b/test/test/dummy_test.go @@ -0,0 +1,9 @@ +package test + +import "testing" + +func TestDummy(t *testing.T) { + t.Run("dummy test", func(t *testing.T) { + t.Logf("this is a dummy test") + }) +}