Repository: docker/buildx

Issue:
docker-bake.json applying HCL string templates
### Contributing guidelines

- [X] I've read the [contributing guidelines](https://github.com/docker/buildx/blob/master/.github/CONTRIBUTING.md) and wholeheartedly agree

### I've found a bug and checked that ...

- [X] ... the documentation does not mention anything about my problem
- [X] ... there are no open or closed issues that are related to my problem

### Description

When using `docker bake` with a JSON docker bake config, strings are being parsed as if they were HCL strings (applying HCL string template interpolation / directives).

### Expected behaviour

Expected the JSON file to be parsed as a JSON file, such that strings on it are not further processed as HCL templates. In the example below, I expect the image to be build and labeled with the `foo` label getting the literal value `%{bar}`.

### Actual behaviour

On the example below, parsing the docker-bake.json fails, as it tries to parse as HCL template.

### Buildx version

github.com/docker/buildx v0.13.0 0de5f1c

### Docker info

```text
docker info          
Client: Docker Engine - Community
 Version:    25.0.4
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.13.0
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v2.24.7
    Path:     /usr/libexec/docker/cli-plugins/docker-compose

Server:
 Containers: 14
  Running: 0
  Paused: 0
  Stopped: 14
 Images: 55
 Server Version: 25.0.4
 Storage Driver: overlay2
  Backing Filesystem: btrfs
  Supports d_type: true
  Using metacopy: false
  Native Overlay Diff: true
  userxattr: false
 Logging Driver: json-file
 Cgroup Driver: cgroupfs
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local splunk syslog
 Swarm: inactive
 Runtimes: io.containerd.runc.v2 runc
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: d2d58213f83a351ca8f528a95fbd145f5654e957
 runc version: v1.1.12-0-g51d5e94
 init version: de40ad0
 Security Options:
  seccomp
   Profile: builtin
  cgroupns
 Kernel Version: 6.7.11-orbstack-00143-ge6b82e26cd22
 Operating System: Ubuntu 20.04.6 LTS
 OSType: linux
 Architecture: aarch64
 CPUs: 12
 Total Memory: 19.5GiB
 Name: dc773ac7bce3
 ID: daf97268-48c1-44df-a7ce-b588fa71dcfb
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Experimental: false
 Insecure Registries:
  127.0.0.0/8
 Live Restore Enabled: false
```


### Builders list

```text
NAME/NODE                            DRIVER/ENDPOINT                   STATUS     BUILDKIT   PLATFORMS  
default*                             docker                                                  
 \_ default                           \_ default                       running    v0.12.5    linux/arm64, linux/amd64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/arm/v7, linux/arm/v6
```


### Configuration

docker-bake.json

```
{
  "target": {
    "default": {
      "dockerfile": "Dockerfile",
      "tags": ["webapp:latest"],
      "labels": {
        "foo": "%{bar}"
      }
    }
  }
}
```


### Build logs

```text
$ docker buildx bake
[+] Building 0.0s (1/1) FINISHED                                                                                                                                     docker:default
 => [internal] load local bake definitions                                                                                                                                     0.0s
 => => reading docker-bake.json 162B / 162B                                                                                                                                    0.0s
docker-bake.json:7
--------------------
   5 |           "tags": ["webapp:latest"],
   6 |           "labels": {
   7 | >>>         "foo": "%{bar}"
   8 |           }
   9 |         }
--------------------
ERROR: docker-bake.json:7,19-22: Invalid template control keyword; "bar" is not a valid template control keyword. Did you mean "for"?, and 1 other diagnostic(s)
```


### Additional info

We use the json format of `docker bake` as we prefer to programmatically generate JSON instead of manually editing HCL. I expect that this would be the most common case for using JSON.

When generating JSON config programmatically, applying HCL string templates is both undesired (we would prefer to do any manipulation on the input before generating the JSON) and also dangerous (it can end up violating validations that we perform before generating the JSON file).

If the current behavior is working as intended, it should at least be documented as such, as the behavior is surprising when programmatically generating raw JSON objects.

Discussion:
@tonistiigi: I think there are cases where access to variables and directives would be useful from JSON format. If you want raw strings then these can be escaped with `%%` or `$$`.

It does look that atm when using `--print` with an escaped value we would print the `"%{bar}"` directly. That does not look correct as I think the output of `--print` should be usable as input. I think we should escape the output of `--print` (unless we want to make loading raw json vs JSON-with-templates configurable somehow).

Solution patch:
diff --git a/bake/bake.go b/bake/bake.go
index df9c559e..3eb41158 100644
--- a/bake/bake.go
+++ b/bake/bake.go
@@ -3,6 +3,7 @@ package bake
 import (
 	"context"
 	"encoding"
+	"encoding/json"
 	"io"
 	"maps"
 	"os"
@@ -733,6 +734,41 @@ type Target struct {
 	linked bool
 }
 
+func (t *Target) MarshalJSON() ([]byte, error) {
+	tgt := *t
+	esc := func(s string) string {
+		return strings.ReplaceAll(strings.ReplaceAll(s, "${", "$${"), "%{", "%%{")
+	}
+
+	tgt.Annotations = slices.Clone(t.Annotations)
+	for i, v := range tgt.Annotations {
+		tgt.Annotations[i] = esc(v)
+	}
+
+	if tgt.DockerfileInline != nil {
+		escaped := esc(*tgt.DockerfileInline)
+		tgt.DockerfileInline = &escaped
+	}
+
+	tgt.Labels = maps.Clone(t.Labels)
+	for k, v := range t.Labels {
+		if v != nil {
+			escaped := esc(*v)
+			tgt.Labels[k] = &escaped
+		}
+	}
+
+	tgt.Args = maps.Clone(t.Args)
+	for k, v := range t.Args {
+		if v != nil {
+			escaped := esc(*v)
+			tgt.Args[k] = &escaped
+		}
+	}
+
+	return json.Marshal(tgt)
+}
+
 var (
 	_ hclparser.WithEvalContexts = &Target{}
 	_ hclparser.WithGetName      = &Target{}



Test patch:
diff --git a/tests/bake.go b/tests/bake.go
index a7ee8a73..7065726a 100644
--- a/tests/bake.go
+++ b/tests/bake.go
@@ -40,6 +40,7 @@ var bakeTests = []func(t *testing.T, sb integration.Sandbox){
 	testBakePrint,
 	testBakePrintSensitive,
 	testBakePrintOverrideEmpty,
+	testBakePrintKeepEscaped,
 	testBakeLocal,
 	testBakeLocalMulti,
 	testBakeRemote,
@@ -329,6 +330,66 @@ target "default" {
 }`, stdout.String())
 }
 
+func testBakePrintKeepEscaped(t *testing.T, sb integration.Sandbox) {
+	bakefile := []byte(`
+target "default" {
+	dockerfile-inline = <<EOT
+ARG VERSION=latest
+FROM alpine:$${VERSION}
+EOT
+	args = {
+		VERSION = "3.21"
+	}
+	annotations = [
+		"org.opencontainers.image.authors=$${user}"
+	]
+	labels = {
+		foo = "hello %%{bar}"
+	}
+}
+`)
+
+	dir := tmpdir(t, fstest.CreateFile("docker-bake.hcl", bakefile, 0600))
+	cmd := buildxCmd(sb, withDir(dir), withArgs("bake", "--print"))
+	stdout := bytes.Buffer{}
+	stderr := bytes.Buffer{}
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	require.NoError(t, cmd.Run(), stdout.String(), stderr.String())
+
+	require.JSONEq(t, `{
+	"group": {
+		"default": {
+			"targets": [
+				"default"
+			]
+		}
+	},
+	"target": {
+		"default": {
+			"annotations": [
+				"org.opencontainers.image.authors=$${user}"
+			],
+			"context": ".",
+			"dockerfile": "Dockerfile",
+			"dockerfile-inline": "ARG VERSION=latest\nFROM alpine:$${VERSION}\n",
+			"args": {
+				"VERSION": "3.21"
+			},
+			"labels": {
+				"foo": "hello %%{bar}"
+			}
+		}
+	}
+}`, stdout.String())
+
+	// test build with definition from print output
+	dir = tmpdir(t, fstest.CreateFile("docker-bake.json", stdout.Bytes(), 0600))
+	cmd = buildxCmd(sb, withDir(dir), withArgs("bake"))
+	out, err := cmd.CombinedOutput()
+	require.NoError(t, err, string(out))
+}
+
 func testBakeLocal(t *testing.T, sb integration.Sandbox) {
 	dockerfile := []byte(`
 FROM scratch