diff --git a/docs/configuration.md b/docs/configuration.md index 5dcff22e1..fc58f03e9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -171,6 +171,36 @@ builds: The values for a `build` will be used if specified, otherwise their respective defaults will be used. Both default and per-build values may use [template parameters](#templating-support). +### Overriding the binary path + +By default, `ko` places the compiled binary at `/ko-app/` inside the +image, where `` is derived from the import path (e.g. building +`./cmd/app` yields `/ko-app/app`). You can override this location with either +`binaryFolder` or `binaryPath`. + +Use `binaryFolder` to override only the folder, keeping the derived app name. +Setting it to `/go/bin` produces `/go/bin/`: + +```yaml +binaryFolder: /go/bin +``` + +Use `binaryPath` to fully replace the default `/ko-app/` with an +absolute path, including both the folder and the binary name. If both +`binaryFolder` and `binaryPath` are set, `binaryPath` takes precedence (`binaryFolder` is ignored): + +```yaml +binaryPath: /go/bin/myapp +``` + +You can also use the `KO_BINARYFOLDER` and `KO_BINARYPATH` environment +variables, which override the YAML configuration: + +```shell +KO_BINARYFOLDER=/go/bin +KO_BINARYPATH=/go/bin/myapp +``` + ### Environment Variables (advanced) For ease of use, backward compatibility and advanced use cases, `ko` supports the following environment variables to diff --git a/pkg/build/gobuild.go b/pkg/build/gobuild.go index 9fbcc40f5..63f4816eb 100644 --- a/pkg/build/gobuild.go +++ b/pkg/build/gobuild.go @@ -100,6 +100,8 @@ type gobuild struct { defaultFlags []string defaultLdflags []string ldflags []string + binaryFolder string + binaryPath string platformMatcher *platformMatcher dir string labels map[string]string @@ -129,6 +131,8 @@ type gobuildOpener struct { defaultFlags []string defaultLdflags []string ldflags []string + binaryFolder string + binaryPath string platforms []string labels map[string]string annotations map[string]string @@ -168,6 +172,8 @@ func (gbo *gobuildOpener) Open() (Interface, error) { defaultFlags: gbo.defaultFlags, defaultLdflags: gbo.defaultLdflags, ldflags: gbo.ldflags, + binaryFolder: gbo.binaryFolder, + binaryPath: gbo.binaryPath, labels: gbo.labels, annotations: gbo.annotations, dir: gbo.dir, @@ -616,6 +622,22 @@ func appFilename(importpath string) string { // owner: BUILTIN/Users group: BUILTIN/Users ($sddlValue="O:BUG:BU") const userOwnerAndGroupSID = "AQAAgBQAAAAkAAAAAAAAAAAAAAABAgAAAAAABSAAAAAhAgAAAQIAAAAAAAUgAAAAIQIAAA==" +func parentDirs(name string) []string { + // filepath.Clean normalizes the path; splitting on the separator leaves an + // empty first element for absolute paths (leading separator), which we skip. + components := strings.Split(filepath.Clean(name), string(os.PathSeparator)) + dirs := make([]string, 0, len(components)) + acc := "" + for _, c := range components[:len(components)-1] { // drop the file itself + if c == "" { + continue + } + acc = path.Join(acc, c) + dirs = append(dirs, acc) + } + return dirs +} + func tarBinary(name, binary string, platform *v1.Platform, opts *layerOptions) (*bytes.Buffer, error) { buf := bytes.NewBuffer(nil) tw := tar.NewWriter(buf) @@ -624,8 +646,9 @@ func tarBinary(name, binary string, platform *v1.Platform, opts *layerOptions) ( // Write the parent directories to the tarball archive. // For Windows, the layer must contain a Hives/ directory, and the root // of the actual filesystem goes in a Files/ directory. - // For Linux, the binary goes into /ko-app/ - dirs := []string{"ko-app"} + // For Linux, the binary's parent directories are derived from its (possibly + // overridden) path so that arbitrary folders are created. + dirs := parentDirs(name) if platform.OS == "windows" { dirs = []string{ "Hives", @@ -1209,9 +1232,24 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, base v1.Image, pl }, }) - appDir := "/ko-app" appFileName := appFilename(ref.Path()) - appPath := path.Join(appDir, appFileName) + var appDir, appPath string + + if g.binaryFolder != "" && g.binaryPath != "" { + log.Printf("both binaryFolder (%q) and binaryPath (%q) are set; binaryPath takes precedence", g.binaryFolder, g.binaryPath) + } + switch { + case g.binaryPath != "": + appPath = g.binaryPath + appDir = path.Dir(appPath) + appFileName = path.Base(appPath) + case g.binaryFolder != "": + appDir = g.binaryFolder + appPath = path.Join(appDir, appFileName) + default: + appDir = "/ko-app" + appPath = path.Join(appDir, appFileName) + } var lo layerOptions lo.linuxCapabilities, err = caps.NewFileCaps(config.LinuxCapabilities...) diff --git a/pkg/build/gobuild_test.go b/pkg/build/gobuild_test.go index a31823518..deafb0f90 100644 --- a/pkg/build/gobuild_test.go +++ b/pkg/build/gobuild_test.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "path" "path/filepath" @@ -1979,3 +1980,212 @@ func TestResolveKodataAllowedRootGitWorktree(t *testing.T) { t.Errorf("resolveKodataAllowedRoot(%q) = %q, want git worktree root %q", kodataRoot, got, resolvedBase) } } + +// TestParentDirs verifies the parentDirs helper returns the cumulative parent +// directories of a POSIX file path as relative paths (no leading slash), +// ordered shallowest-to-deepest so that parents are created before children. +func TestParentDirs(t *testing.T) { + for _, tc := range []struct { + name string + in string + want []string + }{{ + name: "default ko-app path", + in: "/ko-app/ko", + want: []string{"ko-app"}, + }, { + name: "nested folder override", + in: "/go/bin/myapp", + want: []string{"go", "go/bin"}, + }, { + name: "root-level file has no parent dirs", + in: "/myapp", + want: []string{}, + }, { + name: "no leading slash", + in: "ko-app/ko", + want: []string{"ko-app"}, + }, { + name: "deeply nested path", + in: "/a/b/c/d", + want: []string{"a", "a/b", "a/b/c"}, + }} { + t.Run(tc.name, func(t *testing.T) { + got := parentDirs(tc.in) + if len(got) != len(tc.want) { + t.Fatalf("parentDirs(%q) = %v (len %d), want %v (len %d)", tc.in, got, len(got), tc.want, len(tc.want)) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Errorf("parentDirs(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i]) + } + } + }) + } +} + +// buildImageForEntrypoint is a helper that builds a test image using the given +// build options and returns the resulting image's entrypoint. It mirrors the +// harness used by TestGoBuildNoKoData. +func buildImageForEntrypoint(t *testing.T, importpath string, opts ...Option) []string { + t.Helper() + + baseLayers := int64(3) + base, err := random.Image(1024, baseLayers) + if err != nil { + t.Fatalf("random.Image() = %v", err) + } + + creationTime := v1.Time{Time: time.Unix(5000, 0)} + baseOpts := []Option{ + WithCreationTime(creationTime), + WithBaseImages(func(context.Context, string) (name.Reference, Result, error) { return baseRef, base, nil }), + withBuilder(writeTempFile), + withSBOMber(fauxSBOM), + WithPlatforms("all"), + } + ng, err := NewGo(context.Background(), "", append(baseOpts, opts...)...) + if err != nil { + t.Fatalf("NewGo() = %v", err) + } + + result, err := ng.Build(context.Background(), StrictScheme+importpath) + if err != nil { + t.Fatalf("Build() = %v", err) + } + + img, ok := result.(v1.Image) + if !ok { + t.Fatalf("Build() not an Image: %T", result) + } + + cfg, err := img.ConfigFile() + if err != nil { + t.Fatalf("ConfigFile() = %v", err) + } + return cfg.Config.Entrypoint +} + +// TestGoBuildBinaryPathOverrides verifies that WithBinaryFolder and +// WithBinaryPath override the in-image entrypoint as documented, that +// binaryPath wins when both are set, and that the default is unchanged. +func TestGoBuildBinaryPathOverrides(t *testing.T) { + // importpath's base ("ko") is the derived app name. + const importpath = "github.com/google/ko" + + t.Run("default is /ko-app/", func(t *testing.T) { + entrypoint := buildImageForEntrypoint(t, importpath) + if got, want := len(entrypoint), 1; got != want { + t.Fatalf("len(entrypoint) = %v, want %v", got, want) + } + if got, want := entrypoint[0], "/ko-app/ko"; got != want { + t.Errorf("entrypoint = %v, want %v", got, want) + } + }) + + t.Run("WithBinaryFolder keeps app name", func(t *testing.T) { + entrypoint := buildImageForEntrypoint(t, importpath, WithBinaryFolder("/go/bin")) + if got, want := len(entrypoint), 1; got != want { + t.Fatalf("len(entrypoint) = %v, want %v", got, want) + } + if got, want := entrypoint[0], "/go/bin/ko"; got != want { + t.Errorf("entrypoint = %v, want %v", got, want) + } + }) + + t.Run("WithBinaryPath fully replaces path", func(t *testing.T) { + entrypoint := buildImageForEntrypoint(t, importpath, WithBinaryPath("/custom/location/mybinary")) + if got, want := len(entrypoint), 1; got != want { + t.Fatalf("len(entrypoint) = %v, want %v", got, want) + } + if got, want := entrypoint[0], "/custom/location/mybinary"; got != want { + t.Errorf("entrypoint = %v, want %v", got, want) + } + }) + + t.Run("binaryPath wins when both set", func(t *testing.T) { + entrypoint := buildImageForEntrypoint(t, importpath, + WithBinaryFolder("/go/bin"), + WithBinaryPath("/custom/location/mybinary"), + ) + if got, want := len(entrypoint), 1; got != want { + t.Fatalf("len(entrypoint) = %v, want %v", got, want) + } + if got, want := entrypoint[0], "/custom/location/mybinary"; got != want { + t.Errorf("entrypoint = %v, want %v", got, want) + } + }) +} + +// TestTarBinaryParentDirs verifies that tarBinary writes tar directory headers +// for all parent directories of the (possibly overridden) target path, so that +// arbitrary folders (e.g. /go/bin) exist in the resulting layer. +func TestTarBinaryParentDirs(t *testing.T) { + // Create a small dummy executable to stand in for the built binary. + tmpDir := t.TempDir() + binary := filepath.Join(tmpDir, "fakebinary") + if err := os.WriteFile(binary, []byte("#!/bin/sh\ntrue\n"), 0755); err != nil { + t.Fatalf("WriteFile() = %v", err) + } + + platform := &v1.Platform{OS: "linux", Architecture: "amd64"} + + for _, tc := range []struct { + name string + target string + wantDirs []string + wantFile string + }{{ + name: "binary folder override", + target: "/go/bin/myapp", + wantDirs: []string{"go", "go/bin"}, + wantFile: "/go/bin/myapp", + }, { + name: "default ko-app path", + target: "/ko-app/ko", + wantDirs: []string{"ko-app"}, + wantFile: "/ko-app/ko", + }, { + name: "full binary path override", + target: "/custom/location/mybinary", + wantDirs: []string{"custom", "custom/location"}, + wantFile: "/custom/location/mybinary", + }} { + t.Run(tc.name, func(t *testing.T) { + buf, err := tarBinary(tc.target, binary, platform, &layerOptions{}) + if err != nil { + t.Fatalf("tarBinary() = %v", err) + } + + gotDirs := map[string]bool{} + gotFiles := map[string]bool{} + tr := tar.NewReader(bytes.NewReader(buf.Bytes())) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("tar.Reader.Next() = %v", err) + } + switch hdr.Typeflag { + case tar.TypeDir: + gotDirs[hdr.Name] = true + case tar.TypeReg: + gotFiles[hdr.Name] = true + } + } + + for _, dir := range tc.wantDirs { + if !gotDirs[dir] { + t.Errorf("expected dir header %q in tar, got dirs %v", dir, maps.Keys(gotDirs)) + } + } + // The regular file header is written with the leading slash trimmed + // only for Windows; on linux the name is used as-is. + if !gotFiles[tc.wantFile] { + t.Errorf("expected file header %q in tar, got files %v", tc.wantFile, maps.Keys(gotFiles)) + } + }) + } +} diff --git a/pkg/build/options.go b/pkg/build/options.go index a5df0aa33..6c7f0c2b1 100644 --- a/pkg/build/options.go +++ b/pkg/build/options.go @@ -219,3 +219,23 @@ func WithDebugger() Option { return nil } } + +// WithBinaryFolder overrides the in-image folder +// ("/ko-app") that the binary is placed in. The app name is kept. Ignored if +// WithBinaryPath is also set. +func WithBinaryFolder(folder string) Option { + return func(gbo *gobuildOpener) error { + gbo.binaryFolder = folder + return nil + } +} + +// WithBinaryPath fully overrides the in-image +// binary path ("/ko-app/"). Must be an absolute path. Takes +// precedence over WithBinaryFolder. +func WithBinaryPath(p string) Option { + return func(gbo *gobuildOpener) error { + gbo.binaryPath = p + return nil + } +} diff --git a/pkg/commands/options/build.go b/pkg/commands/options/build.go index c9beccd3a..283194383 100644 --- a/pkg/commands/options/build.go +++ b/pkg/commands/options/build.go @@ -55,6 +55,14 @@ type BuildOptions struct { // DefaultLdflags defines the default ldflags when per-build value is not explicitly defined. DefaultLdflags []string + // BinaryFolder overrides the default in-image binary folder ("/ko-app"). + // The app name is kept. Ignored if BinaryPath is set. + BinaryFolder string + + // BinaryPath is an absolute in-image path that fully overrides the default + // binary path ("/ko-app/"). Takes precedence over BinaryFolder. + BinaryPath string + // WorkingDirectory allows for setting the working directory for invocations of the `go` tool. // Empty string means the current working directory. WorkingDirectory string @@ -174,6 +182,14 @@ func (bo *BuildOptions) LoadConfig() error { bo.DefaultLdflags = ldflags } + if folder := v.GetString("binaryFolder"); folder != "" { + bo.BinaryFolder = folder + } + + if binPath := v.GetString("binaryPath"); binPath != "" { + bo.BinaryPath = binPath + } + if bo.BaseImage == "" { ref := v.GetString("defaultBaseImage") if _, err := name.ParseReference(ref); err != nil { diff --git a/pkg/commands/resolver.go b/pkg/commands/resolver.go index 027a3089c..ac2f8699e 100644 --- a/pkg/commands/resolver.go +++ b/pkg/commands/resolver.go @@ -142,6 +142,13 @@ func gobuildOptions(bo *options.BuildOptions) ([]build.Option, error) { opts = append(opts, build.WithSBOMDir(bo.SBOMDir)) } + if bo.BinaryFolder != "" { + opts = append(opts, build.WithBinaryFolder(bo.BinaryFolder)) + } + if bo.BinaryPath != "" { + opts = append(opts, build.WithBinaryPath(bo.BinaryPath)) + } + return opts, nil }