diff --git a/bake/bake.go b/bake/bake.go index 66ef0a1d55b4..7bfce8229284 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,11 +341,13 @@ 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) }() + frel := fileRelativePaths(opts) + var c Config var composeFiles []File var hclFiles []*hcl.File @@ -369,7 +375,7 @@ func ParseFiles(files []File, defaults, vars map[string]string) (_ *Config, _ *h } 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") } @@ -413,9 +419,64 @@ func ParseFiles(files []File, defaults, vars map[string]string) (_ *Config, _ *h pm = *res } + if frel { + rebaseContextPaths(&c) + } + return &c, &pm, nil } +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 { + t.rebaseContextPaths() + } +} + +func (t *Target) rebaseContextPaths() { + if t.Context != nil { + if t.hasContextBase { + contextPath := rebaseContextPath(t.contextBase, *t.Context) + t.Context = &contextPath + } + } 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 localFileDir(name string) (string, bool) { + if name == "" || name == "-" || urlutil.IsRemoteURL(name) { + return "", false + } + return filepath.Dir(name), true +} + +func rebaseContextPath(base, p string) string { + if base == "" || 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)) @@ -781,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) { @@ -829,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() @@ -863,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 @@ -886,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 de3fb9636e06..cecfeb27dc70 100644 --- a/bake/bake_test.go +++ b/bake/bake_test.go @@ -763,6 +763,169 @@ 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 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/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..07594d2a77dc 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 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 8c8633e322e8..5edb3fdb92a0 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 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. + 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..a8e4d411a8fb 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,104 @@ 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("default context in subdirectory", func(t *testing.T) { + bakefile := []byte(` +target "default" { + dockerfile-inline = <