From 6d54039414a6fe52492dc91539ae53a99efafe4c Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:58:42 +0200 Subject: [PATCH 1/2] bake: add file-relative path opt-in Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- bake/bake.go | 55 +++++++++++++++++++-- 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, 249 insertions(+), 5 deletions(-) diff --git a/bake/bake.go b/bake/bake.go index 66ef0a1d55b4..7d0604f06089 100644 --- a/bake/bake.go +++ b/bake/bake.go @@ -46,6 +46,10 @@ type File struct { Data []byte } +type ParseOpt struct { + FileRelativePaths bool +} + type Override struct { Value string ArrValue []string @@ -197,8 +201,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 +341,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 +417,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 filepath.ToSlash(filepath.Clean(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 de3fb9636e06..c6f433331b38 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 TestFileRelativePaths(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 TestFileRelativePathsDoesNotRebaseOverrides(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 TestComposeFileRelativePaths(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 76a077525593..6fcde330d1fd 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 e473b2d1ba30..832d28f312d7 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 8c8633e322e8..30102a41a830 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 c8a1d1ee4dd4..782162d80a96 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 file in subdirectory", 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 remains relative to invocation directory", func(t *testing.T) { + bakefile := []byte(` +target "default" { + context = "cwd://." + dockerfile-inline = < Date: Tue, 7 Jul 2026 09:01:09 +0200 Subject: [PATCH 2/2] bake: make file-relative paths definition-scoped Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- bake/bake.go | 104 ++++++++++++++++++++++++++++------ bake/bake_test.go | 72 ++++++++++++++++++++++- bake/compose.go | 46 ++++++++++++++- bake/hclparser/hclparser.go | 8 +++ docs/bake-reference.md | 6 +- docs/reference/buildx_bake.md | 2 +- tests/bake.go | 28 +++++++++ 7 files changed, 242 insertions(+), 24 deletions(-) diff --git a/bake/bake.go b/bake/bake.go index 7d0604f06089..7bfce8229284 100644 --- a/bake/bake.go +++ b/bake/bake.go @@ -346,6 +346,8 @@ func ParseFiles(files []File, defaults, vars map[string]string, opts ...ParseOpt err = formatHCLError(err, files) }() + frel := fileRelativePaths(opts) + var c Config var composeFiles []File var hclFiles []*hcl.File @@ -373,7 +375,7 @@ func ParseFiles(files []File, defaults, vars map[string]string, opts ...ParseOpt } if len(composeFiles) > 0 { - cfg, cmperr := ParseComposeFiles(composeFiles, vars) + cfg, cmperr := parseComposeFilesWithBase(composeFiles, vars, frel) if cmperr != nil { return nil, nil, errors.Wrap(cmperr, "failed to parse compose file") } @@ -417,41 +419,51 @@ func ParseFiles(files []File, defaults, vars map[string]string, opts ...ParseOpt pm = *res } - for _, opt := range opts { - if opt.FileRelativePaths { - rebaseContextPaths(&c, files) - break - } + if frel { + rebaseContextPaths(&c) } return &c, &pm, nil } -func rebaseContextPaths(c *Config, files []File) { - base, ok := firstLocalFileDir(files) - if !ok { - return - } +func fileRelativePaths(opts []ParseOpt) bool { + return slices.ContainsFunc(opts, func(opt ParseOpt) bool { + return opt.FileRelativePaths + }) +} + +func rebaseContextPaths(c *Config) { for _, t := range c.Targets { - if t.Context != nil { - contextPath := rebaseContextPath(base, *t.Context) + t.rebaseContextPaths() + } +} + +func (t *Target) rebaseContextPaths() { + if t.Context != nil { + if t.hasContextBase { + contextPath := rebaseContextPath(t.contextBase, *t.Context) t.Context = &contextPath } - for k, v := range t.Contexts { + } else if t.hasDefaultContextBase { + contextPath := rebaseContextPath(t.defaultContextBase, ".") + t.Context = &contextPath + } + for k, v := range t.Contexts { + if base, ok := t.contextsBase[k]; ok { t.Contexts[k] = rebaseContextPath(base, v) } } } -func firstLocalFileDir(files []File) (string, bool) { - if len(files) == 0 || files[0].Name == "-" || urlutil.IsRemoteURL(files[0].Name) { +func localFileDir(name string) (string, bool) { + if name == "" || name == "-" || urlutil.IsRemoteURL(name) { return "", false } - return filepath.Dir(files[0].Name), true + return filepath.Dir(name), true } func rebaseContextPath(base, p string) string { - if p == "" || isSpecialContextPath(p) || filepath.IsAbs(p) { + if base == "" || p == "" || isSpecialContextPath(p) || filepath.IsAbs(p) { return p } return filepath.ToSlash(filepath.Clean(filepath.Join(base, filepath.FromSlash(p)))) @@ -830,6 +842,12 @@ type Target struct { // linked is a private field to mark a target used as a linked one linked bool + + defaultContextBase string + hasDefaultContextBase bool + contextBase string + hasContextBase bool + contextsBase map[string]string } func (t *Target) MarshalJSON() ([]byte, error) { @@ -878,10 +896,46 @@ func (t *Target) MarshalJSON() ([]byte, error) { var ( _ hclparser.WithEvalContexts = &Target{} _ hclparser.WithGetName = &Target{} + _ hclparser.WithBlockSource = &Target{} _ hclparser.WithEvalContexts = &Group{} _ hclparser.WithGetName = &Group{} ) +func (t *Target) SetBlockSource(block *hcl.Block) { + base, _ := localFileDir(block.DefRange.Filename) + t.defaultContextBase = base + t.hasDefaultContextBase = true + + content, _, diags := block.Body.PartialContent(&hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + {Name: "context"}, + {Name: "contexts"}, + }, + }) + if diags.HasErrors() { + return + } + if _, ok := content.Attributes["context"]; ok { + t.contextBase = base + t.hasContextBase = true + } + if _, ok := content.Attributes["contexts"]; ok { + t.setContextsBase(base) + } +} + +func (t *Target) setContextsBase(base string) { + if len(t.Contexts) == 0 { + return + } + if t.contextsBase == nil { + t.contextsBase = map[string]string{} + } + for k := range t.Contexts { + t.contextsBase[k] = base + } +} + func (t *Target) normalize() { t.Annotations = removeDupesStr(t.Annotations) t.Attest = t.Attest.Normalize() @@ -912,8 +966,14 @@ func (t *Target) normalize() { } func (t *Target) Merge(t2 *Target) { + if t2.hasDefaultContextBase { + t.defaultContextBase = t2.defaultContextBase + t.hasDefaultContextBase = true + } if t2.Context != nil { t.Context = t2.Context + t.contextBase = t2.contextBase + t.hasContextBase = t2.hasContextBase } if t2.Dockerfile != nil { t.Dockerfile = t2.Dockerfile @@ -935,6 +995,14 @@ func (t *Target) Merge(t2 *Target) { t.Contexts = map[string]string{} } t.Contexts[k] = v + if t.contextsBase == nil { + t.contextsBase = map[string]string{} + } + if base, ok := t2.contextsBase[k]; ok { + t.contextsBase[k] = base + } else { + delete(t.contextsBase, k) + } } for k, v := range t2.Labels { if v == nil { diff --git a/bake/bake_test.go b/bake/bake_test.go index c6f433331b38..cecfeb27dc70 100644 --- a/bake/bake_test.go +++ b/bake/bake_test.go @@ -805,6 +805,70 @@ target "app" { require.Equal(t, filepath.ToSlash(filepath.Clean("shared")), bo["app"].Inputs.NamedContexts["shared"].Path) } +func TestFileRelativePathsDefaultContext(t *testing.T) { + fp := File{ + Name: filepath.Join("definitions", "docker-bake.hcl"), + Data: []byte(` +target "app" { + dockerfile-inline = < 0 { + var c Config + for _, f := range fs { + cfg, err := parseComposeFiles([]File{f}, envs) + if err != nil { + return nil, err + } + setComposeContextBase(cfg, f.Name) + c = mergeConfig(c, *cfg) + c = dedupeConfig(c) + } + return &c, nil + } + + return parseComposeFiles(fs, envs) +} + +func parseComposeFiles(fs []File, envs map[string]string) (*Config, error) { var cfgs []composetypes.ConfigFile for _, f := range fs { cfgs = append(cfgs, composetypes.ConfigFile{ @@ -36,6 +67,19 @@ func ParseComposeFiles(fs []File, envOverrides map[string]string) (*Config, erro return ParseCompose(cfgs, envs) } +func setComposeContextBase(c *Config, name string) { + base, _ := localFileDir(name) + for _, t := range c.Targets { + t.defaultContextBase = base + t.hasDefaultContextBase = true + if t.Context != nil { + t.contextBase = base + t.hasContextBase = true + } + t.setContextsBase(base) + } +} + func ParseCompose(cfgs []composetypes.ConfigFile, envs map[string]string) (*Config, error) { cfg, err := loadComposeFiles(cfgs, envs) if err != nil { diff --git a/bake/hclparser/hclparser.go b/bake/hclparser/hclparser.go index 6b62efad0521..ec7bf19036e0 100644 --- a/bake/hclparser/hclparser.go +++ b/bake/hclparser/hclparser.go @@ -91,6 +91,10 @@ type WithGetName interface { GetName(ectx *hcl.EvalContext, block *hcl.Block, loadDeps func(hcl.Expression) hcl.Diagnostics) (string, error) } +type WithBlockSource interface { + SetBlockSource(block *hcl.Block) +} + // errUndefined is returned when a variable or function is not defined. type errUndefined struct{} @@ -944,6 +948,10 @@ func Parse(b hcl.Body, opt Opt, val any) (*ParseMeta, hcl.Diagnostics) { vvs := p.blockValues[b] for _, vv := range vvs { + if v, ok := vv.Interface().(WithBlockSource); ok { + v.SetBlockSource(b) + } + t := types[b.Type] lblIndex, lblExists := getNameIndex(vv) lblName, _ := getName(vv) diff --git a/docs/bake-reference.md b/docs/bake-reference.md index 832d28f312d7..07594d2a77dc 100644 --- a/docs/bake-reference.md +++ b/docs/bake-reference.md @@ -417,9 +417,9 @@ 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. +`target.context` and `target.contexts` relative to the Bake or Compose file +that defines each path. 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 30102a41a830..5edb3fdb92a0 100644 --- a/docs/reference/buildx_bake.md +++ b/docs/reference/buildx_bake.md @@ -155,7 +155,7 @@ Multiple definitions can be specified by separating them with the system's path 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 +from the Bake or Compose file that defines each path, set `BUILDX_BAKE_FILE_RELATIVE_PATHS=1`. Use the `cwd://` prefix for paths that should remain relative to the current working directory. diff --git a/tests/bake.go b/tests/bake.go index 782162d80a96..a8e4d411a8fb 100644 --- a/tests/bake.go +++ b/tests/bake.go @@ -715,6 +715,34 @@ services: require.FileExists(t, filepath.Join(dirDest, "shared-marker")) }) + t.Run("default context in subdirectory", func(t *testing.T) { + bakefile := []byte(` +target "default" { + dockerfile-inline = <