From b38d1004e3d3b73d7565048c224b61558224a0df Mon Sep 17 00:00:00 2001 From: CrazyMax Date: Fri, 10 Jul 2026 16:21:50 +0200 Subject: [PATCH] bake: add file-relative path opt-in Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- bake/bake.go | 56 +++++++++++++++++++-- bake/bake_test.go | 93 +++++++++++++++++++++++++++++++++++ commands/bake.go | 25 +++++++++- docs/bake-reference.md | 4 ++ docs/reference/buildx_bake.md | 6 +++ tests/bake.go | 71 ++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 5 deletions(-) diff --git a/bake/bake.go b/bake/bake.go index 66ef0a1d5..3a9e63abd 100644 --- a/bake/bake.go +++ b/bake/bake.go @@ -23,6 +23,7 @@ import ( "github.com/docker/buildx/bake/hclparser" "github.com/docker/buildx/build" "github.com/docker/buildx/util/buildflags" + "github.com/docker/buildx/util/osutil" "github.com/docker/buildx/util/platformutil" "github.com/docker/buildx/util/progress" "github.com/docker/buildx/util/urlutil" @@ -46,6 +47,10 @@ type File struct { Data []byte } +type ParseOpt struct { + FileRelativePaths bool +} + type Override struct { Value string ArrValue []string @@ -197,8 +202,8 @@ func ListTargets(files []File) ([]string, error) { return dedupSlice(targets), nil } -func ReadTargets(ctx context.Context, files []File, targets, overrides []string, defaults, vars map[string]string, ent *EntitlementConf) (map[string]*Target, map[string]*Group, error) { - c, _, err := ParseFiles(files, defaults, vars) +func ReadTargets(ctx context.Context, files []File, targets, overrides []string, defaults, vars map[string]string, ent *EntitlementConf, opts ...ParseOpt) (map[string]*Target, map[string]*Group, error) { + c, _, err := ParseFiles(files, defaults, vars, opts...) if err != nil { return nil, nil, err } @@ -337,7 +342,7 @@ func (c Config) matchNames(pattern string) ([]string, error) { return names, nil } -func ParseFiles(files []File, defaults, vars map[string]string) (_ *Config, _ *hclparser.ParseMeta, err error) { +func ParseFiles(files []File, defaults, vars map[string]string, opts ...ParseOpt) (_ *Config, _ *hclparser.ParseMeta, err error) { defer func() { err = formatHCLError(err, files) }() @@ -413,9 +418,54 @@ func ParseFiles(files []File, defaults, vars map[string]string) (_ *Config, _ *h pm = *res } + for _, opt := range opts { + if opt.FileRelativePaths { + rebaseContextPaths(&c, files) + break + } + } + return &c, &pm, nil } +func rebaseContextPaths(c *Config, files []File) { + base, ok := firstLocalFileDir(files) + if !ok { + return + } + for _, t := range c.Targets { + if t.Context != nil { + contextPath := rebaseContextPath(base, *t.Context) + t.Context = &contextPath + } + for k, v := range t.Contexts { + t.Contexts[k] = rebaseContextPath(base, v) + } + } +} + +func firstLocalFileDir(files []File) (string, bool) { + if len(files) == 0 || files[0].Name == "-" || urlutil.IsRemoteURL(files[0].Name) { + return "", false + } + return filepath.Dir(files[0].Name), true +} + +func rebaseContextPath(base, p string) string { + if p == "" || isSpecialContextPath(p) || filepath.IsAbs(p) { + return p + } + return osutil.SanitizePath(filepath.Join(base, filepath.FromSlash(p))) +} + +func isSpecialContextPath(p string) bool { + return strings.HasPrefix(p, "cwd://") || + strings.HasPrefix(p, "target:") || + strings.HasPrefix(p, "docker-image:") || + strings.HasPrefix(p, "oci-layout://") || + urlutil.IsRemoteURL(p) +} + func dedupeConfig(c Config) Config { c2 := c c2.Groups = make([]*Group, 0, len(c2.Groups)) diff --git a/bake/bake_test.go b/bake/bake_test.go index de3fb9636..379830c6f 100644 --- a/bake/bake_test.go +++ b/bake/bake_test.go @@ -763,6 +763,99 @@ func TestHCLDockerfileCwdPrefix(t *testing.T) { assert.Equal(t, ".", bo["app"].Inputs.ContextPath) } +func TestContextPathRebase(t *testing.T) { + fp := File{ + Name: filepath.Join("subdir", "docker-bake.hcl"), + Data: []byte(` +target "base" { + context = "base" +} + +target "app" { + context = "." + dockerfile = "Dockerfile.app" + contexts = { + shared = "../shared" + cwd = "cwd://local" + linked = "target:base" + image = "docker-image://alpine:latest" + layout = "oci-layout://layout" + } +}`), + } + + m, _, err := ReadTargets(context.TODO(), []File{fp}, []string{"app"}, nil, nil, nil, &EntitlementConf{}, ParseOpt{ + FileRelativePaths: true, + }) + require.NoError(t, err) + + require.Equal(t, filepath.ToSlash(filepath.Clean("subdir")), *m["app"].Context) + require.Equal(t, "Dockerfile.app", *m["app"].Dockerfile) + require.Equal(t, filepath.ToSlash(filepath.Clean("shared")), m["app"].Contexts["shared"]) + require.Equal(t, "cwd://local", m["app"].Contexts["cwd"]) + require.Equal(t, "target:base", m["app"].Contexts["linked"]) + require.Equal(t, "docker-image://alpine:latest", m["app"].Contexts["image"]) + require.Equal(t, "oci-layout://layout", m["app"].Contexts["layout"]) + require.Equal(t, filepath.ToSlash(filepath.Clean("subdir/base")), *m["base"].Context) + + bo, err := TargetsToBuildOpt(m, &Input{}) + require.NoError(t, err) + require.Equal(t, filepath.ToSlash(filepath.Clean("subdir")), bo["app"].Inputs.ContextPath) + require.Equal(t, filepath.Join("subdir", "Dockerfile.app"), bo["app"].Inputs.DockerfilePath) + require.Equal(t, filepath.ToSlash(filepath.Clean("shared")), bo["app"].Inputs.NamedContexts["shared"].Path) +} + +func TestOverridesNotRebased(t *testing.T) { + fp := File{ + Name: filepath.Join("subdir", "docker-bake.hcl"), + Data: []byte(` +target "app" { + context = "." + contexts = { + shared = "../shared" + } +}`), + } + + m, _, err := ReadTargets(context.TODO(), []File{fp}, []string{"app"}, []string{ + "app.context=override", + "app.contexts.shared=override-shared", + }, nil, nil, &EntitlementConf{}, ParseOpt{ + FileRelativePaths: true, + }) + require.NoError(t, err) + + require.Equal(t, "override", *m["app"].Context) + require.Equal(t, "override-shared", m["app"].Contexts["shared"]) +} + +func TestComposePathRebase(t *testing.T) { + fp := File{ + Name: filepath.Join("tests", "docker-compose.yml"), + Data: []byte(` +services: + debian: + build: + context: ./dockerfiles/debian + additional_contexts: + shared: ../shared +`), + } + + m, _, err := ReadTargets(context.TODO(), []File{fp}, []string{"debian"}, nil, nil, nil, &EntitlementConf{}, ParseOpt{ + FileRelativePaths: true, + }) + require.NoError(t, err) + + require.Equal(t, filepath.ToSlash(filepath.Clean("tests/dockerfiles/debian")), *m["debian"].Context) + require.Equal(t, filepath.ToSlash(filepath.Clean("shared")), m["debian"].Contexts["shared"]) + + bo, err := TargetsToBuildOpt(m, &Input{}) + require.NoError(t, err) + require.Equal(t, filepath.ToSlash(filepath.Clean("tests/dockerfiles/debian")), bo["debian"].Inputs.ContextPath) + require.Equal(t, filepath.Join("tests", "dockerfiles", "debian", "Dockerfile"), bo["debian"].Inputs.DockerfilePath) +} + func TestOverrideMerge(t *testing.T) { fp := File{ Name: "docker-bake.hcl", diff --git a/commands/bake.go b/commands/bake.go index 76a077525..6fcde330d 100644 --- a/commands/bake.go +++ b/commands/bake.go @@ -12,6 +12,7 @@ import ( "os" "slices" "sort" + "strconv" "strings" "sync" "text/tabwriter" @@ -48,6 +49,7 @@ import ( const ( bakeEnvFileSeparator = "BUILDX_BAKE_PATH_SEPARATOR" bakeEnvFilePath = "BUILDX_BAKE_FILE" + bakeEnvFileRelative = "BUILDX_BAKE_FILE_RELATIVE_PATHS" ) type bakeOptions struct { @@ -225,9 +227,16 @@ func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in ba if err != nil { return err } + fileRelativePaths, err := bakeFileRelativePaths() + if err != nil { + return err + } + parseOpt := bake.ParseOpt{ + FileRelativePaths: fileRelativePaths, + } if in.list != "" { - cfg, pm, err := bake.ParseFiles(files, defaults, vars) + cfg, pm, err := bake.ParseFiles(files, defaults, vars, parseOpt) if err != nil { return err } @@ -246,7 +255,7 @@ func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in ba } } - tgts, grps, err := bake.ReadTargets(ctx, files, targets, overrides, defaults, vars, &ent) + tgts, grps, err := bake.ReadTargets(ctx, files, targets, overrides, defaults, vars, &ent, parseOpt) if err != nil { return err } @@ -674,6 +683,18 @@ func bakeArgs(args []string) (url, cmdContext string, targets []string) { return url, cmdContext, targets } +func bakeFileRelativePaths() (bool, error) { + v := strings.TrimSpace(os.Getenv(bakeEnvFileRelative)) + if v == "" { + return false, nil + } + enabled, err := strconv.ParseBool(v) + if err != nil { + return false, errors.Wrapf(err, "failed to parse %s value %q", bakeEnvFileRelative, v) + } + return enabled, nil +} + func readBakeFiles(ctx context.Context, nodes []builder.Node, url string, names []string, stdin io.Reader, pw progress.Writer, filesFromEnv bool) (files []bake.File, inp *bake.Input, err error) { var lnames []string // local var rnames []string // remote diff --git a/docs/bake-reference.md b/docs/bake-reference.md index e473b2d1b..832d28f31 100644 --- a/docs/bake-reference.md +++ b/docs/bake-reference.md @@ -416,6 +416,10 @@ target "app" { ``` This resolves to the current working directory (`"."`) by default. +Set `BUILDX_BAKE_FILE_RELATIVE_PATHS=1` to resolve local directory paths in +`target.context` and `target.contexts` relative to the directory of the first +Bake file. Use `cwd://` for paths that should remain relative to the current +working directory when this opt-in is enabled. ```console $ docker buildx bake --print -f - <<< 'target "default" {}' diff --git a/docs/reference/buildx_bake.md b/docs/reference/buildx_bake.md index 8c8633e32..30102a41a 100644 --- a/docs/reference/buildx_bake.md +++ b/docs/reference/buildx_bake.md @@ -153,6 +153,12 @@ This is mutually exclusive with `-f` / `--file`; if both are specified, the envi Multiple definitions can be specified by separating them with the system's path separator (typically `;` on Windows and `:` elsewhere), but can be changed with `BUILDX_BAKE_PATH_SEPARATOR`. +By default, local directory build contexts in Bake files are resolved from the +current working directory. To opt in to resolving local directory build contexts +from the directory of the first Bake file, set +`BUILDX_BAKE_FILE_RELATIVE_PATHS=1`. Use the `cwd://` prefix for paths that +should remain relative to the current working directory. + You can pass the names of the targets to build, to build only specific target(s). The following example builds the `db` and `webapp-release` targets that are defined in the `docker-bake.dev.hcl` file: diff --git a/tests/bake.go b/tests/bake.go index c8a1d1ee4..17ae3810c 100644 --- a/tests/bake.go +++ b/tests/bake.go @@ -45,6 +45,7 @@ var bakeTests = []func(t *testing.T, sb integration.Sandbox){ testBakePrintRemoteContextSubdir, testBakeLocal, testBakeLocalMulti, + testBakeFileRelativePaths, testBakeLocalExportDeleteMode, testBakeRemote, testBakeRemoteAuth, @@ -674,6 +675,76 @@ services: require.FileExists(t, filepath.Join(dirDest2, "foo")) } +func testBakeFileRelativePaths(t *testing.T, sb integration.Sandbox) { + t.Run("compose context", func(t *testing.T) { + dockerfile := []byte(` +FROM scratch +COPY marker /marker +COPY --from=shared shared-marker /shared-marker +`) + composefile := []byte(` +services: + debian: + build: + context: ./dockerfiles/debian + additional_contexts: + shared: ../shared +`) + + dir := tmpdir( + t, + fstest.CreateDir("tests", 0700), + fstest.CreateDir("tests/dockerfiles", 0700), + fstest.CreateDir("tests/dockerfiles/debian", 0700), + fstest.CreateDir("shared", 0700), + fstest.CreateFile("tests/docker-compose.yml", composefile, 0600), + fstest.CreateFile("tests/dockerfiles/debian/Dockerfile", dockerfile, 0600), + fstest.CreateFile("tests/dockerfiles/debian/marker", []byte("marker"), 0600), + fstest.CreateFile("shared/shared-marker", []byte("shared"), 0600), + ) + dirDest := t.TempDir() + + out, err := bakeCmd( + sb, + withDir(dir), + withArgs("--file", "tests/docker-compose.yml", "--set", "*.output=type=local,dest="+dirDest), + withEnv("BUILDX_BAKE_FILE_RELATIVE_PATHS=1"), + ) + require.NoError(t, err, out) + require.FileExists(t, filepath.Join(dirDest, "marker")) + require.FileExists(t, filepath.Join(dirDest, "shared-marker")) + }) + + t.Run("cwd prefix", func(t *testing.T) { + bakefile := []byte(` +target "default" { + context = "cwd://." + dockerfile-inline = <