diff --git a/cmd/contour/serve.go b/cmd/contour/serve.go index 9a378732af1..58c603d0c41 100644 --- a/cmd/contour/serve.go +++ b/cmd/contour/serve.go @@ -433,10 +433,15 @@ func doServe(log logrus.FieldLogger, ctx *serveContext) error { err = gatewayapi_v1alpha1.AddToScheme(mgr.GetScheme()) if err != nil { - log.Error(err, "unable to add GatewayAPI to scheme.") + log.Error(err, "unable to add Gateway API to scheme.") os.Exit(1) } + // Create and register the GatewayClass controller with the manager. + if _, err := contour_cache.NewGatewayClassController(mgr, &dynamicHandler, log.WithField("context", "gatewayclass-controller")); err != nil { + log.WithError(err).Fatal("failed to create gatewayclass-controller") + } + // Create and register the NewGatewayController controller with the manager. if _, err := contour_cache.NewGatewayController(mgr, &dynamicHandler, log.WithField("context", "gateway-controller")); err != nil { log.WithError(err).Fatal("failed to create gateway-controller") @@ -462,7 +467,7 @@ func doServe(log logrus.FieldLogger, ctx *serveContext) error { return mgr.Start(signals.SetupSignalHandler()) }) } else { - log.Fatalf("GatewayAPI Gateway configured but APIs not installed in cluster.") + log.Fatalf("Gateway API configured but CRDs not installed in cluster.") } } diff --git a/examples/contour/02-role-contour.yaml b/examples/contour/02-role-contour.yaml index b93640abce5..e5cbd676803 100644 --- a/examples/contour/02-role-contour.yaml +++ b/examples/contour/02-role-contour.yaml @@ -46,8 +46,11 @@ rules: resources: - services verbs: + - create + - delete - get - list + - update - watch - apiGroups: - apiextensions.k8s.io @@ -83,6 +86,7 @@ rules: - networking.x-k8s.io resources: - backendpolicies + - gatewayclasses - gateways - httproutes - tcproutes @@ -96,6 +100,7 @@ rules: - networking.x-k8s.io resources: - backendpolicies/status + - gatewayclasses/status - httproutes/status - tcproutes/status - tlsroutes/status diff --git a/examples/render/contour.yaml b/examples/render/contour.yaml index c91d8abfbf5..1c16732ba78 100644 --- a/examples/render/contour.yaml +++ b/examples/render/contour.yaml @@ -2785,8 +2785,11 @@ rules: resources: - services verbs: + - create + - delete - get - list + - update - watch - apiGroups: - apiextensions.k8s.io @@ -2822,6 +2825,7 @@ rules: - networking.x-k8s.io resources: - backendpolicies + - gatewayclasses - gateways - httproutes - tcproutes @@ -2835,6 +2839,7 @@ rules: - networking.x-k8s.io resources: - backendpolicies/status + - gatewayclasses/status - httproutes/status - tcproutes/status - tlsroutes/status diff --git a/internal/dag/cache.go b/internal/dag/cache.go index 10b61a6cd3d..3c0cb767e95 100644 --- a/internal/dag/cache.go +++ b/internal/dag/cache.go @@ -23,6 +23,8 @@ import ( "github.com/projectcontour/contour/internal/annotation" "github.com/projectcontour/contour/internal/k8s" ingress_validation "github.com/projectcontour/contour/internal/validation/ingress" + "github.com/projectcontour/contour/pkg/config" + "github.com/sirupsen/logrus" v1 "k8s.io/api/core/v1" networking_v1 "k8s.io/api/networking/v1" @@ -60,6 +62,7 @@ type KubernetesCache struct { tlscertificatedelegations map[types.NamespacedName]*contour_api_v1.TLSCertificateDelegation services map[types.NamespacedName]*v1.Service namespaces map[string]*v1.Namespace + gatewayclass *gatewayapi_v1alpha1.GatewayClass gateway *gatewayapi_v1alpha1.Gateway httproutes map[types.NamespacedName]*gatewayapi_v1alpha1.HTTPRoute tlsroutes map[types.NamespacedName]*gatewayapi_v1alpha1.TLSRoute @@ -139,6 +142,22 @@ func (kc *KubernetesCache) matchesIngressClassAnnotation(obj metav1.Object) bool return true } +// matchesGatewayClassController returns true if the given Kubernetes object +// belongs to the GatewayClass that this cache is using. +func (kc *KubernetesCache) matchesGatewayClassController(obj *gatewayapi_v1alpha1.GatewayClass) bool { + if obj.Spec.Controller != config.ContourGatewayClass { + kind := k8s.KindOf(obj) + + kc.WithField("name", obj.GetName()). + WithField("namespace", obj.GetNamespace()). + WithField("kind", kind). + WithField("configured gatewayclass name", obj.Name). + Debug("ignoring object with unmatched gatewayclass controller") + return false + } + return true +} + // matchesGateway returns true if the given Kubernetes object // belongs to the Gateway that this cache is using. func (kc *KubernetesCache) matchesGateway(obj *gatewayapi_v1alpha1.Gateway) bool { @@ -233,6 +252,11 @@ func (kc *KubernetesCache) Insert(obj interface{}) bool { case *contour_api_v1.TLSCertificateDelegation: kc.tlscertificatedelegations[k8s.NamespacedNameOf(obj)] = obj return true + case *gatewayapi_v1alpha1.GatewayClass: + if kc.matchesGatewayClassController(obj) { + kc.gatewayclass = obj + return true + } case *gatewayapi_v1alpha1.Gateway: if kc.matchesGateway(obj) { kc.gateway = obj @@ -426,6 +450,12 @@ func (kc *KubernetesCache) remove(obj interface{}) bool { _, ok := kc.tlscertificatedelegations[m] delete(kc.tlscertificatedelegations, m) return ok + case *gatewayapi_v1alpha1.GatewayClass: + if kc.matchesGatewayClassController(obj) { + kc.gatewayclass = nil + return true + } + return false case *gatewayapi_v1alpha1.Gateway: if kc.matchesGateway(obj) { kc.gateway = nil diff --git a/internal/dag/gatewayapi_processor.go b/internal/dag/gatewayapi_processor.go index f7aa48fa38e..c451bcbc947 100644 --- a/internal/dag/gatewayapi_processor.go +++ b/internal/dag/gatewayapi_processor.go @@ -52,7 +52,7 @@ type matchConditions struct { headerMatchCondition []HeaderMatchCondition } -// Run translates Service APIs into DAG objects and +// Run translates Gateway API objects into DAG objects and // adds them to the DAG. func (p *GatewayAPIProcessor) Run(dag *DAG, source *KubernetesCache) { p.dag = dag @@ -64,100 +64,96 @@ func (p *GatewayAPIProcessor) Run(dag *DAG, source *KubernetesCache) { p.source = nil }() - // Gateway must be defined for resources to be processed. - if p.source.gateway == nil { - p.Error("Gateway is not defined!") - return - } + if p.source.gateway != nil { + for _, listener := range p.source.gateway.Spec.Listeners { - for _, listener := range p.source.gateway.Spec.Listeners { + var matchingRoutes []*gatewayapi_v1alpha1.HTTPRoute + var listenerSecret *Secret - var matchingRoutes []*gatewayapi_v1alpha1.HTTPRoute - var listenerSecret *Secret + // Validate the Kind on the selector is a supported type. + switch listener.Protocol { + case gatewayapi_v1alpha1.HTTPSProtocolType, gatewayapi_v1alpha1.TLSProtocolType: + // Validate that if protocol is type HTTPS or TLS that TLS is defined. + if listener.TLS == nil { + p.Errorf("Listener.TLS is required when protocol is %q.", listener.Protocol) + continue + } - // Validate the Kind on the selector is a supported type. - switch listener.Protocol { - case gatewayapi_v1alpha1.HTTPSProtocolType, gatewayapi_v1alpha1.TLSProtocolType: - // Validate that if protocol is type HTTPS or TLS that TLS is defined. - if listener.TLS == nil { - p.Errorf("Listener.TLS is required when protocol is %q.", listener.Protocol) + // Check for TLS on the Gateway. + if listenerSecret = p.validGatewayTLS(listener); listenerSecret == nil { + // If TLS was configured on the Listener, but it's invalid, don't allow any + // routes to be bound to this listener since it can't serve TLS traffic. + continue + } + case gatewayapi_v1alpha1.HTTPProtocolType: + break + default: + p.Errorf("Listener.Protocol %q is not supported.", listener.Protocol) continue } - // Check for TLS on the Gateway. - if listenerSecret = p.validGatewayTLS(listener); listenerSecret == nil { - // If TLS was configured on the Listener, but it's invalid, don't allow any - // routes to be bound to this listener since it can't serve TLS traffic. - continue + // Validate the Group on the selector is a supported type. + if listener.Routes.Group != nil { + if *listener.Routes.Group != gatewayapi_v1alpha1.GroupName { + p.Errorf("Listener.Routes.Group %q is not supported.", listener.Routes.Group) + continue + } } - case gatewayapi_v1alpha1.HTTPProtocolType: - break - default: - p.Errorf("Listener.Protocol %q is not supported.", listener.Protocol) - continue - } - // Validate the Group on the selector is a supported type. - if listener.Routes.Group != nil { - if *listener.Routes.Group != gatewayapi_v1alpha1.GroupName { - p.Errorf("Listener.Routes.Group %q is not supported.", listener.Routes.Group) + // Validate the Kind on the selector is a supported type. + if listener.Routes.Kind != KindHTTPRoute { + p.Errorf("Listener.Routes.Kind %q is not supported.", listener.Routes.Kind) continue } - } - // Validate the Kind on the selector is a supported type. - if listener.Routes.Kind != KindHTTPRoute { - p.Errorf("Listener.Routes.Kind %q is not supported.", listener.Routes.Kind) - continue - } + for _, route := range p.source.httproutes { - for _, route := range p.source.httproutes { + // Filter the HTTPRoutes that match the gateway which Contour is configured to watch. + // RouteBindingSelector defines a schema for associating routes with the Gateway. + // If Namespaces and Selector are defined, only routes matching both selectors are associated with the Gateway. - // Filter the HTTPRoutes that match the gateway which Contour is configured to watch. - // RouteBindingSelector defines a schema for associating routes with the Gateway. - // If Namespaces and Selector are defined, only routes matching both selectors are associated with the Gateway. + // ## RouteBindingSelector ## + // + // Selector specifies a set of route labels used for selecting routes to associate + // with the Gateway. If this Selector is defined, only routes matching the Selector + // are associated with the Gateway. An empty Selector matches all routes. - // ## RouteBindingSelector ## - // - // Selector specifies a set of route labels used for selecting routes to associate - // with the Gateway. If this Selector is defined, only routes matching the Selector - // are associated with the Gateway. An empty Selector matches all routes. - - nsMatches, err := p.namespaceMatches(listener.Routes.Namespaces, route) - if err != nil { - p.Errorf("error validating namespaces against Listener.Routes.Namespaces: %s", err) - } + nsMatches, err := p.namespaceMatches(listener.Routes.Namespaces, route) + if err != nil { + p.Errorf("error validating namespaces against Listener.Routes.Namespaces: %s", err) + } - selMatches, err := selectorMatches(listener.Routes.Selector, route.Labels) - if err != nil { - p.Errorf("error validating routes against Listener.Routes.Selector: %s", err) - } + selMatches, err := selectorMatches(listener.Routes.Selector, route.Labels) + if err != nil { + p.Errorf("error validating routes against Listener.Routes.Selector: %s", err) + } - // If all the match criteria for this HTTPRoute match the Gateway, then add - // the route to the set of matchingRoutes. - if selMatches && nsMatches { + // If all the match criteria for this HTTPRoute match the Gateway, then add + // the route to the set of matchingRoutes. + if selMatches && nsMatches { - gatewayAllowMatches := p.gatewayMatches(route) - if (listener.Routes.Selector != nil || listener.Routes.Namespaces != nil) && !gatewayAllowMatches { + gatewayAllowMatches := p.gatewayMatches(route) + if (listener.Routes.Selector != nil || listener.Routes.Namespaces != nil) && !gatewayAllowMatches { - // If a label selector or namespace selector matches, but the gateway Allow doesn't - // then set the "Admitted: false" for the route. - routeAccessor, commit := p.dag.StatusCache.ConditionsAccessor(k8s.NamespacedNameOf(route), route.Generation, status.ResourceHTTPRoute, route.Status.Gateways) - routeAccessor.AddCondition(gatewayapi_v1alpha1.ConditionRouteAdmitted, metav1.ConditionFalse, status.ReasonGatewayAllowMismatch, "Gateway RouteSelector matches, but GatewayAllow has mismatch.") - commit() - continue - } + // If a label selector or namespace selector matches, but the gateway Allow doesn't + // then set the "Admitted: false" for the route. + routeAccessor, commit := p.dag.StatusCache.ConditionsAccessor(k8s.NamespacedNameOf(route), route.Generation, status.ResourceHTTPRoute, route.Status.Gateways) + routeAccessor.AddCondition(gatewayapi_v1alpha1.ConditionRouteAdmitted, metav1.ConditionFalse, status.ReasonGatewayAllowMismatch, "Gateway RouteSelector matches, but GatewayAllow has mismatch.") + commit() + continue + } - if gatewayAllowMatches { - // Empty Selector matches all routes. - matchingRoutes = append(matchingRoutes, route) + if gatewayAllowMatches { + // Empty Selector matches all routes. + matchingRoutes = append(matchingRoutes, route) + } } } - } - // Process all the routes that match this Gateway. - for _, matchingRoute := range matchingRoutes { - p.computeHTTPRoute(matchingRoute, listenerSecret) + // Process all the routes that match this Gateway. + for _, matchingRoute := range matchingRoutes { + p.computeHTTPRoute(matchingRoute, listenerSecret) + } } } } diff --git a/internal/equality/equality.go b/internal/equality/equality.go new file mode 100644 index 00000000000..d8cc3e9a03b --- /dev/null +++ b/internal/equality/equality.go @@ -0,0 +1,96 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package equality + +import ( + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + gatewayv1alpha1 "sigs.k8s.io/gateway-api/apis/v1alpha1" +) + +// GatewayClassStatusChanged checks if current and expected match and if not, +// returns true. +func GatewayClassStatusChanged(current, expected gatewayv1alpha1.GatewayClassStatus) bool { + return !apiequality.Semantic.DeepEqual(current.Conditions, expected.Conditions) +} + +// GatewayStatusChanged checks if current and expected match and if not, +// returns true. +func GatewayStatusChanged(current, expected gatewayv1alpha1.GatewayStatus) bool { + return !apiequality.Semantic.DeepEqual(current.Conditions, expected.Conditions) +} + +// LoadBalancerServiceChanged checks if current and expected match and if not, returns +// true and the expected Service resource. The healthCheckNodePort and a port's nodePort +// are not compared since they are dynamically assigned. +func LoadBalancerServiceChanged(current, expected *corev1.Service) (*corev1.Service, bool) { + changed := false + updated := current.DeepCopy() + + // Ports can't simply be matched since some fields are being dynamically assigned. + if len(current.Spec.Ports) != len(expected.Spec.Ports) { + updated.Spec.Ports = expected.Spec.Ports + changed = true + } else { + for i, p := range current.Spec.Ports { + if !apiequality.Semantic.DeepEqual(p.Name, expected.Spec.Ports[i].Name) { + updated.Spec.Ports[i].Name = expected.Spec.Ports[i].Name + changed = true + } + if !apiequality.Semantic.DeepEqual(p.Protocol, expected.Spec.Ports[i].Protocol) { + updated.Spec.Ports[i].Protocol = expected.Spec.Ports[i].Protocol + changed = true + } + if !apiequality.Semantic.DeepEqual(p.Port, expected.Spec.Ports[i].Port) { + updated.Spec.Ports[i].Port = expected.Spec.Ports[i].Port + changed = true + } + if !apiequality.Semantic.DeepEqual(p.TargetPort, expected.Spec.Ports[i].TargetPort) { + updated.Spec.Ports[i].TargetPort = expected.Spec.Ports[i].TargetPort + changed = true + } + } + } + + if !apiequality.Semantic.DeepEqual(current.Spec.Selector, expected.Spec.Selector) { + updated.Spec.Selector = expected.Spec.Selector + changed = true + } + + if !apiequality.Semantic.DeepEqual(current.Spec.ExternalTrafficPolicy, expected.Spec.ExternalTrafficPolicy) { + updated.Spec.ExternalTrafficPolicy = expected.Spec.ExternalTrafficPolicy + changed = true + } + + if !apiequality.Semantic.DeepEqual(current.Spec.SessionAffinity, expected.Spec.SessionAffinity) { + updated.Spec.SessionAffinity = expected.Spec.SessionAffinity + changed = true + } + + if !apiequality.Semantic.DeepEqual(current.Spec.Type, expected.Spec.Type) { + updated.Spec.Type = expected.Spec.Type + changed = true + } + + if !apiequality.Semantic.DeepEqual(current.Annotations, expected.Annotations) { + updated.Annotations = expected.Annotations + changed = true + } + + if !changed { + return nil, false + } + + return updated, true +} diff --git a/internal/equality/equality_test.go b/internal/equality/equality_test.go new file mode 100644 index 00000000000..12b9f464529 --- /dev/null +++ b/internal/equality/equality_test.go @@ -0,0 +1,168 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package equality + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestLoadBalancerServiceChanged(t *testing.T) { + testCases := []struct { + description string + mutate func(service *corev1.Service) + expect bool + }{ + { + description: "if nothing changed", + mutate: func(_ *corev1.Service) {}, + expect: false, + }, + { + description: "if the port number changed", + mutate: func(svc *corev1.Service) { + svc.Spec.Ports[0].Port = int32(1234) + }, + expect: true, + }, + { + description: "if the target port number changed", + mutate: func(svc *corev1.Service) { + intStrPort := intstr.IntOrString{IntVal: int32(1234)} + svc.Spec.Ports[0].TargetPort = intStrPort + }, + expect: true, + }, + { + description: "if the port name changed", + mutate: func(svc *corev1.Service) { + svc.Spec.Ports[0].Name = "foo" + }, + expect: true, + }, + { + description: "if the port protocol changed", + mutate: func(svc *corev1.Service) { + svc.Spec.Ports[0].Protocol = corev1.ProtocolUDP + }, + expect: true, + }, + { + description: "if ports are added", + mutate: func(svc *corev1.Service) { + port := corev1.ServicePort{ + Name: "foo", + Protocol: corev1.ProtocolUDP, + Port: int32(1234), + TargetPort: intstr.IntOrString{IntVal: int32(1234)}, + } + svc.Spec.Ports = append(svc.Spec.Ports, port) + }, + expect: true, + }, + { + description: "if ports are removed", + mutate: func(svc *corev1.Service) { + svc.Spec.Ports = []corev1.ServicePort{} + }, + expect: true, + }, + { + description: "if the cluster IP changed", + mutate: func(svc *corev1.Service) { + svc.Spec.ClusterIP = "1.2.3.4" + }, + expect: false, + }, + { + description: "if selector changed", + mutate: func(svc *corev1.Service) { + svc.Spec.Selector = map[string]string{"foo": "bar"} + }, + expect: true, + }, + { + description: "if service type changed", + mutate: func(svc *corev1.Service) { + svc.Spec.Type = corev1.ServiceTypeClusterIP + }, + expect: true, + }, + { + description: "if session affinity changed", + mutate: func(svc *corev1.Service) { + svc.Spec.SessionAffinity = corev1.ServiceAffinityClientIP + }, + expect: true, + }, + { + description: "if external traffic policy changed", + mutate: func(svc *corev1.Service) { + svc.Spec.ExternalTrafficPolicy = corev1.ServiceExternalTrafficPolicyTypeCluster + }, + expect: true, + }, + { + description: "if annotations have changed", + mutate: func(svc *corev1.Service) { + svc.Annotations = map[string]string{} + }, + expect: true, + }, + } + + expected := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a1": "one", "a2": "two"}, + Labels: map[string]string{"l1": "one", "l2": "two"}, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "one", + Protocol: corev1.ProtocolTCP, + Port: int32(1), + TargetPort: intstr.FromInt(1), + NodePort: int32(1), + }, + { + Name: "two", + Protocol: corev1.ProtocolTCP, + Port: int32(2), + TargetPort: intstr.FromInt(2), + NodePort: int32(2), + }, + }, + Type: corev1.ServiceTypeNodePort, + Selector: map[string]string{"s1": "one", "s2": "two"}, + SessionAffinity: corev1.ServiceAffinityNone, + ExternalTrafficPolicy: corev1.ServiceExternalTrafficPolicyTypeLocal, + }, + } + + for _, tc := range testCases { + mutated := expected.DeepCopy() + tc.mutate(mutated) + if updated, changed := LoadBalancerServiceChanged(mutated, expected); changed != tc.expect { + t.Errorf("%s, expect LoadBalancerServiceChanged to be %t, got %t", tc.description, tc.expect, changed) + } else if changed { + if _, changedAgain := LoadBalancerServiceChanged(updated, expected); changedAgain { + t.Errorf("%s, LoadBalancerServiceChanged does not behave as a fixed point function", tc.description) + } + } + } +} diff --git a/internal/k8s/cache/gateway.go b/internal/k8s/cache/gateway.go index f8c942de3ed..99522581621 100644 --- a/internal/k8s/cache/gateway.go +++ b/internal/k8s/cache/gateway.go @@ -15,9 +15,22 @@ package cache import ( "context" + "fmt" + "strings" + + "github.com/projectcontour/contour/internal/equality" + "github.com/projectcontour/contour/internal/labels" + "github.com/projectcontour/contour/internal/slice" + "github.com/projectcontour/contour/internal/status" + "github.com/projectcontour/contour/internal/validation" + "github.com/projectcontour/contour/pkg/config" "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/tools/cache" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" @@ -25,13 +38,15 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" "sigs.k8s.io/controller-runtime/pkg/source" - gatewayapi_v1alpha1 "sigs.k8s.io/gateway-api/apis/v1alpha1" + gatewayv1a1 "sigs.k8s.io/gateway-api/apis/v1alpha1" ) +const finalizer = "gateway.networking.x-k8s.io/finalizer" + type gatewayReconciler struct { client client.Client eventHandler cache.ResourceEventHandler - logrus.FieldLogger + log logrus.FieldLogger } // NewGatewayController creates the gateway controller from mgr. The controller will be pre-configured @@ -40,36 +55,283 @@ func NewGatewayController(mgr manager.Manager, eventHandler cache.ResourceEventH r := &gatewayReconciler{ client: mgr.GetClient(), eventHandler: eventHandler, - FieldLogger: log, + log: log, } c, err := controller.New("gateway-controller", mgr, controller.Options{Reconciler: r}) if err != nil { return nil, err } - if err := c.Watch(&source.Kind{Type: &gatewayapi_v1alpha1.Gateway{}}, &handler.EnqueueRequestForObject{}); err != nil { + if err := c.Watch(&source.Kind{Type: &gatewayv1a1.Gateway{}}, r.enqueueRequestForOwnedGateway()); err != nil { return nil, err } + // TODO: Add a watch for gatewayclasses owned by contour to keep gateway status updated. return c, nil } +// enqueueRequestForOwnedGateway returns an event handler that maps events to +// Gateway objects that reference a GatewayClass owned by Contour. +func (r *gatewayReconciler) enqueueRequestForOwnedGateway() handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(a client.Object) []reconcile.Request { + gw, ok := a.(*gatewayv1a1.Gateway) + if !ok { + r.log.WithField("name", a.GetName()).WithField("namespace", a.GetNamespace()).Info("invalid object, bypassing reconciliation.") + return []reconcile.Request{} + } + if err := classForGateway(context.Background(), r.client, gw); err != nil { + r.log.WithField("namespace", gw.Namespace).WithField("name", gw.Name).Info(err, ", bypassing reconciliation") + return []reconcile.Request{} + } + // The gateway references a gatewayclass that exists and is managed + // by Contour, so enqueue it for reconciliation. + r.log.WithField("namespace", gw.Namespace).WithField("name", gw.Name).Info("queueing gateway") + return []reconcile.Request{ + { + NamespacedName: types.NamespacedName{ + Namespace: gw.Namespace, + Name: gw.Name, + }, + }, + } + }) +} + +// classForGateway returns an error if gw does not exist or is not owned by Contour. +func classForGateway(ctx context.Context, cli client.Client, gw *gatewayv1a1.Gateway) error { + gc := &gatewayv1a1.GatewayClass{} + if err := cli.Get(ctx, types.NamespacedName{Name: gw.Spec.GatewayClassName}, gc); err != nil { + return fmt.Errorf("failed to get gatewayclass %s: %w", gw.Spec.GatewayClassName, err) + } + if !isController(gc) { + return fmt.Errorf("gatewayclass %s not owned by contour", gw.Spec.GatewayClassName) + } + return nil +} + +// isController returns true if Contour is the controller for gc. +func isController(gc *gatewayv1a1.GatewayClass) bool { + return gc.Spec.Controller == config.ContourGatewayClass +} + func (r *gatewayReconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + r.log.WithField("namespace", request.Namespace).WithField("name", request.Name).Info("reconciling gateway") // Fetch the Gateway from the cache. - gateway := &gatewayapi_v1alpha1.Gateway{} - err := r.client.Get(ctx, request.NamespacedName, gateway) - if errors.IsNotFound(err) { - r.Error(nil, "Could not find Gateway %q in Namespace %q", request.Name, request.Namespace) - return reconcile.Result{}, nil + gw := &gatewayv1a1.Gateway{} + if err := r.client.Get(ctx, request.NamespacedName, gw); err != nil { + if errors.IsNotFound(err) { + r.log.WithField("name", request.Name).WithField("namespace", request.Namespace).Info("failed to find gateway") + return reconcile.Result{}, nil + } + // Error reading the object, so requeue the request. + return reconcile.Result{}, fmt.Errorf("failed to get gateway %s/%s: %w", request.Namespace, request.Name, err) } // Check if object is deleted. - if !gateway.ObjectMeta.DeletionTimestamp.IsZero() { - r.eventHandler.OnDelete(gateway) + if !gw.ObjectMeta.DeletionTimestamp.IsZero() { + r.eventHandler.OnDelete(gw) + // TODO: Add method to remove gateway sub-resources and finalizer. return reconcile.Result{}, nil } // Pass the new changed object off to the eventHandler. - r.eventHandler.OnAdd(gateway) + r.eventHandler.OnAdd(gw) + + // Check if the gateway is valid. + valid := true + if err := validation.Gateway(ctx, r.client, gw); err != nil { + r.log.WithField("namespace", gw.Namespace).WithField("name", gw.Name).Info("invalid gateway: ", err) + valid = false + } + + if valid { + if !isFinalized(gw) { + // Before doing anything with the gateway, ensure it has a finalizer + // so it can cleaned-up later. + if err := ensureFinalizer(ctx, r.client, gw); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to finalize gateway %s/%s: %w", gw.Namespace, gw.Name, err) + } + r.log.WithField("name", request.Name).WithField("namespace", request.Namespace).Info("finalized gateway") + // The gateway has been mutated, so update gw. + if err := r.client.Get(ctx, request.NamespacedName, gw); err != nil { + if errors.IsNotFound(err) { + r.log.WithField("name", request.Name).WithField("namespace", request.Namespace).Info("failed to find gateway") + return reconcile.Result{}, nil + } + // Error reading the object, so requeue the request. + return reconcile.Result{}, fmt.Errorf("failed to get gateway %s/%s: %w", request.Namespace, request.Name, err) + } + } + // The gateway is safe to process. + if err := r.ensureGateway(ctx, gw); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get ensure gateway %s/%s: %w", request.Namespace, request.Name, err) + } + } + + if err := status.SyncGateway(ctx, r.client, gw, valid); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to sync gateway %s/%s status: %w", gw.Namespace, gw.Name, err) + } + r.log.WithField("namespace", gw.Namespace).WithField("name", gw.Name).Info("synced gateway status") return reconcile.Result{}, nil } + +// ensureGateway ensures all necessary resources exist for the given gw. +func (r *gatewayReconciler) ensureGateway(ctx context.Context, gw *gatewayv1a1.Gateway) error { + cli := r.client + + if err := ensureEnvoyService(ctx, cli, gw); err != nil { + return fmt.Errorf("failed to ensure envoy service: %w", err) + } + r.log.WithField("namespace", gw.Namespace).WithField("name", gw.Name).Info("ensured envoy service") + + return nil +} + +// isFinalized returns true if gw is finalized. +func isFinalized(gw *gatewayv1a1.Gateway) bool { + for _, f := range gw.Finalizers { + if f == finalizer { + return true + } + } + return false +} + +// ensureFinalizer ensures the finalizer is added to the given gw. +func ensureFinalizer(ctx context.Context, cli client.Client, gw *gatewayv1a1.Gateway) error { + if !slice.ContainsString(gw.Finalizers, finalizer) { + updated := gw.DeepCopy() + updated.Finalizers = append(updated.Finalizers, finalizer) + if err := cli.Update(ctx, updated); err != nil { + return fmt.Errorf("failed to add finalizer %s: %w", finalizer, err) + } + } + return nil +} + +// ensureEnvoyService ensures that an Envoy Service exists for the given gw. +func ensureEnvoyService(ctx context.Context, cli client.Client, gw *gatewayv1a1.Gateway) error { + desired := desiredService(gw) + current, err := currentService(ctx, cli, gw) + if err != nil { + if errors.IsNotFound(err) { + return createService(ctx, cli, desired) + } + return fmt.Errorf("failed to get service %s/%s: %w", desired.Namespace, desired.Name, err) + } + if err := updateServiceIfNeeded(ctx, cli, gw, current, desired); err != nil { + return fmt.Errorf("failed to update service %s/%s: %w", desired.Namespace, desired.Name, err) + } + return nil +} + +// desiredService generates the desired Envoy Service for the given gw. +func desiredService(gw *gatewayv1a1.Gateway) *corev1.Service { + var ports []corev1.ServicePort + for _, l := range gw.Spec.Listeners { + var p corev1.ServicePort + httpFound := false + httpsFound := false + switch { + case httpsFound && httpFound: + break + case l.Protocol == gatewayv1a1.HTTPProtocolType: + httpFound = true + p.Name = strings.ToLower(string(gatewayv1a1.HTTPProtocolType)) + p.Port = int32(l.Port) + p.Protocol = corev1.ProtocolTCP + // TODO: 8080 is default Contour insecure port. Update when a config CRD is added. + p.TargetPort = intstr.IntOrString{IntVal: 8080} + ports = append(ports, p) + case l.Protocol == gatewayv1a1.HTTPSProtocolType: + httpsFound = true + p.Name = strings.ToLower(string(gatewayv1a1.HTTPSProtocolType)) + p.Port = int32(l.Port) + p.Protocol = corev1.ProtocolTCP + // TODO: 8443 is default Contour secure port. Update when a config CRD is added. + p.TargetPort = intstr.IntOrString{IntVal: 8443} + ports = append(ports, p) + } + } + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: gw.Namespace, + Name: "envoy", + Annotations: map[string]string{"service.beta.kubernetes.io/aws-load-balancer-backend-protocol": "tcp"}, + Labels: map[string]string{ + labels.OwningGatewayNs: gw.Namespace, + labels.OwningGatewayName: gw.Name, + }, + }, + Spec: corev1.ServiceSpec{ + Ports: ports, + Selector: labels.EnvoySelector().MatchLabels, + SessionAffinity: corev1.ServiceAffinityNone, + }, + } + svc.Spec.ExternalTrafficPolicy = corev1.ServiceExternalTrafficPolicyTypeLocal + // TODO: Add support for different network publishing types when config CRD is added. + svc.Spec.Type = corev1.ServiceTypeLoadBalancer + // NodePorts are configured to support testing on kind clusters. + for _, l := range gw.Spec.Listeners { + if l.Protocol == gatewayv1a1.HTTPProtocolType { + for i, p := range ports { + if p.Name == strings.ToLower(string(gatewayv1a1.HTTPProtocolType)) { + svc.Spec.Ports[i].NodePort = int32(30080) + } + } + } + if l.Protocol == gatewayv1a1.HTTPSProtocolType { + for i, p := range ports { + if p.Name == strings.ToLower(string(gatewayv1a1.HTTPSProtocolType)) { + svc.Spec.Ports[i].NodePort = int32(30443) + } + } + } + } + + return svc +} + +// currentService returns the current Envoy Service for the provided gw. +func currentService(ctx context.Context, cli client.Client, gw *gatewayv1a1.Gateway) (*corev1.Service, error) { + current := &corev1.Service{} + key := types.NamespacedName{ + Namespace: gw.Namespace, + Name: "envoy", + } + err := cli.Get(ctx, key, current) + if err != nil { + return nil, err + } + return current, nil +} + +// createService creates a Service resource for the provided svc. +func createService(ctx context.Context, cli client.Client, svc *corev1.Service) error { + if err := cli.Create(ctx, svc); err != nil { + return fmt.Errorf("failed to create service %s/%s: %w", svc.Namespace, svc.Name, err) + } + return nil +} + +// updateServiceIfNeeded updates an Envoy Service if current does not match desired, +// using gw to verify the existence of owner labels. +func updateServiceIfNeeded(ctx context.Context, cli client.Client, gw *gatewayv1a1.Gateway, current, desired *corev1.Service) error { + if !labels.Exist(current, labels.GatewayOwner(gw)) { + // The current service doesn't contain Contour owner labels. + return nil + } + + // Using the returned Service instead of the desired parameter since clusterIP is immutable. + // TODO: Add support for different network publishing types when config CRD is added. + updated, needed := equality.LoadBalancerServiceChanged(current, desired) + if !needed { + return nil + } + if err := cli.Update(ctx, updated); err != nil { + return fmt.Errorf("failed to update service %s/%s: %w", updated.Namespace, updated.Name, err) + } + + return nil +} diff --git a/internal/k8s/cache/gatewayclass.go b/internal/k8s/cache/gatewayclass.go new file mode 100644 index 00000000000..44a732f28bd --- /dev/null +++ b/internal/k8s/cache/gatewayclass.go @@ -0,0 +1,125 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache + +import ( + "context" + "fmt" + + "github.com/projectcontour/contour/internal/status" + "github.com/projectcontour/contour/internal/validation" + "github.com/projectcontour/contour/pkg/config" + + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + gatewayapi_v1alpha1 "sigs.k8s.io/gateway-api/apis/v1alpha1" +) + +type gatewayClassReconciler struct { + client client.Client + eventHandler cache.ResourceEventHandler + log logrus.FieldLogger +} + +// NewGatewayClassController creates the gatewayclass controller from mgr. The controller will be +// pre-configured to watch for GatewayClass objects. +func NewGatewayClassController(mgr manager.Manager, eventHandler cache.ResourceEventHandler, log logrus.FieldLogger) (controller.Controller, error) { + r := &gatewayClassReconciler{ + client: mgr.GetClient(), + eventHandler: eventHandler, + log: log, + } + + c, err := controller.New("gatewayclass-controller", mgr, controller.Options{Reconciler: r}) + if err != nil { + return nil, err + } + + // Only enqueue GatewayClass objects that specify contour as the controller. + if err := c.Watch(&source.Kind{Type: &gatewayapi_v1alpha1.GatewayClass{}}, r.enqueueRequestForGatewayClass()); err != nil { + return nil, err + } + + return c, nil +} + +// enqueueRequestForGatewayClass returns an event handler that maps events to +// GatewayClass objects owned by the controller. +func (r *gatewayClassReconciler) enqueueRequestForGatewayClass() handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(a client.Object) []reconcile.Request { + gc, ok := a.(*gatewayapi_v1alpha1.GatewayClass) + if !ok { + r.log.WithField("name", a.GetName()).Info("invalid object, bypassing reconciliation.") + return []reconcile.Request{} + } + if gc.Spec.Controller == config.ContourGatewayClass { + r.log.WithField("name", gc.Name).Info("queueing gatewayclass") + return []reconcile.Request{ + { + NamespacedName: types.NamespacedName{ + Name: gc.Name, + }, + }, + } + } + r.log.WithField("name", gc.Name).Info("gatewayclass not owned by contour, bypassing reconciliation") + return []reconcile.Request{} + }) +} + +func (r *gatewayClassReconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + r.log.WithField("name", request.Name).Info("reconciling gatewayclass") + + // Fetch the Gateway from the cache. + gc := &gatewayapi_v1alpha1.GatewayClass{} + if err := r.client.Get(ctx, request.NamespacedName, gc); err != nil { + if errors.IsNotFound(err) { + r.log.WithField("name", request.Name).Info("failed to find gatewayclass") + return reconcile.Result{}, nil + } + // Error reading the object, so requeue the request. + return reconcile.Result{}, fmt.Errorf("failed to get gatewayclass %q: %w", request.Name, err) + } + + // Check if object is marked for deletion. + if !gc.ObjectMeta.DeletionTimestamp.IsZero() { + r.eventHandler.OnDelete(gc) + return reconcile.Result{}, nil + } + + // Pass the new changed object off to the eventHandler. + r.eventHandler.OnAdd(gc) + + // The gatewayclass is safe to process, so check if it's valid. + valid := true + if err := validation.GatewayClass(gc); err != nil { + r.log.WithField("name", gc.Name).Error("invalid gatewayclass: ", err) + valid = false + } + + if err := status.SyncGatewayClass(ctx, r.client, gc, valid); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to sync gatewayclass %q status: %w", gc.Name, err) + } + r.log.WithField("name", gc.Name).Info("synced gatewayclass status") + + return reconcile.Result{}, nil +} diff --git a/internal/k8s/informers.go b/internal/k8s/informers.go index 0f3d62a7082..33fc9522526 100644 --- a/internal/k8s/informers.go +++ b/internal/k8s/informers.go @@ -53,12 +53,18 @@ func IngressV1Resources() []schema.GroupVersionResource { } } -// +kubebuilder:rbac:groups="networking.x-k8s.io",resources=gateways;httproutes;backendpolicies;tlsroutes;tcproutes;udproutes,verbs=get;list;watch -// +kubebuilder:rbac:groups="networking.x-k8s.io",resources=httproutes/status;backendpolicies/status;tlsroutes/status;tcproutes/status;udproutes/status,verbs=update +// +kubebuilder:rbac:groups="networking.x-k8s.io",resources=gatewayclasses;gateways;httproutes;backendpolicies;tlsroutes;tcproutes;udproutes,verbs=get;list;watch +// +kubebuilder:rbac:groups="networking.x-k8s.io",resources=gatewayclasses/status;httproutes/status;backendpolicies/status;tlsroutes/status;tcproutes/status;udproutes/status,verbs=update +// Required for contour to manage the envoy service when using Gateway API. +// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;delete;create;update -// GatewayAPIResources ... +// GatewayAPIResources returns a list of Gateway API group/version resources. func GatewayAPIResources() []schema.GroupVersionResource { return []schema.GroupVersionResource{{ + Group: gatewayapi_v1alpha1.GroupVersion.Group, + Version: gatewayapi_v1alpha1.GroupVersion.Version, + Resource: "gatewayclasses", + }, { Group: gatewayapi_v1alpha1.GroupVersion.Group, Version: gatewayapi_v1alpha1.GroupVersion.Version, Resource: "gateways", diff --git a/internal/labels/labels.go b/internal/labels/labels.go new file mode 100644 index 00000000000..b7f70a7bc67 --- /dev/null +++ b/internal/labels/labels.go @@ -0,0 +1,58 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package labels + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1a1 "sigs.k8s.io/gateway-api/apis/v1alpha1" +) + +const ( + OwningGatewayNs = "contour.projectcontour.io/owning-gateway-namespace" + OwningGatewayName = "contour.projectcontour.io/owning-gateway-name" +) + +// Exist returns true if obj contains labels m. +func Exist(obj client.Object, m map[string]string) bool { + labels := obj.GetLabels() + if labels == nil { + return false + } + for key, val := range m { + if found, ok := labels[key]; !ok || found != val { + return false + } + } + return true +} + +// EnvoySelector returns a label selector using "app: envoy" as the key/value pair. +// TODO [danehans]: Update to use "contour.projectcontour.io/daemonset-envoy" +// when https://github.com/projectcontour/contour/issues/1821 is fixed. +func EnvoySelector() *metav1.LabelSelector { + return &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "envoy", + }, + } +} + +// GatewayOwner returns owner labels for the provided gw. +func GatewayOwner(gw *gatewayv1a1.Gateway) map[string]string { + return map[string]string{ + OwningGatewayNs: gw.Namespace, + OwningGatewayName: gw.Name, + } +} diff --git a/internal/labels/labels_test.go b/internal/labels/labels_test.go new file mode 100644 index 00000000000..3ef4876fc60 --- /dev/null +++ b/internal/labels/labels_test.go @@ -0,0 +1,75 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package labels + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestExist(t *testing.T) { + testCases := []struct { + description string + current map[string]string + exist map[string]string + expected bool + }{ + { + description: "one matched label", + current: map[string]string{"name": "foo"}, + exist: map[string]string{"name": "foo"}, + expected: true, + }, + { + description: "one of two matched labels", + current: map[string]string{"name": "foo"}, + exist: map[string]string{"name": "foo", "ns": "foo-ns"}, + expected: false, + }, + { + description: "two matched labels", + current: map[string]string{"name": "foo", "ns": "foo-ns"}, + exist: map[string]string{"name": "foo", "ns": "foo-ns"}, + expected: true, + }, + { + description: "four labels, two matched", + current: map[string]string{"name": "foo", "ns": "foo-ns", "bar": "baz", "biz": "bar"}, + exist: map[string]string{"name": "foo", "ns": "foo-ns"}, + expected: true, + }, + { + description: "one unmatched label", + current: map[string]string{"foo": "baz"}, + exist: map[string]string{"foo": "bar"}, + expected: false, + }, + { + description: "two unmatched labels", + current: map[string]string{"name": "bar"}, + exist: map[string]string{"name": "bar", "ns": "foo-ns"}, + expected: false, + }, + } + + p := corev1.Pod{} + for _, tc := range testCases { + p.Labels = tc.current + result := Exist(&p, tc.exist) + if result != tc.expected { + t.Fatalf("%q: returned %t, expected %t.", tc.description, result, tc.expected) + } + } +} diff --git a/internal/retryableerror/retryableerror.go b/internal/retryableerror/retryableerror.go new file mode 100644 index 00000000000..7f90a226b32 --- /dev/null +++ b/internal/retryableerror/retryableerror.go @@ -0,0 +1,72 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package retryableerror + +import ( + "time" + + utilerrors "k8s.io/apimachinery/pkg/util/errors" +) + +// Error represents an error for an operation that should be retried after the +// specified duration. +type Error interface { + error + // After is the time period after which the operation that caused the + // error should be retried. + After() time.Duration +} + +// New returns a new RetryableError with the given error and time period. +func New(err error, after time.Duration) Error { + return retryableError{err, after} +} + +type retryableError struct { + error + after time.Duration +} + +// After returns the time period after which the operation that caused the error +// should be retried. +func (r retryableError) After() time.Duration { + return r.after +} + +// NewMaybeRetryableAggregate converts a slice of errors into a single error +// value. Nil values will be filtered from the slice. If the filtered slice is +// empty, the return value will be nil. Else, if any values are non-retryable +// errors, the result will be an Aggregate interface. Else, if all errors are +// retryable, the result will be a retryable Error interface, with After() equal +// to the minimum of all the errors' After() values. +func NewMaybeRetryableAggregate(errs []error) error { + aggregate := utilerrors.NewAggregate(errs) + if aggregate == nil { + return nil + } + afterHasInitialValue := false + var after time.Duration + for _, err := range aggregate.Errors() { + switch e := err.(type) { + case Error: + if !afterHasInitialValue || e.After() < after { + after = e.After() + } + afterHasInitialValue = true + default: + return aggregate + } + } + return New(aggregate, after) +} diff --git a/internal/retryableerror/retryableerror_test.go b/internal/retryableerror/retryableerror_test.go new file mode 100644 index 00000000000..263465b0338 --- /dev/null +++ b/internal/retryableerror/retryableerror_test.go @@ -0,0 +1,72 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package retryableerror + +import ( + "errors" + "testing" + "time" + + utilerrors "k8s.io/apimachinery/pkg/util/errors" +) + +func TestRetryableError(t *testing.T) { + tests := map[string]struct { + errors []error + expectRetryable bool + expectAggregate bool + expectAfter time.Duration + }{ + "empty list": {}, + "nil error": { + errors: []error{nil}, + }, + "non-retryable errors": { + errors: []error{errors.New("foo"), errors.New("bar")}, + expectAggregate: true, + }, + "mix of retryable and non-retryable errors": { + errors: []error{ + errors.New("foo"), + errors.New("bar"), + New(errors.New("baz"), time.Second*15), + New(errors.New("quux"), time.Minute), + }, + expectAggregate: true, + }, + "only retryable errors": { + errors: []error{ + New(errors.New("baz"), time.Second*15), + New(errors.New("quux"), time.Minute), + nil, + }, + expectRetryable: true, + expectAfter: time.Second * 15, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + err := NewMaybeRetryableAggregate(test.errors) + if retryable, gotRetryable := err.(Error); gotRetryable != test.expectRetryable { + t.Errorf("expected retryable %T, got %T: %w", test.expectRetryable, gotRetryable, err) + } else if gotRetryable && retryable.After() != test.expectAfter { + t.Errorf("expected after %v, got %v: %w", test.expectAfter, retryable.After(), err) + } + if _, gotAggregate := err.(utilerrors.Aggregate); gotAggregate != test.expectAggregate { + t.Errorf("expected aggregate %T, got %T: %w", test.expectAggregate, gotAggregate, err) + } + }) + } +} diff --git a/internal/slice/slice.go b/internal/slice/slice.go new file mode 100644 index 00000000000..1dc9378ccb5 --- /dev/null +++ b/internal/slice/slice.go @@ -0,0 +1,42 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slice + +// RemoveString returns a newly created []string that contains all items from slice that +// are not equal to s. +func RemoveString(slice []string, s string) []string { + newSlice := make([]string, 0) + for _, item := range slice { + if item == s { + continue + } + newSlice = append(newSlice, item) + } + if len(newSlice) == 0 { + // Sanitize for unit tests so we don't need to distinguish empty array + // and nil. + newSlice = nil + } + return newSlice +} + +// ContainsString checks if a given slice of strings contains the provided string. +func ContainsString(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} diff --git a/internal/slice/slice_test.go b/internal/slice/slice_test.go new file mode 100644 index 00000000000..e4d12e15e1c --- /dev/null +++ b/internal/slice/slice_test.go @@ -0,0 +1,72 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slice + +import ( + "reflect" + "testing" +) + +func TestRemoveString(t *testing.T) { + testCases := []struct { + name string + in []string + remove string + out []string + }{ + { + name: "one string, remove one", + in: []string{"one"}, + remove: "one", + out: nil, + }, + { + name: "two strings, remove first string", + in: []string{"one", "two"}, + remove: "one", + out: []string{"two"}, + }, + { + name: "two strings, remove second string", + in: []string{"one", "two"}, + remove: "two", + out: []string{"one"}, + }, + { + name: "two strings, remove one that doesn't exist", + in: []string{"one", "two"}, + remove: "three", + out: []string{"one", "two"}, + }, + { + name: "three strings, remove the second string", + in: []string{"one", "two", "three"}, + remove: "two", + out: []string{"one", "three"}, + }, + { + name: "three strings, remove empty string", + in: []string{"one", "two", "three"}, + remove: "", + out: []string{"one", "two", "three"}, + }, + } + + for _, tc := range testCases { + out := RemoveString(tc.in, tc.remove) + if !reflect.DeepEqual(out, tc.out) { + t.Errorf("%s, expected slice to be %v, got %v", tc.name, tc.out, out) + } + } +} diff --git a/internal/status/conditions.go b/internal/status/conditions.go index b50331e3467..2109d1d0ed3 100644 --- a/internal/status/conditions.go +++ b/internal/status/conditions.go @@ -19,6 +19,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + utilclock "k8s.io/apimachinery/pkg/util/clock" gatewayapi_v1alpha1 "sigs.k8s.io/gateway-api/apis/v1alpha1" ) @@ -38,6 +39,9 @@ const ReasonValid RouteReasonType = "Valid" const ReasonErrorsExist RouteReasonType = "ErrorsExist" const ReasonGatewayAllowMismatch RouteReasonType = "GatewayAllowMismatch" +// clock is used to set lastTransitionTime on status conditions. +var clock utilclock.Clock = utilclock.RealClock{} + type ConditionsUpdate struct { FullName types.NamespacedName Conditions map[gatewayapi_v1alpha1.RouteConditionType]metav1.Condition @@ -177,3 +181,87 @@ func (c *Cache) getGatewayConditions(gatewayStatus []gatewayapi_v1alpha1.RouteGa } return map[gatewayapi_v1alpha1.RouteConditionType]metav1.Condition{} } + +// computeGatewayClassAdmittedCondition computes the Admitted status condition based +// on the provided valid. +func computeGatewayClassAdmittedCondition(valid bool) metav1.Condition { + c := metav1.Condition{ + Type: string(gatewayapi_v1alpha1.GatewayClassConditionStatusAdmitted), + Status: metav1.ConditionTrue, + Reason: "Valid", + Message: "Valid GatewayClass.", + } + + if !valid { + c.Status = metav1.ConditionFalse + c.Reason = "Invalid" + c.Message = "Invalid GatewayClass." + } + + return c +} + +// mergeConditions adds or updates matching conditions, and updates the transition +// time if details of a condition have changed. Returns the updated condition array. +func mergeConditions(conditions []metav1.Condition, updates ...metav1.Condition) []metav1.Condition { + now := metav1.NewTime(clock.Now()) + var additions []metav1.Condition + for i, update := range updates { + add := true + for j, cond := range conditions { + if cond.Type == update.Type { + add = false + if conditionChanged(cond, update) { + conditions[j].Status = update.Status + conditions[j].Reason = update.Reason + conditions[j].Message = update.Message + if cond.Status != update.Status { + conditions[j].LastTransitionTime = now + } + break + } + } + } + if add { + updates[i].LastTransitionTime = now + additions = append(additions, updates[i]) + } + } + conditions = append(conditions, additions...) + return conditions +} + +func conditionChanged(a, b metav1.Condition) bool { + return a.Status != b.Status || a.Reason != b.Reason || a.Message != b.Message +} + +// removeGatewayCondition returns a newly created []metav1.Condition that contains all items +// from conditions that are not equal to condition type t. +func removeGatewayCondition(conditions []metav1.Condition, t gatewayapi_v1alpha1.GatewayConditionType) []metav1.Condition { + var new []metav1.Condition + if len(conditions) > 0 { + for _, c := range conditions { + if c.Type != string(t) { + new = append(new, c) + } + } + } + return new +} + +// computeGatewayReadyCondition computes the Gateway "Ready" status condition +// based on the provided valid. +func computeGatewayReadyCondition(valid bool) metav1.Condition { + c := metav1.Condition{ + Type: string(gatewayapi_v1alpha1.GatewayConditionReady), + Status: metav1.ConditionTrue, + Reason: "GatewayReady", + Message: "The Gateway is ready to serve routes.", + } + if !valid { + c.Status = metav1.ConditionFalse + c.Reason = "InvalidGatewayClass" + c.Message = "The referenced GatewayClass is invalid." + } + return c +} diff --git a/internal/status/conditions_test.go b/internal/status/conditions_test.go index 472ab1a3541..3c5fb1c7531 100644 --- a/internal/status/conditions_test.go +++ b/internal/status/conditions_test.go @@ -15,12 +15,15 @@ package status import ( "testing" + "time" projectcontour "github.com/projectcontour/contour/apis/projectcontour/v1" "github.com/projectcontour/contour/internal/k8s" "github.com/stretchr/testify/assert" + apiequality "k8s.io/apimachinery/pkg/api/equality" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - gatewayapi_v1alpha1 "sigs.k8s.io/gateway-api/apis/v1alpha1" + utilclock "k8s.io/apimachinery/pkg/util/clock" + gatewayv1a1 "sigs.k8s.io/gateway-api/apis/v1alpha1" ) func TestHTTPRouteAddCondition(t *testing.T) { @@ -28,7 +31,7 @@ func TestHTTPRouteAddCondition(t *testing.T) { var testGeneration int64 = 7 simpleValidCondition := metav1.Condition{ - Type: string(gatewayapi_v1alpha1.ConditionRouteAdmitted), + Type: string(gatewayv1a1.ConditionRouteAdmitted), Status: projectcontour.ConditionTrue, Reason: "Valid", Message: "Valid HTTPRoute", @@ -38,10 +41,10 @@ func TestHTTPRouteAddCondition(t *testing.T) { httpRouteUpdate := ConditionsUpdate{ FullName: k8s.NamespacedNameFrom("test/test"), Generation: testGeneration, - Conditions: make(map[gatewayapi_v1alpha1.RouteConditionType]metav1.Condition), + Conditions: make(map[gatewayv1a1.RouteConditionType]metav1.Condition), } - got := httpRouteUpdate.AddCondition(gatewayapi_v1alpha1.ConditionRouteAdmitted, metav1.ConditionTrue, "Valid", "Valid HTTPRoute") + got := httpRouteUpdate.AddCondition(gatewayv1a1.ConditionRouteAdmitted, metav1.ConditionTrue, "Valid", "Valid HTTPRoute") assert.Equal(t, simpleValidCondition.Message, got.Message) assert.Equal(t, simpleValidCondition.Reason, got.Reason) @@ -49,3 +52,213 @@ func TestHTTPRouteAddCondition(t *testing.T) { assert.Equal(t, simpleValidCondition.Status, got.Status) assert.Equal(t, simpleValidCondition.ObservedGeneration, got.ObservedGeneration) } + +func newCondition(t string, status metav1.ConditionStatus, reason, msg string, lt time.Time) metav1.Condition { + return metav1.Condition{ + Type: t, + Status: status, + Reason: reason, + Message: msg, + LastTransitionTime: metav1.NewTime(lt), + } +} + +func TestComputeGatewayClassAdmittedCondition(t *testing.T) { + testCases := []struct { + description string + valid bool + expect metav1.Condition + }{ + { + description: "valid gatewayclass", + valid: true, + expect: metav1.Condition{ + Type: string(gatewayv1a1.GatewayClassConditionStatusAdmitted), + Status: metav1.ConditionTrue, + }, + }, + { + description: "invalid gatewayclass", + valid: false, + expect: metav1.Condition{ + Type: string(gatewayv1a1.GatewayClassConditionStatusAdmitted), + Status: metav1.ConditionFalse, + }, + }, + } + + for _, tc := range testCases { + actual := computeGatewayClassAdmittedCondition(tc.valid) + if !apiequality.Semantic.DeepEqual(actual.Type, tc.expect.Type) || + !apiequality.Semantic.DeepEqual(actual.Status, tc.expect.Status) { + t.Fatalf("%q: expected %#v, got %#v", tc.description, tc.expect, actual) + } + } +} + +func TestComputeGatewayReadyCondition(t *testing.T) { + testCases := []struct { + description string + valid bool + expect metav1.Condition + }{ + { + description: "valid gateway", + valid: true, + expect: metav1.Condition{ + Type: string(gatewayv1a1.GatewayConditionReady), + Status: metav1.ConditionTrue, + }, + }, + { + description: "invalid gateway", + valid: false, + expect: metav1.Condition{ + Type: string(gatewayv1a1.GatewayConditionReady), + Status: metav1.ConditionFalse, + }, + }, + } + + for _, tc := range testCases { + actual := computeGatewayReadyCondition(tc.valid) + if !apiequality.Semantic.DeepEqual(actual.Type, tc.expect.Type) || + !apiequality.Semantic.DeepEqual(actual.Status, tc.expect.Status) { + t.Fatalf("%q: expected %#v, got %#v", tc.description, tc.expect, actual) + } + } +} + +func TestConditionChanged(t *testing.T) { + testCases := []struct { + description string + expected bool + a, b metav1.Condition + }{ + { + description: "nil and non-nil current are equal", + expected: false, + a: metav1.Condition{}, + }, + { + description: "empty slices should be equal", + expected: false, + a: metav1.Condition{}, + b: metav1.Condition{}, + }, + { + description: "condition LastTransitionTime should be ignored", + expected: false, + a: metav1.Condition{ + Type: string(gatewayv1a1.GatewayClassConditionStatusAdmitted), + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Unix(0, 0), + }, + b: metav1.Condition{ + Type: string(gatewayv1a1.GatewayClassConditionStatusAdmitted), + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Unix(1, 0), + }, + }, + { + description: "check condition reason differs", + expected: true, + a: metav1.Condition{ + Type: string(gatewayv1a1.GatewayConditionReady), + Status: metav1.ConditionFalse, + Reason: "foo", + }, + b: metav1.Condition{ + Type: string(gatewayv1a1.GatewayConditionReady), + Status: metav1.ConditionFalse, + Reason: "bar", + }, + }, + { + description: "condition status differs", + expected: true, + a: metav1.Condition{ + Type: string(gatewayv1a1.GatewayClassConditionStatusAdmitted), + Status: metav1.ConditionTrue, + }, + b: metav1.Condition{ + Type: string(gatewayv1a1.GatewayClassConditionStatusAdmitted), + Status: metav1.ConditionFalse, + }, + }, + } + + for _, tc := range testCases { + if actual := conditionChanged(tc.a, tc.b); actual != tc.expected { + t.Fatalf("%q: expected %v, got %v", tc.description, tc.expected, actual) + } + } +} + +func TestMergeConditions(t *testing.T) { + // Inject a fake clock and don't forget to reset it + fakeClock := utilclock.NewFakeClock(time.Time{}) + clock = fakeClock + defer func() { + clock = utilclock.RealClock{} + }() + + start := fakeClock.Now() + middle := start.Add(1 * time.Minute) + later := start.Add(2 * time.Minute) + + testCases := []struct { + description string + current []metav1.Condition + updates []metav1.Condition + expected []metav1.Condition + }{ + { + description: "status updated", + current: []metav1.Condition{ + newCondition("available", "false", "Reason", "Message", start), + }, + updates: []metav1.Condition{ + newCondition("available", "true", "Reason", "Message", middle), + }, + expected: []metav1.Condition{ + newCondition("available", "true", "Reason", "Message", later), + }, + }, + { + description: "reason updated", + current: []metav1.Condition{ + newCondition("available", "false", "Reason", "Message", start), + }, + updates: []metav1.Condition{ + newCondition("available", "false", "New Reason", "Message", middle), + }, + expected: []metav1.Condition{ + newCondition("available", "false", "New Reason", "Message", start), + }, + }, + { + description: "message updated", + current: []metav1.Condition{ + newCondition("available", "false", "Reason", "Message", start), + }, + updates: []metav1.Condition{ + newCondition("available", "false", "Reason", "New Message", middle), + }, + expected: []metav1.Condition{ + newCondition("available", "false", "Reason", "New Message", start), + }, + }, + } + + // Simulate the passage of time between original condition creation + // and update processing + fakeClock.SetTime(later) + + for _, tc := range testCases { + actual := mergeConditions(tc.current, tc.updates...) + if conditionChanged(tc.expected[0], actual[0]) { + t.Errorf("expected:\n%v\nactual:\n%v", tc.expected, actual) + } + } +} diff --git a/internal/status/gateway.go b/internal/status/gateway.go new file mode 100644 index 00000000000..e309e898860 --- /dev/null +++ b/internal/status/gateway.go @@ -0,0 +1,52 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package status + +import ( + "context" + "fmt" + + "github.com/projectcontour/contour/internal/equality" + retryable "github.com/projectcontour/contour/internal/retryableerror" + + "sigs.k8s.io/controller-runtime/pkg/client" + gateway_v1alpha1 "sigs.k8s.io/gateway-api/apis/v1alpha1" +) + +// SyncGateway computes the current status of gw and updates status based on +// any changes since last sync. +func SyncGateway(ctx context.Context, cli client.Client, gw *gateway_v1alpha1.Gateway, valid bool) error { + var errs []error + + updated := gw.DeepCopy() + + // TODO [danehans] Check the CRD, i.e. envoys.projectcontour.io, status when introduced. + + // Gateway's contain a default status condition that must be removed when reconciled by a controller. + updated.Status.Conditions = removeGatewayCondition(updated.Status.Conditions, gateway_v1alpha1.GatewayConditionScheduled) + updated.Status.Conditions = mergeConditions(updated.Status.Conditions, + computeGatewayReadyCondition(valid)) + + // Update status if current does not match desired. + // TODO [danehans]: Get the IP of the Envoy service and provide to gateway.status.addresses. + updated.Status.Addresses = []gateway_v1alpha1.GatewayAddress{} + if equality.GatewayStatusChanged(gw.Status, updated.Status) { + if err := cli.Status().Update(ctx, updated); err != nil { + errs = append(errs, fmt.Errorf("failed to update gateway %s/%s status: %w", updated.Namespace, + updated.Name, err)) + } + } + + return retryable.NewMaybeRetryableAggregate(errs) +} diff --git a/internal/status/gatewayclass.go b/internal/status/gatewayclass.go new file mode 100644 index 00000000000..e1d937a0948 --- /dev/null +++ b/internal/status/gatewayclass.go @@ -0,0 +1,57 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package status + +import ( + "context" + "fmt" + + "github.com/projectcontour/contour/internal/equality" + retryable "github.com/projectcontour/contour/internal/retryableerror" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gateway_v1alpha1 "sigs.k8s.io/gateway-api/apis/v1alpha1" +) + +// SyncGatewayClass computes the current status of gc and updates status upon +// any changes since last sync. +func SyncGatewayClass(ctx context.Context, cli client.Client, gc *gateway_v1alpha1.GatewayClass, valid bool) error { + var errs []error + + latest := &gateway_v1alpha1.GatewayClass{} + key := types.NamespacedName{ + Namespace: gc.Namespace, + Name: gc.Name, + } + if err := cli.Get(ctx, key, latest); err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed to sync status for gateway class %s: %w", gc.Name, err) + } + + updated := latest.DeepCopy() + + updated.Status.Conditions = mergeConditions(updated.Status.Conditions, computeGatewayClassAdmittedCondition(valid)) + + if equality.GatewayClassStatusChanged(latest.Status, updated.Status) { + if err := cli.Status().Update(ctx, updated); err != nil { + errs = append(errs, fmt.Errorf("failed to update gatewayclass %s status: %w", latest.Name, err)) + } + } + + return retryable.NewMaybeRetryableAggregate(errs) +} diff --git a/internal/validation/gateway.go b/internal/validation/gateway.go new file mode 100644 index 00000000000..01c875f1906 --- /dev/null +++ b/internal/validation/gateway.go @@ -0,0 +1,115 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// TODO [danehans]: Refactor to use upstream validation pkg. + +package validation + +import ( + "context" + "fmt" + + retryable "github.com/projectcontour/contour/internal/retryableerror" + + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" + "sigs.k8s.io/controller-runtime/pkg/client" + gateway_v1alpha1 "sigs.k8s.io/gateway-api/apis/v1alpha1" +) + +// Gateway returns an error if gw is an invalid Gateway. +func Gateway(ctx context.Context, cli client.Client, gw *gateway_v1alpha1.Gateway) error { + var errs []error + gc := &gateway_v1alpha1.GatewayClass{} + if err := cli.Get(ctx, types.NamespacedName{Name: gw.Spec.GatewayClassName}, gc); err != nil { + if errors.IsNotFound(err) { + errs = append(errs, fmt.Errorf("gatewayclass %q doesn't exist", gw.Spec.GatewayClassName)) + } + errs = append(errs, fmt.Errorf("failed to get gatewayclass %q: %w", gw.Spec.GatewayClassName, err)) + } + // See if the referenced gatewayclass is admitted. + gcAdmitted := false + for _, c := range gc.Status.Conditions { + if c.Type == string(gateway_v1alpha1.ConditionRouteAdmitted) && c.Status == metav1.ConditionTrue { + gcAdmitted = true + } + } + if !gcAdmitted { + errs = append(errs, fmt.Errorf("referenced gatewayclass %q is not admitted", gw.Spec.GatewayClassName)) + } + if err := gatewayListeners(gw); err != nil { + errs = append(errs, fmt.Errorf("failed to validate listeners for gateway %s/%s: %w", gw.Namespace, + gw.Name, err)) + } + if err := gatewayAddresses(gw); err != nil { + errs = append(errs, fmt.Errorf("failed to validate addresses for gateway %s/%s: %w", gw.Namespace, + gw.Name, err)) + } + if len(errs) != 0 { + return retryable.NewMaybeRetryableAggregate(errs) + } + return nil +} + +// gatewayListeners returns an error if the listeners of the provided gw are invalid. +// TODO [danehans]: Refactor when more than 2 listeners are supported. +func gatewayListeners(gw *gateway_v1alpha1.Gateway) error { + listeners := gw.Spec.Listeners + numListeners := len(listeners) + if numListeners != 1 && numListeners != 2 { + return fmt.Errorf("%d is an invalid number of listeners", len(gw.Spec.Listeners)) + } + if numListeners == 2 { + if listeners[0].Port == listeners[1].Port { + return fmt.Errorf("invalid listeners, port %v is non-unique", listeners[0].Port) + } + } + for _, listener := range listeners { + if listener.Protocol != gateway_v1alpha1.HTTPProtocolType && listener.Protocol != gateway_v1alpha1.HTTPSProtocolType { + return fmt.Errorf("invalid listener protocol %s", listener.Protocol) + } + empty := gateway_v1alpha1.Hostname("") + wildcard := gateway_v1alpha1.Hostname("*") + if listener.Hostname != nil { + if listener.Hostname != &empty || listener.Hostname != &wildcard { + hostname := string(*listener.Hostname) + // According to the Gateway spec, a listener hostname cannot be an IP address. + if ip := validation.IsValidIP(hostname); ip == nil { + return fmt.Errorf("invalid listener hostname %s", hostname) + } + if parsed := validation.IsDNS1123Subdomain(hostname); parsed != nil { + return fmt.Errorf("invalid listener hostname %s", hostname) + } + } + } + } + return nil +} + +// gatewayAddresses returns an error if any gw addresses are invalid. +// TODO [danehans]: Refactor when named addresses are supported. +func gatewayAddresses(gw *gateway_v1alpha1.Gateway) error { + if len(gw.Spec.Addresses) > 0 { + for _, a := range gw.Spec.Addresses { + if a.Type == nil || *a.Type != gateway_v1alpha1.IPAddressType { + return fmt.Errorf("invalid address type %v", a.Type) + } + if ip := validation.IsValidIP(a.Value); ip != nil { + return fmt.Errorf("invalid address value %s", a.Value) + } + } + } + return nil +} diff --git a/internal/validation/gatewayclass.go b/internal/validation/gatewayclass.go new file mode 100644 index 00000000000..7173db13be5 --- /dev/null +++ b/internal/validation/gatewayclass.go @@ -0,0 +1,59 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// TODO [danehans]: Refactor to use upstream validation pkg. + +package validation + +import ( + "fmt" + + gateway_v1alpha1 "sigs.k8s.io/gateway-api/apis/v1alpha1" +) + +// GatewayClass returns nil if gc is a valid GatewayClass, +// otherwise an error. +func GatewayClass(gc *gateway_v1alpha1.GatewayClass) error { + return parameterRef(gc) +} + +// parameterRef returns nil if parametersRef of gc is valid, +// otherwise an error. +func parameterRef(gc *gateway_v1alpha1.GatewayClass) error { + // ParametersRef is optional. Default config should be used when nil. + if gc.Spec.ParametersRef == nil { + return nil + } + /*if gc.Spec.ParametersRef == nil { + return fmt.Errorf("invalid gatewayclass %s, missing parametersRef", gc.Name) + }*/ + + // Update the following when the Envoy CRD is added and xref'd by the gc. + /*if gc.Spec.ParametersRef.Scope == nil || *gc.Spec.ParametersRef.Scope != gatewayClassNamespacedParamRef { + return fmt.Errorf("invalid parametersRef for gatewayclass %s, only namespaced-scoped references are supported", gc.Name) + } + + group := gc.Spec.ParametersRef.Group + if group != operator_v1alpha1.GatewayClassParamsRefGroup { + return fmt.Errorf("invalid group %q", group) + } + kind := gc.Spec.ParametersRef.Kind + if kind != operator_v1alpha1.GatewayClassParamsRefKind { + return fmt.Errorf("invalid kind %q", kind) + } + if gc.Spec.ParametersRef.Namespace == nil { + return fmt.Errorf("invalid parametersRef for gatewayclass %s, missing namespace", gc.Name) + } + return nil*/ + return fmt.Errorf("invalid gatewayclass %q", gc.Name) +} diff --git a/pkg/config/parameters.go b/pkg/config/parameters.go index b412e6a1ce1..970390730c9 100644 --- a/pkg/config/parameters.go +++ b/pkg/config/parameters.go @@ -32,6 +32,7 @@ type ServerType string const ContourServerType ServerType = "contour" const EnvoyServerType ServerType = "envoy" +const ContourGatewayClass = "projectcontour.io/contour" // Validate the xDS server type. func (s ServerType) Validate() error {