Skip to content

Commit

Permalink
host list can be used to declare both IPv4 and IPv6 for same hostname
Browse files Browse the repository at this point in the history
Signed-off-by: Nicolas De Loof <[email protected]>
  • Loading branch information
ndeloof committed Feb 5, 2024
1 parent c2ed46d commit de3416a
Show file tree
Hide file tree
Showing 2 changed files with 95 additions and 75 deletions.
66 changes: 58 additions & 8 deletions types/hostList.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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{}:
Expand All @@ -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, "=", ":")
strings := make([]string, len(v))

Check failure on line 101 in types/hostList.go

View workflow job for this annotation

GitHub Actions / test (1.21, ubuntu-latest)

importShadow: shadow of imported package 'strings' (gocritic)

Check failure on line 101 in types/hostList.go

View workflow job for this annotation

GitHub Actions / test (1.20, ubuntu-latest)

importShadow: shadow of imported package 'strings' (gocritic)
for i, e := range v {
strings[i] = fmt.Sprint(e)
}
list, err := NewHostsList(strings)
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
}
104 changes: 37 additions & 67 deletions types/hostList_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
}
})
}
}

0 comments on commit de3416a

Please sign in to comment.