Skip to content

Commit a5007e7

Browse files
robmryndeloof
authored andcommitted
Permit '=' separator and '[ipv6]' in 'extra_hosts'.
Fixes docker/cli#4648 Align the format of 'extra_hosts' strings with '--add-hosts' options in the docker CLI and buildx - by permitting 'host=ip' in addition to 'host:ip', and allowing square brackets around the address. For example: extra_hosts: - "my-host1:127.0.0.1" - "my-host2:::1" - "my-host3=::1" - "my-host4=[::1]" Signed-off-by: Rob Murray <[email protected]>
1 parent 6e38069 commit a5007e7

File tree

4 files changed

+218
-15
lines changed

4 files changed

+218
-15
lines changed

loader/full-struct_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -738,8 +738,8 @@ services:
738738
- project_db_1:mysql
739739
- project_db_1:postgresql
740740
extra_hosts:
741-
- otherhost:50.31.209.229
742-
- somehost:162.242.195.82
741+
- otherhost=50.31.209.229
742+
- somehost=162.242.195.82
743743
hostname: foo
744744
healthcheck:
745745
test:
@@ -1336,8 +1336,8 @@ func fullExampleJSON(workingDir, homeDir string) string {
13361336
"project_db_1:postgresql"
13371337
],
13381338
"extra_hosts": [
1339-
"otherhost:50.31.209.229",
1340-
"somehost:162.242.195.82"
1339+
"otherhost=50.31.209.229",
1340+
"somehost=162.242.195.82"
13411341
],
13421342
"hostname": "foo",
13431343
"healthcheck": {

types/hostList.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,33 @@ import (
2020
"encoding/json"
2121
"fmt"
2222
"sort"
23+
"strings"
2324
)
2425

2526
// HostsList is a list of colon-separated host-ip mappings
2627
type HostsList map[string]string
2728

28-
// AsList return host-ip mappings as a list of colon-separated strings
29-
func (h HostsList) AsList() []string {
29+
// AsList returns host-ip mappings as a list of strings, using the given
30+
// separator. The Docker Engine API expects ':' separators, the original format
31+
// for '--add-hosts'. But an '=' separator is used in YAML/JSON renderings to
32+
// make IPv6 addresses more readable (for example "my-host=::1" instead of
33+
// "my-host:::1").
34+
func (h HostsList) AsList(sep string) []string {
3035
l := make([]string, 0, len(h))
3136
for k, v := range h {
32-
l = append(l, fmt.Sprintf("%s:%s", k, v))
37+
l = append(l, fmt.Sprintf("%s%s%s", k, sep, v))
3338
}
3439
return l
3540
}
3641

3742
func (h HostsList) MarshalYAML() (interface{}, error) {
38-
list := h.AsList()
43+
list := h.AsList("=")
3944
sort.Strings(list)
4045
return list, nil
4146
}
4247

4348
func (h HostsList) MarshalJSON() ([]byte, error) {
44-
list := h.AsList()
49+
list := h.AsList("=")
4550
sort.Strings(list)
4651
return json.Marshal(list)
4752
}
@@ -58,9 +63,21 @@ func (h *HostsList) DecodeMapstructure(value interface{}) error {
5863
}
5964
*h = list
6065
case []interface{}:
61-
*h = decodeMapping(v, ":")
66+
*h = decodeMapping(v, "=", ":")
6267
default:
6368
return fmt.Errorf("unexpected value type %T for mapping", value)
6469
}
70+
for host, ip := range *h {
71+
// Check that there is a hostname and that it doesn't contain either
72+
// of the allowed separators, to generate a clearer error than the
73+
// engine would do if it splits the string differently.
74+
if host == "" || strings.ContainsAny(host, ":=") {
75+
return fmt.Errorf("bad host name '%s'", host)
76+
}
77+
// Remove brackets from IP addresses (for example "[::1]" -> "::1").
78+
if len(ip) > 2 && ip[0] == '[' && ip[len(ip)-1] == ']' {
79+
(*h)[host] = ip[1 : len(ip)-1]
80+
}
81+
}
6582
return nil
6683
}

types/hostList_test.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
Copyright 2020 The Compose Specification Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package types
18+
19+
import (
20+
"sort"
21+
"strings"
22+
"testing"
23+
24+
"gotest.tools/v3/assert"
25+
is "gotest.tools/v3/assert/cmp"
26+
)
27+
28+
func TestHostsList(t *testing.T) {
29+
testCases := []struct {
30+
doc string
31+
input map[string]any
32+
expectedError string
33+
expectedOut string
34+
}{
35+
{
36+
doc: "IPv4",
37+
input: map[string]any{"myhost": "192.168.0.1"},
38+
expectedOut: "myhost:192.168.0.1",
39+
},
40+
{
41+
doc: "Weird but permitted, IPv4 with brackets",
42+
input: map[string]any{"myhost": "[192.168.0.1]"},
43+
expectedOut: "myhost:192.168.0.1",
44+
},
45+
{
46+
doc: "Host and domain",
47+
input: map[string]any{"host.invalid": "10.0.2.1"},
48+
expectedOut: "host.invalid:10.0.2.1",
49+
},
50+
{
51+
doc: "IPv6",
52+
input: map[string]any{"anipv6host": "2003:ab34:e::1"},
53+
expectedOut: "anipv6host:2003:ab34:e::1",
54+
},
55+
{
56+
doc: "IPv6, brackets",
57+
input: map[string]any{"anipv6host": "[2003:ab34:e::1]"},
58+
expectedOut: "anipv6host:2003:ab34:e::1",
59+
},
60+
{
61+
doc: "IPv6 localhost",
62+
input: map[string]any{"ipv6local": "::1"},
63+
expectedOut: "ipv6local:::1",
64+
},
65+
{
66+
doc: "IPv6 localhost, brackets",
67+
input: map[string]any{"ipv6local": "[::1]"},
68+
expectedOut: "ipv6local:::1",
69+
},
70+
{
71+
doc: "host-gateway special case",
72+
input: map[string]any{"host.docker.internal": "host-gateway"},
73+
expectedOut: "host.docker.internal:host-gateway",
74+
},
75+
{
76+
doc: "multiple inputs",
77+
input: map[string]any{
78+
"myhost": "192.168.0.1",
79+
"anipv6host": "[2003:ab34:e::1]",
80+
"host.docker.internal": "host-gateway",
81+
},
82+
expectedOut: "anipv6host:2003:ab34:e::1 host.docker.internal:host-gateway myhost:192.168.0.1",
83+
},
84+
{
85+
// This won't work, but address validation is left to the engine.
86+
doc: "no ip",
87+
input: map[string]any{"myhost": nil},
88+
expectedOut: "myhost:",
89+
},
90+
{
91+
doc: "bad host, colon",
92+
input: map[string]any{":": "::1"},
93+
expectedError: "bad host name",
94+
},
95+
{
96+
doc: "bad host, eq",
97+
input: map[string]any{"=": "::1"},
98+
expectedError: "bad host name",
99+
},
100+
}
101+
102+
inputAsList := func(input map[string]any, sep string) []any {
103+
result := make([]any, 0, len(input))
104+
for host, ip := range input {
105+
if ip == nil {
106+
result = append(result, host+sep)
107+
} else {
108+
result = append(result, host+sep+ip.(string))
109+
}
110+
}
111+
return result
112+
}
113+
114+
for _, tc := range testCases {
115+
// Decode the input map, check the output is as-expected.
116+
var hlFromMap HostsList
117+
t.Run(tc.doc+"_map", func(t *testing.T) {
118+
err := hlFromMap.DecodeMapstructure(tc.input)
119+
if tc.expectedError == "" {
120+
assert.NilError(t, err)
121+
actualOut := hlFromMap.AsList(":")
122+
sort.Strings(actualOut)
123+
sortedActualStr := strings.Join(actualOut, " ")
124+
assert.Check(t, is.Equal(sortedActualStr, tc.expectedOut))
125+
126+
// The YAML rendering of HostsList should be the same as the AsList() output, but
127+
// with '=' separators.
128+
yamlOut, err := hlFromMap.MarshalYAML()
129+
assert.NilError(t, err)
130+
expYAMLOut := make([]string, len(actualOut))
131+
for i, s := range actualOut {
132+
expYAMLOut[i] = strings.Replace(s, ":", "=", 1)
133+
}
134+
assert.DeepEqual(t, yamlOut.([]string), expYAMLOut)
135+
136+
// The JSON rendering of HostsList should also have '=' separators. Same as the
137+
// YAML output, but as a JSON list of strings.
138+
jsonOut, err := hlFromMap.MarshalJSON()
139+
assert.NilError(t, err)
140+
expJSONStrings := make([]string, len(expYAMLOut))
141+
for i, s := range expYAMLOut {
142+
expJSONStrings[i] = `"` + s + `"`
143+
}
144+
expJSONString := "[" + strings.Join(expJSONStrings, ",") + "]"
145+
assert.Check(t, is.Equal(string(jsonOut), expJSONString))
146+
} else {
147+
assert.ErrorContains(t, err, tc.expectedError)
148+
}
149+
})
150+
151+
// Convert the input into a ':' separated list, check that the result is the same
152+
// as for the map-input.
153+
t.Run(tc.doc+"_colon_sep", func(t *testing.T) {
154+
var hl HostsList
155+
err := hl.DecodeMapstructure(inputAsList(tc.input, ":"))
156+
if tc.expectedError == "" {
157+
assert.NilError(t, err)
158+
assert.DeepEqual(t, hl, hlFromMap)
159+
} else {
160+
assert.ErrorContains(t, err, tc.expectedError)
161+
}
162+
})
163+
164+
// Convert the input into a ':' separated list, check that the result is the same
165+
// as for the map-input.
166+
t.Run(tc.doc+"_eq_sep", func(t *testing.T) {
167+
var hl HostsList
168+
err := hl.DecodeMapstructure(inputAsList(tc.input, "="))
169+
if tc.expectedError == "" {
170+
assert.NilError(t, err)
171+
assert.DeepEqual(t, hl, hlFromMap)
172+
} else {
173+
assert.ErrorContains(t, err, tc.expectedError)
174+
}
175+
})
176+
}
177+
}

types/mapping.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,14 +195,23 @@ func (m *Mapping) DecodeMapstructure(value interface{}) error {
195195
return nil
196196
}
197197

198-
func decodeMapping(v []interface{}, sep string) map[string]string {
198+
// Generate a mapping by splitting strings at any of seps, which will be tried
199+
// in-order for each input string. (For example, to allow the preferred 'host=ip'
200+
// in 'extra_hosts', as well as 'host:ip' for backwards compatibility.)
201+
func decodeMapping(v []interface{}, seps ...string) map[string]string {
199202
mapping := make(Mapping, len(v))
200203
for _, s := range v {
201-
k, e, ok := strings.Cut(fmt.Sprint(s), sep)
202-
if !ok {
203-
e = ""
204+
for i, sep := range seps {
205+
k, e, ok := strings.Cut(fmt.Sprint(s), sep)
206+
if ok {
207+
// Mapping found with this separator, stop here.
208+
mapping[k] = e
209+
break
210+
} else if i == len(seps)-1 {
211+
// No more separators to try, map to empty string.
212+
mapping[k] = ""
213+
}
204214
}
205-
mapping[k] = e
206215
}
207216
return mapping
208217
}

0 commit comments

Comments
 (0)