Repository: docker/buildx

Issue:
Support custom host-to-IP mappings in `bake` the same way it's done in `build --add-host`
See subject.
Ideally, it's better be implemented with `--set` on a per-target basis.


Hints from issue comments:
@johnbizokk: This feature could benefit virtually any virtual testing environment (from load testing to E2E testing), primarily if the remote host is behind the proxy and does not support transparent TCP proxying to the host. Services such as Cloudflare provide transparent TCP proxying, but that can come at the cost of a big buck. Therefore we would like to have an opportunity to remap some hosts during the building process and drop silly workarounds like semi-manual modification of hosts file. Moreover, this feature is an old citizen of the legacy builder.@johnbizokk: It turned out that we cannot modify the hosts file during build time because it is mounted in read-only mode. The last resort is well-designed software that allows the hosts to be overridden, such as cypress (E2E) and k6 (load testing).

Solution patch:
diff --git a/bake/bake.go b/bake/bake.go
index ca4cb277..32ec402b 100644
--- a/bake/bake.go
+++ b/bake/bake.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding"
 	"encoding/json"
+	"fmt"
 	"io"
 	"maps"
 	"os"
@@ -726,6 +727,7 @@ type Target struct {
 	Ulimits          []string                `json:"ulimits,omitempty" hcl:"ulimits,optional" cty:"ulimits"`
 	Call             *string                 `json:"call,omitempty" hcl:"call,optional" cty:"call"`
 	Entitlements     []string                `json:"entitlements,omitempty" hcl:"entitlements,optional" cty:"entitlements"`
+	ExtraHosts       map[string]*string      `json:"extra-hosts,omitempty" hcl:"extra-hosts,optional" cty:"extra-hosts"`
 	// IMPORTANT: if you add more fields here, do not forget to update newOverrides/AddOverrides and docs/bake-reference.md.
 
 	// linked is a private field to mark a target used as a linked one
@@ -764,6 +766,14 @@ func (t *Target) MarshalJSON() ([]byte, error) {
 		}
 	}
 
+	tgt.ExtraHosts = maps.Clone(t.ExtraHosts)
+	for k, v := range t.ExtraHosts {
+		if v != nil {
+			escaped := esc(*v)
+			tgt.ExtraHosts[k] = &escaped
+		}
+	}
+
 	return json.Marshal(tgt)
 }
 
@@ -894,6 +904,15 @@ func (t *Target) Merge(t2 *Target) {
 	if t2.Entitlements != nil { // merge
 		t.Entitlements = append(t.Entitlements, t2.Entitlements...)
 	}
+	for k, v := range t2.ExtraHosts {
+		if v == nil {
+			continue
+		}
+		if t.ExtraHosts == nil {
+			t.ExtraHosts = map[string]*string{}
+		}
+		t.ExtraHosts[k] = v
+	}
 	t.Inherits = append(t.Inherits, t2.Inherits...)
 }
 
@@ -1082,6 +1101,14 @@ func (t *Target) AddOverrides(overrides map[string]Override, ent *EntitlementCon
 				return errors.Errorf("invalid value %s for boolean key load", value)
 			}
 			t.Outputs = setLoadOverride(t.Outputs, load)
+		case "extra-hosts":
+			if len(keys) != 2 {
+				return errors.Errorf("invalid format for extra-hosts, expecting extra-hosts.<hostname>=<ip>")
+			}
+			if t.ExtraHosts == nil {
+				t.ExtraHosts = map[string]*string{}
+			}
+			t.ExtraHosts[keys[1]] = &value
 		default:
 			return errors.Errorf("unknown key: %s", keys[0])
 		}
@@ -1404,6 +1431,14 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) {
 		}
 	}
 
+	var extraHosts []string
+	for k, v := range t.ExtraHosts {
+		if v == nil {
+			continue
+		}
+		extraHosts = append(extraHosts, fmt.Sprintf("%s=%s", k, *v))
+	}
+
 	bo := &build.Options{
 		Inputs:        bi,
 		Tags:          t.Tags,
@@ -1415,6 +1450,7 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) {
 		NetworkMode:   networkMode,
 		Linked:        t.linked,
 		ShmSize:       *shmSize,
+		ExtraHosts:    extraHosts,
 	}
 
 	platforms, err := platformutil.Parse(t.Platforms)
diff --git a/bake/compose.go b/bake/compose.go
index 1ca046d0..b913d560 100644
--- a/bake/compose.go
+++ b/bake/compose.go
@@ -122,6 +122,16 @@ func ParseCompose(cfgs []composetypes.ConfigFile, envs map[string]string) (*Conf
 				}
 			}
 
+			extraHosts := map[string]*string{}
+			if s.Build.ExtraHosts != nil {
+				for k, v := range s.Build.ExtraHosts {
+					for _, ip := range v {
+						vv := ip
+						extraHosts[k] = &vv
+					}
+				}
+			}
+
 			var ssh []*buildflags.SSH
 			for _, bkey := range s.Build.SSH {
 				sshkey := composeToBuildkitSSH(bkey)
@@ -180,6 +190,7 @@ func ParseCompose(cfgs []composetypes.ConfigFile, envs map[string]string) (*Conf
 				Secrets:     secrets,
 				ShmSize:     shmSize,
 				Ulimits:     ulimits,
+				ExtraHosts:  extraHosts,
 			}
 			if err = t.composeExtTarget(s.Build.Extensions); err != nil {
 				return nil, err
diff --git a/docs/bake-reference.md b/docs/bake-reference.md
index 5245f611..22592ec3 100644
--- a/docs/bake-reference.md
+++ b/docs/bake-reference.md
@@ -227,6 +227,8 @@ The following table shows the complete list of attributes that you can assign to
 | [`description`](#targetdescription)             | String  | Description of a target                                              |
 | [`dockerfile-inline`](#targetdockerfile-inline) | String  | Inline Dockerfile string                                             |
 | [`dockerfile`](#targetdockerfile)               | String  | Dockerfile location                                                  |
+| [`entitlements`](#targetentitlements)           | List    | Permissions that the build process requires to run                   |
+| [`extra-hosts`](#targetextra-hosts)             | List    | Customs host-to-IP mapping                                           |
 | [`inherits`](#targetinherits)                   | List    | Inherit attributes from other targets                                |
 | [`labels`](#targetlabels)                       | Map     | Metadata for images                                                  |
 | [`matrix`](#targetmatrix)                       | Map     | Define a set of variables that forks a target into multiple targets. |
@@ -583,6 +585,20 @@ target "integration-tests" {
 
 Entitlements are enabled with a two-step process. First, a target must declare the entitlements it requires. Secondly, when invoking the `bake` command, the user must grant the entitlements by passing the `--allow` flag or confirming the entitlements when prompted in an interactive terminal. This is to ensure that the user is aware of the possibly insecure permissions they are granting to the build process.
 
+### `target.extra-hosts`
+
+Use the `extra-hosts` attribute to define customs host-to-IP mapping for the
+target. This has the same effect as passing a [`--add-host`][add-host] flag to
+the build command.
+
+```hcl
+target "default" {
+  extra-hosts = {
+    my_hostname = "8.8.8.8"
+  }
+}
+```
+
 ### `target.inherits`
 
 A target can inherit attributes from other targets.
@@ -1422,6 +1438,7 @@ target "webapp-dev" {
 
 <!-- external links -->
 
+[add-host]: https://docs.docker.com/reference/cli/docker/buildx/build/#add-host
 [attestations]: https://docs.docker.com/build/attestations/
 [bake_stdlib]: https://github.com/docker/buildx/blob/master/bake/hclparser/stdlib.go
 [build-arg]: https://docs.docker.com/reference/cli/docker/image/build/#build-arg
diff --git a/docs/reference/buildx_bake.md b/docs/reference/buildx_bake.md
index 189866b2..ab896703 100644
--- a/docs/reference/buildx_bake.md
+++ b/docs/reference/buildx_bake.md
@@ -366,6 +366,7 @@ You can override the following fields:
 * `context`
 * `dockerfile`
 * `entitlements`
+* `extra-hosts`
 * `labels`
 * `load`
 * `no-cache`



Test patch:
diff --git a/bake/bake_test.go b/bake/bake_test.go
index 482b83ad..ec3b7ec1 100644
--- a/bake/bake_test.go
+++ b/bake/bake_test.go
@@ -27,6 +27,9 @@ target "webDEP" {
 	no-cache = true
 	shm-size = "128m"
 	ulimits = ["nofile=1024:1024"]
+	extra-hosts = {
+		my_hostname = "8.8.8.8"
+	}
 }
 
 target "webapp" {
@@ -64,6 +67,7 @@ target "webapp" {
 		require.Equal(t, true, *m["webapp"].NoCache)
 		require.Equal(t, "128m", *m["webapp"].ShmSize)
 		require.Equal(t, []string{"nofile=1024:1024"}, m["webapp"].Ulimits)
+		require.Equal(t, map[string]*string{"my_hostname": ptrstr("8.8.8.8")}, m["webapp"].ExtraHosts)
 		require.Nil(t, m["webapp"].Pull)
 
 		require.Equal(t, 1, len(g))
diff --git a/bake/compose_test.go b/bake/compose_test.go
index e19ff138..6e08fdc4 100644
--- a/bake/compose_test.go
+++ b/bake/compose_test.go
@@ -32,6 +32,9 @@ services:
         - type=local,src=path/to/cache
       cache_to:
         - type=local,dest=path/to/cache
+      extra_hosts:
+        - "somehost:162.242.195.82"
+        - "myhostv6:::1"
       ssh:
         - key=/path/to/key
         - default
@@ -76,6 +79,7 @@ secrets:
 	require.Equal(t, ptrstr("123"), c.Targets[1].Args["buildno"])
 	require.Equal(t, []string{"type=local,src=path/to/cache"}, stringify(c.Targets[1].CacheFrom))
 	require.Equal(t, []string{"type=local,dest=path/to/cache"}, stringify(c.Targets[1].CacheTo))
+	require.Equal(t, map[string]*string{"myhostv6": ptrstr("::1"), "somehost": ptrstr("162.242.195.82")}, c.Targets[1].ExtraHosts)
 	require.Equal(t, "none", *c.Targets[1].NetworkMode)
 	require.Equal(t, []string{"default", "key=/path/to/key"}, stringify(c.Targets[1].SSH))
 	require.Equal(t, []string{
diff --git a/tests/bake.go b/tests/bake.go
index ca9902a4..71df5e86 100644
--- a/tests/bake.go
+++ b/tests/bake.go
@@ -80,6 +80,7 @@ var bakeTests = []func(t *testing.T, sb integration.Sandbox){
 	testBakeCallMetadata,
 	testBakeMultiPlatform,
 	testBakeCheckCallOutput,
+	testBakeExtraHosts,
 }
 
 func testBakePrint(t *testing.T, sb integration.Sandbox) {
@@ -2162,6 +2163,31 @@ target "third" {
 	})
 }
 
+func testBakeExtraHosts(t *testing.T, sb integration.Sandbox) {
+	dockerfile := []byte(`
+FROM busybox
+RUN cat /etc/hosts | grep myhost | grep 1.2.3.4
+	`)
+	bakefile := []byte(`
+target "default" {
+  extra-hosts = {
+    myhost = "1.2.3.4"
+  }
+}
+`)
+	dir := tmpdir(
+		t,
+		fstest.CreateFile("docker-bake.hcl", bakefile, 0600),
+		fstest.CreateFile("Dockerfile", dockerfile, 0600),
+	)
+
+	out, err := bakeCmd(
+		sb,
+		withDir(dir),
+	)
+	require.NoError(t, err, out)
+}
+
 func writeTempPrivateKey(fp string) error {
 	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
 	if err != nil {