diff --git a/pkg/kobject/kobject.go b/pkg/kobject/kobject.go index 18c431eb89..2673806831 100644 --- a/pkg/kobject/kobject.go +++ b/pkg/kobject/kobject.go @@ -170,8 +170,9 @@ type ServiceConfig struct { CronJobBackoffLimit *int32 `compose:"kompose.cronjob.backoff_limit"` Volumes []Volumes `compose:""` Secrets []types.ServiceSecretConfig - HealthChecks HealthChecks `compose:""` - Placement Placement `compose:""` + HealthChecks HealthChecks `compose:""` + Placement Placement `compose:""` + HostAliases []HostAliases `compose:""` //This is for long LONG SYNTAX link(https://docs.docker.com/compose/compose-file/#long-syntax) Configs []types.ServiceConfigObjConfig `compose:""` //This is for SHORT SYNTAX link(https://docs.docker.com/compose/compose-file/#configs) @@ -216,6 +217,11 @@ type Ports struct { Protocol string // Upper string } +type HostAliases struct { + IP string + Hostnames []string +} + // ID returns an unique id for this port settings, to avoid conflict func (port *Ports) ID() string { return strconv.Itoa(int(port.ContainerPort)) + port.Protocol diff --git a/pkg/loader/compose/compose.go b/pkg/loader/compose/compose.go index ca1f77ab35..ed08b8bb23 100644 --- a/pkg/loader/compose/compose.go +++ b/pkg/loader/compose/compose.go @@ -19,6 +19,7 @@ package compose import ( "context" "fmt" + "net" "os" "reflect" "strconv" @@ -314,6 +315,31 @@ func loadPorts(ports []types.ServicePortConfig, expose []string) []kobject.Ports return komposePorts } +// Convert extra hosts from compose to kobject.HostAliases +func loadExtraHosts(extraHosts types.HostsList) []kobject.HostAliases { + ipToHosts := make(map[string][]string) + + for hostname, ips := range extraHosts { + for _, ip := range ips { + if net.ParseIP(ip) == nil { + log.Warnf("Extra hosts contains invalid IP address %q for hostname %q. Kubernetes HostAlias requires valid IPv4 or IPv6 address.", ip, hostname) + continue + } + ipToHosts[ip] = append(ipToHosts[ip], hostname) + } + } + + hostAliases := make([]kobject.HostAliases, 0, len(ipToHosts)) + for ip, hostnames := range ipToHosts { + hostAliases = append(hostAliases, kobject.HostAliases{ + IP: ip, + Hostnames: hostnames, + }) + } + + return hostAliases +} + /* Convert the HealthCheckConfig as designed by Docker to @@ -587,6 +613,9 @@ func dockerComposeToKomposeMapping(composeObject *types.Project) (kobject.Kompos return kobject.KomposeObject{}, err } + // Parse extra hosts + serviceConfig.HostAliases = loadExtraHosts(composeServiceConfig.ExtraHosts) + // Log if the name will been changed if normalizeServiceNames(name) != name { log.Infof("Service name in docker-compose has been changed from %q to %q", name, normalizeServiceNames(name)) diff --git a/pkg/loader/compose/compose_test.go b/pkg/loader/compose/compose_test.go index 6fd7f439ed..f2ac1e4826 100644 --- a/pkg/loader/compose/compose_test.go +++ b/pkg/loader/compose/compose_test.go @@ -20,6 +20,7 @@ import ( "fmt" "os" "reflect" + "sort" "strings" "testing" "time" @@ -243,6 +244,102 @@ func TestLoadV3Ports(t *testing.T) { } } +func TestLoadExtraHosts(t *testing.T) { + for _, tt := range []struct { + desc string + extraHosts types.HostsList + want []kobject.HostAliases + }{ + { + desc: "single host with IPv4", + extraHosts: types.HostsList{ + "example.com": []string{"192.168.1.100"}, + }, + want: []kobject.HostAliases{ + {IP: "192.168.1.100", Hostnames: []string{"example.com"}}, + }, + }, + { + desc: "multiple hosts with the same IP", + extraHosts: types.HostsList{ + "example.com": []string{"192.168.1.100"}, + "api.example.com": []string{"192.168.1.100"}, + }, + want: []kobject.HostAliases{ + {IP: "192.168.1.100", Hostnames: []string{"example.com", "api.example.com"}}, + }, + }, + { + desc: "multiple hosts with different IPs", + extraHosts: types.HostsList{ + "host1.com": []string{"192.168.1.100"}, + "host2.com": []string{"192.168.1.101"}, + }, + want: []kobject.HostAliases{ + {IP: "192.168.1.100", Hostnames: []string{"host1.com"}}, + {IP: "192.168.1.101", Hostnames: []string{"host2.com"}}, + }, + }, + { + desc: "IPv6 addresses", + extraHosts: types.HostsList{ + "ipv6.example.com": []string{"2001:db8::1"}, + "localhost6": []string{"::1"}, + }, + want: []kobject.HostAliases{ + {IP: "2001:db8::1", Hostnames: []string{"ipv6.example.com"}}, + {IP: "::1", Hostnames: []string{"localhost6"}}, + }, + }, + { + desc: "mixed IPv4 and IPv6", + extraHosts: types.HostsList{ + "ipv4.example.com": []string{"192.168.1.100"}, + "ipv6.example.com": []string{"2001:db8::1"}, + }, + want: []kobject.HostAliases{ + {IP: "192.168.1.100", Hostnames: []string{"ipv4.example.com"}}, + {IP: "2001:db8::1", Hostnames: []string{"ipv6.example.com"}}, + }, + }, + { + desc: "single host with multiple IPs", + extraHosts: types.HostsList{ + "multi.example.com": []string{"192.168.1.100", "10.0.0.1"}, + }, + want: []kobject.HostAliases{ + {IP: "192.168.1.100", Hostnames: []string{"multi.example.com"}}, + {IP: "10.0.0.1", Hostnames: []string{"multi.example.com"}}, + }, + }, + { + desc: "empty extra hosts", + extraHosts: types.HostsList{}, + want: []kobject.HostAliases{}, + }, + } { + t.Run(tt.desc, func(t *testing.T) { + got := loadExtraHosts(tt.extraHosts) + + sortHostAliases := func(ha []kobject.HostAliases) { + for i := range ha { + sort.Strings(ha[i].Hostnames) + } + sort.Slice(ha, func(i, j int) bool { + return ha[i].IP < ha[j].IP + }) + } + + sortHostAliases(got) + sortHostAliases(tt.want) + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("loadExtraHosts() mismatch (-want +got):\n%s", diff) + } + }) + } +} + // Test if service types are parsed properly on user input // give a service type and expect correct input func TestHandleServiceType(t *testing.T) { diff --git a/pkg/transformer/kubernetes/k8sutils.go b/pkg/transformer/kubernetes/k8sutils.go index 8242163002..9a15b81810 100644 --- a/pkg/transformer/kubernetes/k8sutils.go +++ b/pkg/transformer/kubernetes/k8sutils.go @@ -718,6 +718,18 @@ func (k *Kubernetes) UpdateKubernetesObjects(name string, service kobject.Servic template.Spec.Subdomain = service.DomainName } + // Configure hostAliases + if len(service.HostAliases) > 0 { + var hostAliases []api.HostAlias + for _, ha := range service.HostAliases { + hostAliases = append(hostAliases, api.HostAlias{ + IP: ha.IP, + Hostnames: ha.Hostnames, + }) + } + template.Spec.HostAliases = hostAliases + } + if serviceAccountName, ok := service.Labels[compose.LabelServiceAccountName]; ok { template.Spec.ServiceAccountName = serviceAccountName } diff --git a/pkg/transformer/kubernetes/kubernetes.go b/pkg/transformer/kubernetes/kubernetes.go index 66b44b4b8b..b924677460 100644 --- a/pkg/transformer/kubernetes/kubernetes.go +++ b/pkg/transformer/kubernetes/kubernetes.go @@ -1646,6 +1646,7 @@ func (k *Kubernetes) Transform(komposeObject kobject.KomposeObject, opt kobject. SecurityContext(groupName, service), HostName(service), DomainName(service), + HostAliases(service), ResourcesLimits(service), ResourcesRequests(service), TerminationGracePeriodSeconds(groupName, service), diff --git a/pkg/transformer/kubernetes/kubernetes_test.go b/pkg/transformer/kubernetes/kubernetes_test.go index f291082187..8f7b6653e8 100644 --- a/pkg/transformer/kubernetes/kubernetes_test.go +++ b/pkg/transformer/kubernetes/kubernetes_test.go @@ -61,6 +61,7 @@ func newServiceConfig() kobject.ServiceConfig { CapAdd: []string{"cap_add"}, CapDrop: []string{"cap_drop"}, Expose: []string{"expose"}, // not supported + HostAliases: []kobject.HostAliases{{IP: "127.0.0.1", Hostnames: []string{"localhost"}}}, Privileged: true, Restart: "always", ImagePullSecret: "regcred", @@ -1322,3 +1323,150 @@ UNDEFINED_VAR=${MISSING_VAR:-default_value} }) } } + +func TestHostAliases(t *testing.T) { + testCases := map[string]struct { + komposeObject kobject.KomposeObject + opt kobject.ConvertOptions + }{ + "Convert with HostAliases": { + komposeObject: func() kobject.KomposeObject { + ko := newKomposeObject() + config := ko.ServiceConfigs["app"] + config.HostAliases = []kobject.HostAliases{ + {IP: "127.0.0.1", Hostnames: []string{"localhost", "local"}}, + {IP: "::1", Hostnames: []string{"ip6-localhost"}}, + } + ko.ServiceConfigs["app"] = config + return ko + }(), + opt: kobject.ConvertOptions{CreateD: true}, + }, + } + + for name, test := range testCases { + t.Log("Test case:", name) + k := Kubernetes{} + objs, err := k.Transform(test.komposeObject, test.opt) + if err != nil { + t.Error(errors.Wrap(err, "k.Transform failed")) + } + + foundHostAliases := false + for _, obj := range objs { + if d, ok := obj.(*appsv1.Deployment); ok { + hostAliases := d.Spec.Template.Spec.HostAliases + if len(hostAliases) != 2 { + t.Errorf("Expected 2 HostAliases, got %d", len(hostAliases)) + continue + } + + if hostAliases[0].IP != "127.0.0.1" { + t.Errorf("Expected IP 127.0.0.1, got %s", hostAliases[0].IP) + } + if len(hostAliases[0].Hostnames) != 2 || hostAliases[0].Hostnames[0] != "localhost" || hostAliases[0].Hostnames[1] != "local" { + t.Errorf("Expected hostnames [localhost local], got %v", hostAliases[0].Hostnames) + } + + if hostAliases[1].IP != "::1" { + t.Errorf("Expected IP ::1, got %s", hostAliases[1].IP) + } + if len(hostAliases[1].Hostnames) != 1 || hostAliases[1].Hostnames[0] != "ip6-localhost" { + t.Errorf("Expected hostnames [ip6-localhost], got %v", hostAliases[1].Hostnames) + } + + foundHostAliases = true + } + } + + if !foundHostAliases { + t.Error("Did not find HostAliases in generated Deployment") + } + } +} + +func TestHostAliasesServiceGroup(t *testing.T) { + serviceName1 := "web" + serviceName2 := "db" + testCases := map[string]struct { + komposeObject kobject.KomposeObject + opt kobject.ConvertOptions + }{ + "Service Group with HostAliases": { + komposeObject: func() kobject.KomposeObject { + ko := kobject.KomposeObject{ + ServiceConfigs: map[string]kobject.ServiceConfig{ + serviceName1: { + Name: serviceName1, + Image: "nginx", + Port: []kobject.Ports{{HostPort: 80, ContainerPort: 80}}, + HostAliases: []kobject.HostAliases{ + {IP: "10.0.0.1", Hostnames: []string{"web-alias"}}, + }, + GroupAdd: []int64{1000}, + Labels: map[string]string{ + "kompose.service.group": "mygroup", + }, + }, + serviceName2: { + Name: serviceName2, + Image: "postgres", + Port: []kobject.Ports{{HostPort: 5432, ContainerPort: 5432}}, + HostAliases: []kobject.HostAliases{ + {IP: "10.0.0.2", Hostnames: []string{"db-alias"}}, + }, + GroupAdd: []int64{1001}, + Labels: map[string]string{ + "kompose.service.group": "mygroup", + }, + }, + }, + } + return ko + }(), + opt: kobject.ConvertOptions{ServiceGroupMode: "label", CreateD: true}, + }, + } + + for name, test := range testCases { + t.Log("Test case:", name) + k := Kubernetes{} + objs, err := k.Transform(test.komposeObject, test.opt) + if err != nil { + t.Error(errors.Wrap(err, "k.Transform failed")) + } + + foundHostAliases := false + for _, obj := range objs { + if d, ok := obj.(*appsv1.Deployment); ok { + hostAliases := d.Spec.Template.Spec.HostAliases + if len(hostAliases) != 2 { + t.Errorf("Expected 2 HostAliases, got %d", len(hostAliases)) + continue + } + + // Check first HostAlias + found1 := false + found2 := false + for _, ha := range hostAliases { + if ha.IP == "10.0.0.1" && len(ha.Hostnames) == 1 && ha.Hostnames[0] == "web-alias" { + found1 = true + } + if ha.IP == "10.0.0.2" && len(ha.Hostnames) == 1 && ha.Hostnames[0] == "db-alias" { + found2 = true + } + } + + if !found1 || !found2 { + t.Errorf("Expected both HostAliases (10.0.0.1/web-alias and 10.0.0.2/db-alias), got %v", hostAliases) + } + + foundHostAliases = true + } + } + + if !foundHostAliases { + t.Error("Did not find HostAliases in generated Deployment for Service Group") + } + } +} diff --git a/pkg/transformer/kubernetes/podspec.go b/pkg/transformer/kubernetes/podspec.go index c09c6d1c92..646d4f2300 100644 --- a/pkg/transformer/kubernetes/podspec.go +++ b/pkg/transformer/kubernetes/podspec.go @@ -299,6 +299,20 @@ func DomainName(service kobject.ServiceConfig) PodSpecOption { } } +// HostAliases configure the host aliases of a pod +func HostAliases(service kobject.ServiceConfig) PodSpecOption { + return func(podSpec *PodSpec) { + if len(service.HostAliases) > 0 { + for _, ha := range service.HostAliases { + podSpec.HostAliases = append(podSpec.HostAliases, api.HostAlias{ + IP: ha.IP, + Hostnames: ha.Hostnames, + }) + } + } + } +} + func configProbe(healthCheck kobject.HealthCheck) *api.Probe { probe := api.Probe{} // We check to see if it's blank or disable diff --git a/pkg/transformer/openshift/openshift_test.go b/pkg/transformer/openshift/openshift_test.go index 57813ebf2f..cbee261fd3 100644 --- a/pkg/transformer/openshift/openshift_test.go +++ b/pkg/transformer/openshift/openshift_test.go @@ -491,3 +491,50 @@ func TestNamespaceGeneration(t *testing.T) { } } } + +func TestHostAliasesOpenShift(t *testing.T) { + config := newServiceConfig() + config.HostAliases = []kobject.HostAliases{ + {IP: "192.168.1.100", Hostnames: []string{"custom.local"}}, + {IP: "2001:db8::1", Hostnames: []string{"ipv6.example.com"}}, + } + komposeObject := kobject.KomposeObject{ + ServiceConfigs: map[string]kobject.ServiceConfig{"app": config}, + } + o := OpenShift{} + objs, err := o.Transform(komposeObject, kobject.ConvertOptions{CreateDeploymentConfig: true}) + if err != nil { + t.Error(errors.Wrap(err, "o.Transform failed")) + } + + foundHostAliases := false + for _, obj := range objs { + if dc, ok := obj.(*deployapi.DeploymentConfig); ok { + hostAliases := dc.Spec.Template.Spec.HostAliases + if len(hostAliases) != 2 { + t.Errorf("Expected 2 HostAliases, got %d", len(hostAliases)) + continue + } + + if hostAliases[0].IP != "192.168.1.100" { + t.Errorf("Expected IP 192.168.1.100, got %s", hostAliases[0].IP) + } + if len(hostAliases[0].Hostnames) != 1 || hostAliases[0].Hostnames[0] != "custom.local" { + t.Errorf("Expected hostnames [custom.local], got %v", hostAliases[0].Hostnames) + } + + if hostAliases[1].IP != "2001:db8::1" { + t.Errorf("Expected IP 2001:db8::1, got %s", hostAliases[1].IP) + } + if len(hostAliases[1].Hostnames) != 1 || hostAliases[1].Hostnames[0] != "ipv6.example.com" { + t.Errorf("Expected hostnames [ipv6.example.com], got %v", hostAliases[1].Hostnames) + } + + foundHostAliases = true + } + } + + if !foundHostAliases { + t.Error("Did not find HostAliases in generated DeploymentConfig") + } +}