Repository: compose-spec/compose-go
Pull request #563 (2024-02-05 17:41:45)

host list can be used to declare both IPv4 and IPv6 for same hostname
`HostList` was refactored as a `map[string]` but we missed the case where a single hostname can be mapped to multiple IPs, especially as one can have both IPv4 and IPv6 addresses


Patch:
diff --git a/types/hostList.go b/types/hostList.go
index 68692b7..e224b81 100644
--- a/types/hostList.go
+++ b/types/hostList.go
@@ -24,7 +24,33 @@ import (
 )
 
 // HostsList is a list of colon-separated host-ip mappings
-type HostsList map[string]string
+type HostsList map[string][]string
+
+// NewHostsList creates a HostsList from a list of `host=ip` strings
+func NewHostsList(hosts []string) (HostsList, error) {
+	list := HostsList{}
+	for _, s := range hosts {
+		var found bool
+		for _, sep := range hostListSerapators {
+			host, ip, ok := strings.Cut(s, sep)
+			if ok {
+				// Mapping found with this separator, stop here.
+				if ips, ok := list[host]; ok {
+					list[host] = append(ips, ip)
+				} else {
+					list[host] = []string{ip}
+				}
+				found = true
+				break
+			}
+		}
+		if !found {
+			return nil, fmt.Errorf("invalid additional host, missing IP: %s", s)
+		}
+	}
+	err := list.cleanup()
+	return list, err
+}
 
 // AsList returns host-ip mappings as a list of strings, using the given
 // separator. The Docker Engine API expects ':' separators, the original format
@@ -34,7 +60,9 @@ type HostsList map[string]string
 func (h HostsList) AsList(sep string) []string {
 	l := make([]string, 0, len(h))
 	for k, v := range h {
-		l = append(l, fmt.Sprintf("%s%s%s", k, sep, v))
+		for _, ip := range v {
+			l = append(l, fmt.Sprintf("%s%s%s", k, sep, ip))
+		}
 	}
 	return l
 }
@@ -51,6 +79,8 @@ func (h HostsList) MarshalJSON() ([]byte, error) {
 	return json.Marshal(list)
 }
 
+var hostListSerapators = []string{"=", ":"}
+
 func (h *HostsList) DecodeMapstructure(value interface{}) error {
 	switch v := value.(type) {
 	case map[string]interface{}:
@@ -59,25 +89,45 @@ func (h *HostsList) DecodeMapstructure(value interface{}) error {
 			if e == nil {
 				e = ""
 			}
-			list[i] = fmt.Sprint(e)
+			list[i] = []string{fmt.Sprint(e)}
+		}
+		err := list.cleanup()
+		if err != nil {
+			return err
 		}
 		*h = list
+		return nil
 	case []interface{}:
-		*h = decodeMapping(v, "=", ":")
+		s := make([]string, len(v))
+		for i, e := range v {
+			s[i] = fmt.Sprint(e)
+		}
+		list, err := NewHostsList(s)
+		if err != nil {
+			return err
+		}
+		*h = list
+		return nil
 	default:
 		return fmt.Errorf("unexpected value type %T for mapping", value)
 	}
-	for host, ip := range *h {
+}
+
+func (h HostsList) cleanup() error {
+	for host, ips := range h {
 		// Check that there is a hostname and that it doesn't contain either
 		// of the allowed separators, to generate a clearer error than the
 		// engine would do if it splits the string differently.
 		if host == "" || strings.ContainsAny(host, ":=") {
 			return fmt.Errorf("bad host name '%s'", host)
 		}
-		// Remove brackets from IP addresses (for example "[::1]" -> "::1").
-		if len(ip) > 2 && ip[0] == '[' && ip[len(ip)-1] == ']' {
-			(*h)[host] = ip[1 : len(ip)-1]
+		for i, ip := range ips {
+			// Remove brackets from IP addresses (for example "[::1]" -> "::1").
+			if len(ip) > 2 && ip[0] == '[' && ip[len(ip)-1] == ']' {
+				ips[i] = ip[1 : len(ip)-1]
+			}
 		}
+		h[host] = ips
 	}
 	return nil
 }


Test patch:
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index a05aabf..01aa026 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -193,8 +193,8 @@ func services(workingDir, homeDir string) types.Services {
 				"project_db_1:postgresql",
 			},
 			ExtraHosts: types.HostsList{
-				"somehost":  "162.242.195.82",
-				"otherhost": "50.31.209.229",
+				"somehost":  []string{"162.242.195.82"},
+				"otherhost": []string{"50.31.209.229"},
 			},
 			Extensions: map[string]interface{}{
 				"x-bar": "baz",
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 46082cd..e70a320 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -1454,8 +1454,8 @@ services:
 	assert.NilError(t, err)
 
 	expected := types.HostsList{
-		"alpha": "50.31.209.229",
-		"zulu":  "162.242.195.82",
+		"alpha": []string{"50.31.209.229"},
+		"zulu":  []string{"162.242.195.82"},
 	}
 
 	assert.Assert(t, is.Len(config.Services, 1))
@@ -1470,13 +1470,14 @@ services:
     image: busybox
     extra_hosts:
       - "alpha:50.31.209.229"
+      - "zulu:127.0.0.2"
       - "zulu:ff02::1"
 `)
 	assert.NilError(t, err)
 
 	expected := types.HostsList{
-		"alpha": "50.31.209.229",
-		"zulu":  "ff02::1",
+		"alpha": []string{"50.31.209.229"},
+		"zulu":  []string{"127.0.0.2", "ff02::1"},
 	}
 
 	assert.Assert(t, is.Len(config.Services, 1))
diff --git a/types/hostList_test.go b/types/hostList_test.go
index 0295bda..b32e350 100644
--- a/types/hostList_test.go
+++ b/types/hostList_test.go
@@ -25,107 +25,103 @@ import (
 	is "gotest.tools/v3/assert/cmp"
 )
 
-func TestHostsList(t *testing.T) {
+func TestHostsListEqual(t *testing.T) {
+	testHostsList(t, "=")
+}
+
+func TestHostsListComa(t *testing.T) {
+	testHostsList(t, ":")
+}
+
+func testHostsList(t *testing.T, sep string) {
 	testCases := []struct {
 		doc           string
-		input         map[string]any
+		input         []string
 		expectedError string
 		expectedOut   string
 	}{
 		{
 			doc:         "IPv4",
-			input:       map[string]any{"myhost": "192.168.0.1"},
+			input:       []string{"myhost" + sep + "192.168.0.1"},
 			expectedOut: "myhost:192.168.0.1",
 		},
 		{
 			doc:         "Weird but permitted, IPv4 with brackets",
-			input:       map[string]any{"myhost": "[192.168.0.1]"},
+			input:       []string{"myhost" + sep + "[192.168.0.1]"},
 			expectedOut: "myhost:192.168.0.1",
 		},
 		{
 			doc:         "Host and domain",
-			input:       map[string]any{"host.invalid": "10.0.2.1"},
+			input:       []string{"host.invalid" + sep + "10.0.2.1"},
 			expectedOut: "host.invalid:10.0.2.1",
 		},
 		{
 			doc:         "IPv6",
-			input:       map[string]any{"anipv6host": "2003:ab34:e::1"},
+			input:       []string{"anipv6host" + sep + "2003:ab34:e::1"},
 			expectedOut: "anipv6host:2003:ab34:e::1",
 		},
 		{
 			doc:         "IPv6, brackets",
-			input:       map[string]any{"anipv6host": "[2003:ab34:e::1]"},
+			input:       []string{"anipv6host" + sep + "[2003:ab34:e::1]"},
 			expectedOut: "anipv6host:2003:ab34:e::1",
 		},
 		{
 			doc:         "IPv6 localhost",
-			input:       map[string]any{"ipv6local": "::1"},
+			input:       []string{"ipv6local" + sep + "::1"},
 			expectedOut: "ipv6local:::1",
 		},
 		{
 			doc:         "IPv6 localhost, brackets",
-			input:       map[string]any{"ipv6local": "[::1]"},
+			input:       []string{"ipv6local" + sep + "[::1]"},
 			expectedOut: "ipv6local:::1",
 		},
 		{
 			doc:         "host-gateway special case",
-			input:       map[string]any{"host.docker.internal": "host-gateway"},
+			input:       []string{"host.docker.internal" + sep + "host-gateway"},
 			expectedOut: "host.docker.internal:host-gateway",
 		},
 		{
 			doc: "multiple inputs",
-			input: map[string]any{
-				"myhost":               "192.168.0.1",
-				"anipv6host":           "[2003:ab34:e::1]",
-				"host.docker.internal": "host-gateway",
+			input: []string{
+				"myhost" + sep + "192.168.0.1",
+				"anipv6host" + sep + "[2003:ab34:e::1]",
+				"host.docker.internal" + sep + "host-gateway",
 			},
 			expectedOut: "anipv6host:2003:ab34:e::1 host.docker.internal:host-gateway myhost:192.168.0.1",
 		},
-		{
-			// This won't work, but address validation is left to the engine.
-			doc:         "no ip",
-			input:       map[string]any{"myhost": nil},
-			expectedOut: "myhost:",
-		},
 		{
 			doc:           "bad host, colon",
-			input:         map[string]any{":": "::1"},
+			input:         []string{"::::1"},
 			expectedError: "bad host name",
 		},
 		{
 			doc:           "bad host, eq",
-			input:         map[string]any{"=": "::1"},
+			input:         []string{"=::1"},
 			expectedError: "bad host name",
 		},
-	}
-
-	inputAsList := func(input map[string]any, sep string) []any {
-		result := make([]any, 0, len(input))
-		for host, ip := range input {
-			if ip == nil {
-				result = append(result, host+sep)
-			} else {
-				result = append(result, host+sep+ip.(string))
-			}
-		}
-		return result
+		{
+			doc: "both ipv4 and ipv6",
+			input: []string{
+				"foo:127.0.0.2",
+				"foo:ff02::1",
+			},
+			expectedOut: "foo:127.0.0.2 foo:ff02::1",
+		},
 	}
 
 	for _, tc := range testCases {
-		// Decode the input map, check the output is as-expected.
-		var hlFromMap HostsList
-		t.Run(tc.doc+"_map", func(t *testing.T) {
-			err := hlFromMap.DecodeMapstructure(tc.input)
+		t.Run(tc.doc, func(t *testing.T) {
+			hostlist, err := NewHostsList(tc.input)
 			if tc.expectedError == "" {
 				assert.NilError(t, err)
-				actualOut := hlFromMap.AsList(":")
+				actualOut := hostlist.AsList(":")
 				sort.Strings(actualOut)
 				sortedActualStr := strings.Join(actualOut, " ")
 				assert.Check(t, is.Equal(sortedActualStr, tc.expectedOut))
 
 				// The YAML rendering of HostsList should be the same as the AsList() output, but
 				// with '=' separators.
-				yamlOut, err := hlFromMap.MarshalYAML()
+				yamlOut, err := hostlist.MarshalYAML()
 				assert.NilError(t, err)
 				expYAMLOut := make([]string, len(actualOut))
 				for i, s := range actualOut {
@@ -135,7 +131,7 @@ func TestHostsList(t *testing.T) {
 
 				// The JSON rendering of HostsList should also have '=' separators. Same as the
 				// YAML output, but as a JSON list of strings.
-				jsonOut, err := hlFromMap.MarshalJSON()
+				jsonOut, err := hostlist.MarshalJSON()
 				assert.NilError(t, err)
 				expJSONStrings := make([]string, len(expYAMLOut))
 				for i, s := range expYAMLOut {
@@ -147,31 +143,5 @@ func TestHostsList(t *testing.T) {
 				assert.ErrorContains(t, err, tc.expectedError)
 			}
 		})
-
-		// Convert the input into a ':' separated list, check that the result is the same
-		// as for the map-input.
-		t.Run(tc.doc+"_colon_sep", func(t *testing.T) {
-			var hl HostsList
-			err := hl.DecodeMapstructure(inputAsList(tc.input, ":"))
-			if tc.expectedError == "" {
-				assert.NilError(t, err)
-				assert.DeepEqual(t, hl, hlFromMap)
-			} else {
-				assert.ErrorContains(t, err, tc.expectedError)
-			}
-		})
-
-		// Convert the input into a ':' separated list, check that the result is the same
-		// as for the map-input.
-		t.Run(tc.doc+"_eq_sep", func(t *testing.T) {
-			var hl HostsList
-			err := hl.DecodeMapstructure(inputAsList(tc.input, "="))
-			if tc.expectedError == "" {
-				assert.NilError(t, err)
-				assert.DeepEqual(t, hl, hlFromMap)
-			} else {
-				assert.ErrorContains(t, err, tc.expectedError)
-			}
-		})
 	}
 }