Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<app-name>` inside the
image, where `<app-name>` 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/<app-name>`:

```yaml
binaryFolder: /go/bin
```

Use `binaryPath` to fully replace the default `/ko-app/<app-name>` 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
Expand Down
46 changes: 42 additions & 4 deletions pkg/build/gobuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand Down Expand Up @@ -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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows ignores binary path override

High Severity · Logic Bug

When binaryFolder or binaryPath are used, the binary's location within Windows images is customized. However, the Windows entrypoint and PATH environment variable still expect the binary at C:\ko-app. This mismatch causes Windows images to fail to start.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b163e5e. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cache omits destination path

High Severity · Logic Bug

When KOCACHE is enabled, the binary layer cache key doesn't include the binary's in-image path, which can now be overridden by binaryFolder or binaryPath. If only the in-image path changes, the cache may return a hit, but the layer's tarball content will be rebuilt with the new path, leading to a digest/blob mismatch.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b163e5e. Configure here.


var lo layerOptions
lo.linuxCapabilities, err = caps.NewFileCaps(config.LinuxCapabilities...)
Expand Down
210 changes: 210 additions & 0 deletions pkg/build/gobuild_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"os"
"path"
"path/filepath"
Expand Down Expand Up @@ -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/<app-name>", 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))
}
})
}
}
20 changes: 20 additions & 0 deletions pkg/build/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<app-name>"). Must be an absolute path. Takes
// precedence over WithBinaryFolder.
func WithBinaryPath(p string) Option {
return func(gbo *gobuildOpener) error {
gbo.binaryPath = p
return nil
}
}
Loading