diff --git a/cmd/contour/serve.go b/cmd/contour/serve.go index 1e4c20dd229..56af61fef5e 100644 --- a/cmd/contour/serve.go +++ b/cmd/contour/serve.go @@ -342,7 +342,9 @@ func doServe(log logrus.FieldLogger, ctx *serveContext) error { DNSLookupFamily: ctx.Config.Cluster.DNSLookupFamily, ClientCertificate: clientCert, }, - &dag.ServiceAPIsProcessor{}, + &dag.ServiceAPIsProcessor{ + FieldLogger: log.WithField("context", "ServiceAPISProcessor"), + }, &dag.ListenerProcessor{}, }, }, diff --git a/internal/dag/builder_test.go b/internal/dag/builder_test.go index e4481537dd6..80e598a4242 100644 --- a/internal/dag/builder_test.go +++ b/internal/dag/builder_test.go @@ -18,6 +18,8 @@ import ( "testing" "time" + serviceapis "sigs.k8s.io/service-apis/apis/v1alpha1" + contour_api_v1 "github.com/projectcontour/contour/apis/projectcontour/v1" "github.com/projectcontour/contour/internal/fixture" "github.com/projectcontour/contour/internal/timeout" @@ -28,8 +30,375 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/pointer" ) +func TestDAGInsertServiceAPIs(t *testing.T) { + + kuardService := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kuard", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Name: "http", + Protocol: "TCP", + Port: 8080, + TargetPort: intstr.FromInt(8080), + }}, + }, + } + + blogService := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "blogsvc", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{ + Name: "http", + Protocol: "TCP", + Port: 80, + TargetPort: intstr.FromInt(8080), + }}, + }, + } + + gateway := &serviceapis.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway", + Namespace: "default", + }, + Spec: serviceapis.GatewaySpec{ + Listeners: []serviceapis.Listener{{ + Port: 80, + Protocol: "HTTP", + Routes: serviceapis.RouteBindingSelector{ + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "contour", + }, + }, + }, + }}, + }, + } + + tests := map[string]struct { + objs []interface{} + disablePermitInsecure bool + fallbackCertificateName string + fallbackCertificateNamespace string + want []Vertex + }{ + "insert basic single route, single hostname": { + objs: []interface{}{ + gateway, + kuardService, + &serviceapis.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic", + Namespace: "default", + Labels: map[string]string{ + "app": "contour", + }, + }, + Spec: serviceapis.HTTPRouteSpec{ + Hostnames: []serviceapis.Hostname{ + "test.projectcontour.io", + }, + Rules: []serviceapis.HTTPRouteRule{{ + Matches: []serviceapis.HTTPRouteMatch{{ + Path: serviceapis.HTTPPathMatch{ + Type: "Prefix", + Value: "/", + }, + }}, + ForwardTo: []serviceapis.HTTPRouteForwardTo{{ + ServiceName: pointer.StringPtr("kuard"), + Port: 8080, + }}, + }}, + }, + }, + }, + want: listeners( + &Listener{ + Port: 80, + VirtualHosts: virtualhosts( + virtualhost("test.projectcontour.io", prefixroute("/", service(kuardService))), + ), + }, + ), + }, + "insert basic multiple routes, single hostname": { + objs: []interface{}{ + gateway, + kuardService, + blogService, + &serviceapis.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic", + Namespace: "default", + Labels: map[string]string{ + "app": "contour", + }, + }, + Spec: serviceapis.HTTPRouteSpec{ + Hostnames: []serviceapis.Hostname{ + "test.projectcontour.io", + }, + Rules: []serviceapis.HTTPRouteRule{{ + Matches: []serviceapis.HTTPRouteMatch{{ + Path: serviceapis.HTTPPathMatch{ + Type: "Prefix", + Value: "/", + }, + }}, + ForwardTo: []serviceapis.HTTPRouteForwardTo{{ + ServiceName: pointer.StringPtr("kuard"), + Port: 8080, + }}, + }, { + Matches: []serviceapis.HTTPRouteMatch{{ + Path: serviceapis.HTTPPathMatch{ + Type: "Prefix", + Value: "/blog", + }, + }}, + ForwardTo: []serviceapis.HTTPRouteForwardTo{{ + ServiceName: pointer.StringPtr("blogsvc"), + Port: 80, + }}, + }}, + }, + }, + }, + want: listeners( + &Listener{ + Port: 80, + VirtualHosts: virtualhosts( + virtualhost("test.projectcontour.io", + prefixroute("/", service(kuardService)), prefixroute("/blog", service(blogService))), + ), + }, + ), + }, + "multiple hosts": { + objs: []interface{}{ + gateway, + kuardService, + &serviceapis.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic", + Namespace: "default", + Labels: map[string]string{ + "app": "contour", + }, + }, + Spec: serviceapis.HTTPRouteSpec{ + Hostnames: []serviceapis.Hostname{ + "test.projectcontour.io", + "test2.projectcontour.io", + "test3.projectcontour.io", + "test4.projectcontour.io", + }, + Rules: []serviceapis.HTTPRouteRule{{ + Matches: []serviceapis.HTTPRouteMatch{{ + Path: serviceapis.HTTPPathMatch{ + Type: "Prefix", + Value: "/", + }, + }}, + ForwardTo: []serviceapis.HTTPRouteForwardTo{{ + ServiceName: pointer.StringPtr("kuard"), + Port: 8080, + }}, + }}, + }, + }, + }, + want: listeners( + &Listener{ + Port: 80, + VirtualHosts: virtualhosts( + virtualhost("test.projectcontour.io", prefixroute("/", service(kuardService))), + virtualhost("test2.projectcontour.io", prefixroute("/", service(kuardService))), + virtualhost("test3.projectcontour.io", prefixroute("/", service(kuardService))), + virtualhost("test4.projectcontour.io", prefixroute("/", service(kuardService))), + ), + }, + ), + }, + "no host defined": { + objs: []interface{}{ + gateway, + kuardService, + &serviceapis.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic", + Namespace: "default", + Labels: map[string]string{ + "app": "contour", + }, + }, + Spec: serviceapis.HTTPRouteSpec{ + Rules: []serviceapis.HTTPRouteRule{{ + Matches: []serviceapis.HTTPRouteMatch{{ + Path: serviceapis.HTTPPathMatch{ + Type: "Prefix", + Value: "/", + }, + }}, + ForwardTo: []serviceapis.HTTPRouteForwardTo{{ + ServiceName: pointer.StringPtr("kuard"), + Port: 8080, + }}, + }}, + }, + }, + }, + want: listeners( + &Listener{ + Port: 80, + VirtualHosts: virtualhosts( + virtualhost("*", prefixroute("/", service(kuardService))), + ), + }, + ), + }, + // If the ServiceName referenced from an HTTPRoute is missing, + // the route should not be added. + "missing service": { + objs: []interface{}{ + gateway, + &serviceapis.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic", + Namespace: "default", + Labels: map[string]string{ + "app": "contour", + }, + }, + Spec: serviceapis.HTTPRouteSpec{ + Rules: []serviceapis.HTTPRouteRule{{ + Matches: []serviceapis.HTTPRouteMatch{{ + Path: serviceapis.HTTPPathMatch{ + Type: "Prefix", + Value: "/", + }, + }}, + ForwardTo: []serviceapis.HTTPRouteForwardTo{{ + ServiceName: pointer.StringPtr("kuard"), + Port: 8080, + }}, + }}, + }, + }, + }, + want: listeners(), + }, + // Single host with single route containing multiple prefixes to the same service. + "insert basic single route with multiple prefixes, single hostname": { + objs: []interface{}{ + gateway, + kuardService, + &serviceapis.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic", + Namespace: "default", + Labels: map[string]string{ + "app": "contour", + }, + }, + Spec: serviceapis.HTTPRouteSpec{ + Hostnames: []serviceapis.Hostname{ + "test.projectcontour.io", + }, + Rules: []serviceapis.HTTPRouteRule{{ + Matches: []serviceapis.HTTPRouteMatch{{ + Path: serviceapis.HTTPPathMatch{ + Type: "Prefix", + Value: "/", + }, + }, { + Path: serviceapis.HTTPPathMatch{ + Type: "Prefix", + Value: "/blog", + }, + }, { + Path: serviceapis.HTTPPathMatch{ + Type: "Prefix", + Value: "/tech", + }, + }}, + ForwardTo: []serviceapis.HTTPRouteForwardTo{{ + ServiceName: pointer.StringPtr("kuard"), + Port: 8080, + }}, + }}, + }, + }, + }, + want: listeners( + &Listener{ + Port: 80, + VirtualHosts: virtualhosts( + virtualhost("test.projectcontour.io", + prefixroute("/", service(kuardService)), + prefixroute("/blog", service(kuardService)), + prefixroute("/tech", service(kuardService))), + ), + }, + ), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + + builder := Builder{ + Source: KubernetesCache{ + FieldLogger: fixture.NewTestLogger(t), + }, + Processors: []Processor{ + &IngressProcessor{ + FieldLogger: fixture.NewTestLogger(t), + }, + &HTTPProxyProcessor{ + DisablePermitInsecure: tc.disablePermitInsecure, + FallbackCertificate: &types.NamespacedName{ + Name: tc.fallbackCertificateName, + Namespace: tc.fallbackCertificateNamespace, + }, + }, + &ServiceAPIsProcessor{ + FieldLogger: fixture.NewTestLogger(t), + }, + &ListenerProcessor{}, + }, + } + + for _, o := range tc.objs { + builder.Source.Insert(o) + } + dag := builder.Build() + + got := make(map[int]*Listener) + dag.Visit(listenerMap(got).Visit) + + want := make(map[int]*Listener) + for _, v := range tc.want { + if l, ok := v.(*Listener); ok { + want[l.Port] = l + } + } + assert.Equal(t, want, got) + }) + } +} + func TestDAGInsert(t *testing.T) { // The DAG is sensitive to ordering, adding an ingress, then a service, // should have the same result as adding a service, then an ingress. diff --git a/internal/dag/serviceapis_processor.go b/internal/dag/serviceapis_processor.go index cab9bdaf668..7d78d3b0f71 100644 --- a/internal/dag/serviceapis_processor.go +++ b/internal/dag/serviceapis_processor.go @@ -13,9 +13,18 @@ package dag +import ( + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + serviceapis "sigs.k8s.io/service-apis/apis/v1alpha1" +) + // ServiceAPIsProcessor translates Service API types into DAG // objects and adds them to the DAG. type ServiceAPIsProcessor struct { + logrus.FieldLogger + dag *DAG source *KubernetesCache } @@ -26,5 +35,97 @@ func (p *ServiceAPIsProcessor) Run(dag *DAG, source *KubernetesCache) { p.dag = dag p.source = source - // NOT IMPLEMENTED + // reset the processor when we're done + defer func() { + p.dag = nil + p.source = nil + }() + + for _, route := range p.source.httproutes { + p.computeHTTPRoute(route) + } +} + +func (p *ServiceAPIsProcessor) computeHTTPRoute(route *serviceapis.HTTPRoute) { + + // Validate TLS Configuration + if route.Spec.TLS != nil { + p.Error("NOT IMPLEMENTED: The 'RouteTLSConfig' is not yet implemented.") + } + + // Determine the hosts on the route, if no hosts + // are defined, then set to "*". + var hosts []string + if len(route.Spec.Hostnames) == 0 { + hosts = append(hosts, "*") + } else { + for _, host := range route.Spec.Hostnames { + hosts = append(hosts, string(host)) + } + } + + for _, rule := range route.Spec.Rules { + + var pathPrefixes []string + var services []*Service + + for _, match := range rule.Matches { + switch match.Path.Type { + case serviceapis.PathMatchPrefix: + pathPrefixes = append(pathPrefixes, stringOrDefault(match.Path.Value, "/")) + default: + p.Error("NOT IMPLEMENTED: Only PathMatchPrefix is currently implemented.") + } + } + + for _, forward := range rule.ForwardTo { + // Verify the service is valid + if forward.ServiceName == nil { + p.Error("ServiceName must be specified and is currently only type implemented!") + break + } + meta := types.NamespacedName{Name: *forward.ServiceName, Namespace: route.Namespace} + + // TODO: Refactor EnsureService to take an int32 so conversion to intstr is not needed. + service, err := p.dag.EnsureService(meta, intstr.FromInt(int(forward.Port)), p.source) + if err != nil { + // TODO: Raise `ResolvedRefs` condition on Gateway with `DegradedRoutes` reason. + p.Errorf("Service %q does not exist in namespace %q", meta.Name, meta.Namespace) + return + } + services = append(services, service) + } + + routes := p.routes(pathPrefixes, services) + + for _, vhost := range hosts { + vhost := p.dag.EnsureVirtualHost(vhost) + for _, route := range routes { + vhost.addRoute(route) + } + } + } +} + +// routes builds a []*dag.Route for the supplied set of pathPrefixes & services. +func (p *ServiceAPIsProcessor) routes(pathPrefixes []string, services []*Service) []*Route { + var clusters []*Cluster + var routes []*Route + + for _, service := range services { + clusters = append(clusters, &Cluster{ + Upstream: service, + Protocol: service.Protocol, + }) + } + + for _, prefix := range pathPrefixes { + r := &Route{ + Clusters: clusters, + } + r.PathMatchCondition = &PrefixMatchCondition{Prefix: prefix} + routes = append(routes, r) + } + + return routes }