istio.io/istio@v0.0.0-20240520182934-d79c90f27776/pilot/pkg/config/kube/ingress/conversion.go (about)

     1  // Copyright Istio Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package ingress
    16  
    17  import (
    18  	"errors"
    19  	"fmt"
    20  	"sort"
    21  	"strconv"
    22  	"strings"
    23  
    24  	"github.com/hashicorp/go-multierror"
    25  	corev1 "k8s.io/api/core/v1"
    26  	knetworking "k8s.io/api/networking/v1"
    27  
    28  	"istio.io/api/annotation"
    29  	meshconfig "istio.io/api/mesh/v1alpha1"
    30  	networking "istio.io/api/networking/v1alpha3"
    31  	"istio.io/istio/pkg/config"
    32  	"istio.io/istio/pkg/config/constants"
    33  	"istio.io/istio/pkg/config/labels"
    34  	"istio.io/istio/pkg/config/protocol"
    35  	"istio.io/istio/pkg/config/schema/gvk"
    36  	"istio.io/istio/pkg/kube/kclient"
    37  	"istio.io/istio/pkg/log"
    38  )
    39  
    40  const (
    41  	IstioIngressController = "istio.io/ingress-controller"
    42  )
    43  
    44  var errNotFound = errors.New("item not found")
    45  
    46  // EncodeIngressRuleName encodes an ingress rule name for a given ingress resource name,
    47  // as well as the position of the rule and path specified within it, counting from 1.
    48  // ruleNum == pathNum == 0 indicates the default backend specified for an ingress.
    49  func EncodeIngressRuleName(ingressName string, ruleNum, pathNum int) string {
    50  	return fmt.Sprintf("%s-%d-%d", ingressName, ruleNum, pathNum)
    51  }
    52  
    53  // decodeIngressRuleName decodes an ingress rule name previously encoded with EncodeIngressRuleName.
    54  func decodeIngressRuleName(name string) (ingressName string, ruleNum, pathNum int, err error) {
    55  	parts := strings.Split(name, "-")
    56  	if len(parts) < 3 {
    57  		err = fmt.Errorf("could not decode string into ingress rule name: %s", name)
    58  		return
    59  	}
    60  
    61  	ingressName = strings.Join(parts[0:len(parts)-2], "-")
    62  	ruleNum, ruleErr := strconv.Atoi(parts[len(parts)-2])
    63  	pathNum, pathErr := strconv.Atoi(parts[len(parts)-1])
    64  
    65  	if pathErr != nil || ruleErr != nil {
    66  		err = multierror.Append(
    67  			fmt.Errorf("could not decode string into ingress rule name: %s", name),
    68  			pathErr, ruleErr)
    69  		return
    70  	}
    71  
    72  	return
    73  }
    74  
    75  // ConvertIngressV1alpha3 converts from ingress spec to Istio Gateway
    76  func ConvertIngressV1alpha3(ingress knetworking.Ingress, mesh *meshconfig.MeshConfig, domainSuffix string) config.Config {
    77  	gateway := &networking.Gateway{}
    78  	gateway.Selector = getIngressGatewaySelector(mesh.IngressSelector, mesh.IngressService)
    79  
    80  	for i, tls := range ingress.Spec.TLS {
    81  		if tls.SecretName == "" {
    82  			log.Infof("invalid ingress rule %s:%s for hosts %q, no secretName defined", ingress.Namespace, ingress.Name, tls.Hosts)
    83  			continue
    84  		}
    85  		// TODO validation when multiple wildcard tls secrets are given
    86  		if len(tls.Hosts) == 0 {
    87  			tls.Hosts = []string{"*"}
    88  		}
    89  		gateway.Servers = append(gateway.Servers, &networking.Server{
    90  			Port: &networking.Port{
    91  				Number:   443,
    92  				Protocol: string(protocol.HTTPS),
    93  				Name:     fmt.Sprintf("https-443-ingress-%s-%s-%d", ingress.Name, ingress.Namespace, i),
    94  			},
    95  			Hosts: tls.Hosts,
    96  			Tls: &networking.ServerTLSSettings{
    97  				HttpsRedirect:  false,
    98  				Mode:           networking.ServerTLSSettings_SIMPLE,
    99  				CredentialName: tls.SecretName,
   100  			},
   101  		})
   102  	}
   103  
   104  	gateway.Servers = append(gateway.Servers, &networking.Server{
   105  		Port: &networking.Port{
   106  			Number:   80,
   107  			Protocol: string(protocol.HTTP),
   108  			Name:     fmt.Sprintf("http-80-ingress-%s-%s", ingress.Name, ingress.Namespace),
   109  		},
   110  		Hosts: []string{"*"},
   111  	})
   112  
   113  	gatewayConfig := config.Config{
   114  		Meta: config.Meta{
   115  			GroupVersionKind: gvk.Gateway,
   116  			Name:             ingress.Name + "-" + constants.IstioIngressGatewayName + "-" + ingress.Namespace,
   117  			Namespace:        IngressNamespace,
   118  			Domain:           domainSuffix,
   119  		},
   120  		Spec: gateway,
   121  	}
   122  
   123  	return gatewayConfig
   124  }
   125  
   126  // ConvertIngressVirtualService converts from ingress spec to Istio VirtualServices
   127  func ConvertIngressVirtualService(ingress knetworking.Ingress, domainSuffix string,
   128  	ingressByHost map[string]*config.Config, services kclient.Client[*corev1.Service],
   129  ) {
   130  	// Ingress allows a single host - if missing '*' is assumed
   131  	// We need to merge all rules with a particular host across
   132  	// all ingresses, and return a separate VirtualService for each
   133  	// host.
   134  	for _, rule := range ingress.Spec.Rules {
   135  		if rule.HTTP == nil {
   136  			log.Infof("invalid ingress rule %s:%s for host %q, no paths defined", ingress.Namespace, ingress.Name, rule.Host)
   137  			continue
   138  		}
   139  
   140  		host := rule.Host
   141  		namePrefix := strings.Replace(host, ".", "-", -1)
   142  		if host == "" {
   143  			host = "*"
   144  		}
   145  		virtualService := &networking.VirtualService{
   146  			Hosts:    []string{host},
   147  			Gateways: []string{fmt.Sprintf("%s/%s-%s-%s", IngressNamespace, ingress.Name, constants.IstioIngressGatewayName, ingress.Namespace)},
   148  		}
   149  
   150  		httpRoutes := make([]*networking.HTTPRoute, 0, len(rule.HTTP.Paths))
   151  		for _, httpPath := range rule.HTTP.Paths {
   152  			httpMatch := &networking.HTTPMatchRequest{}
   153  			if httpPath.PathType != nil {
   154  				switch *httpPath.PathType {
   155  				case knetworking.PathTypeExact:
   156  					httpMatch.Uri = &networking.StringMatch{
   157  						MatchType: &networking.StringMatch_Exact{Exact: httpPath.Path},
   158  					}
   159  				case knetworking.PathTypePrefix:
   160  					// Optimize common case of / to not needed regex
   161  					httpMatch.Uri = &networking.StringMatch{
   162  						MatchType: &networking.StringMatch_Prefix{Prefix: httpPath.Path},
   163  					}
   164  				default:
   165  					// Fallback to the legacy string matching
   166  					// If the httpPath.Path is a wildcard path, Uri will be nil
   167  					httpMatch.Uri = createFallbackStringMatch(httpPath.Path)
   168  				}
   169  			} else {
   170  				httpMatch.Uri = createFallbackStringMatch(httpPath.Path)
   171  			}
   172  
   173  			httpRoute := ingressBackendToHTTPRoute(&httpPath.Backend, ingress.Namespace, domainSuffix, services)
   174  			if httpRoute == nil {
   175  				log.Infof("invalid ingress rule %s:%s for host %q, no backend defined for path", ingress.Namespace, ingress.Name, rule.Host)
   176  				continue
   177  			}
   178  			// Only create a match if Uri is not nil. HttpMatchRequest cannot be empty
   179  			if httpMatch.Uri != nil {
   180  				httpRoute.Match = []*networking.HTTPMatchRequest{httpMatch}
   181  			}
   182  			httpRoutes = append(httpRoutes, httpRoute)
   183  		}
   184  
   185  		virtualService.Http = httpRoutes
   186  
   187  		virtualServiceConfig := config.Config{
   188  			Meta: config.Meta{
   189  				GroupVersionKind: gvk.VirtualService,
   190  				Name:             namePrefix + "-" + ingress.Name + "-" + constants.IstioIngressGatewayName,
   191  				Namespace:        ingress.Namespace,
   192  				Domain:           domainSuffix,
   193  				Annotations:      map[string]string{constants.InternalRouteSemantics: constants.RouteSemanticsIngress},
   194  			},
   195  			Spec: virtualService,
   196  		}
   197  
   198  		old, f := ingressByHost[host]
   199  		if f {
   200  			vs := old.Spec.(*networking.VirtualService)
   201  			vs.Http = append(vs.Http, httpRoutes...)
   202  		} else {
   203  			ingressByHost[host] = &virtualServiceConfig
   204  		}
   205  
   206  		// sort routes to meet ingress route precedence requirements
   207  		// see https://kubernetes.io/docs/concepts/services-networking/ingress/#multiple-matches
   208  		vs := ingressByHost[host].Spec.(*networking.VirtualService)
   209  		sort.SliceStable(vs.Http, func(i, j int) bool {
   210  			var r1Len, r2Len int
   211  			var r1Ex, r2Ex bool
   212  			if vs.Http[i].Match != nil || len(vs.Http[i].Match) != 0 {
   213  				r1Len, r1Ex = getMatchURILength(vs.Http[i].Match[0])
   214  			}
   215  			if vs.Http[j].Match != nil || len(vs.Http[j].Match) != 0 {
   216  				r2Len, r2Ex = getMatchURILength(vs.Http[j].Match[0])
   217  			}
   218  			// TODO: default at the end
   219  			if r1Len == r2Len {
   220  				return r1Ex && !r2Ex
   221  			}
   222  			return r1Len > r2Len
   223  		})
   224  	}
   225  
   226  	// Matches * and "/". Currently not supported - would conflict
   227  	// with any other explicit VirtualService.
   228  	if ingress.Spec.DefaultBackend != nil {
   229  		log.Infof("Ignore default wildcard ingress, use VirtualService %s:%s",
   230  			ingress.Namespace, ingress.Name)
   231  	}
   232  }
   233  
   234  // getMatchURILength returns the length of matching path, and whether the match type is EXACT
   235  func getMatchURILength(match *networking.HTTPMatchRequest) (length int, exact bool) {
   236  	uri := match.GetUri()
   237  	switch uri.GetMatchType().(type) {
   238  	case *networking.StringMatch_Exact:
   239  		return len(uri.GetExact()), true
   240  	case *networking.StringMatch_Prefix:
   241  		return len(uri.GetPrefix()), false
   242  	}
   243  	// should not happen
   244  	return -1, false
   245  }
   246  
   247  func ingressBackendToHTTPRoute(backend *knetworking.IngressBackend, namespace string,
   248  	domainSuffix string, services kclient.Client[*corev1.Service],
   249  ) *networking.HTTPRoute {
   250  	if backend == nil {
   251  		return nil
   252  	}
   253  
   254  	port := &networking.PortSelector{}
   255  
   256  	if backend.Service == nil {
   257  		log.Infof("backend service must be specified")
   258  		return nil
   259  	}
   260  	if backend.Service.Port.Number > 0 {
   261  		port.Number = uint32(backend.Service.Port.Number)
   262  	} else {
   263  		resolvedPort, err := resolveNamedPort(backend, namespace, services)
   264  		if err != nil {
   265  			log.Infof("failed to resolve named port %s, error: %v", backend.Service.Port.Name, err)
   266  			return nil
   267  		}
   268  		port.Number = uint32(resolvedPort)
   269  	}
   270  
   271  	return &networking.HTTPRoute{
   272  		Route: []*networking.HTTPRouteDestination{
   273  			{
   274  				Destination: &networking.Destination{
   275  					Host: fmt.Sprintf("%s.%s.svc.%s", backend.Service.Name, namespace, domainSuffix),
   276  					Port: port,
   277  				},
   278  				Weight: 100,
   279  			},
   280  		},
   281  	}
   282  }
   283  
   284  func resolveNamedPort(backend *knetworking.IngressBackend, namespace string, services kclient.Client[*corev1.Service]) (int32, error) {
   285  	svc := services.Get(backend.Service.Name, namespace)
   286  	if svc == nil {
   287  		return 0, errNotFound
   288  	}
   289  	for _, port := range svc.Spec.Ports {
   290  		if port.Name == backend.Service.Port.Name {
   291  			return port.Port, nil
   292  		}
   293  	}
   294  	return 0, errNotFound
   295  }
   296  
   297  // shouldProcessIngress determines whether the given knetworking resource should be processed
   298  // by the controller, based on its knetworking class annotation or, in more recent versions of
   299  // kubernetes (v1.18+), based on the Ingress's specified IngressClass
   300  // See https://kubernetes.io/docs/concepts/services-networking/ingress/#ingress-class
   301  func shouldProcessIngressWithClass(mesh *meshconfig.MeshConfig, ingress *knetworking.Ingress, ingressClass *knetworking.IngressClass) bool {
   302  	if class, exists := ingress.Annotations[annotation.IoKubernetesIngressClass.Name]; exists {
   303  		switch mesh.IngressControllerMode {
   304  		case meshconfig.MeshConfig_OFF:
   305  			return false
   306  		case meshconfig.MeshConfig_STRICT:
   307  			return class == mesh.IngressClass
   308  		case meshconfig.MeshConfig_DEFAULT:
   309  			return class == mesh.IngressClass
   310  		default:
   311  			log.Warnf("invalid ingress synchronization mode: %v", mesh.IngressControllerMode)
   312  			return false
   313  		}
   314  	} else if ingressClass != nil {
   315  		return ingressClass.Spec.Controller == IstioIngressController
   316  	} else {
   317  		switch mesh.IngressControllerMode {
   318  		case meshconfig.MeshConfig_OFF:
   319  			return false
   320  		case meshconfig.MeshConfig_STRICT:
   321  			return false
   322  		case meshconfig.MeshConfig_DEFAULT:
   323  			return true
   324  		default:
   325  			log.Warnf("invalid ingress synchronization mode: %v", mesh.IngressControllerMode)
   326  			return false
   327  		}
   328  	}
   329  }
   330  
   331  func createFallbackStringMatch(s string) *networking.StringMatch {
   332  	// If the string is empty or a wildcard, return nil
   333  	if s == "" || s == "*" || s == "/*" || s == ".*" {
   334  		return nil
   335  	}
   336  
   337  	// Note that this implementation only converts prefix and exact matches, not regexps.
   338  
   339  	// Replace e.g. "foo.*" with prefix match
   340  	if strings.HasSuffix(s, ".*") {
   341  		return &networking.StringMatch{
   342  			MatchType: &networking.StringMatch_Prefix{Prefix: strings.TrimSuffix(s, ".*")},
   343  		}
   344  	}
   345  	if strings.HasSuffix(s, "/*") {
   346  		return &networking.StringMatch{
   347  			MatchType: &networking.StringMatch_Prefix{Prefix: strings.TrimSuffix(s, "/*")},
   348  		}
   349  	}
   350  
   351  	// Replace e.g. "foo" with a exact match
   352  	return &networking.StringMatch{
   353  		MatchType: &networking.StringMatch_Exact{Exact: s},
   354  	}
   355  }
   356  
   357  func getIngressGatewaySelector(ingressSelector, ingressService string) map[string]string {
   358  	// Setup the selector for the gateway
   359  	if ingressSelector != "" {
   360  		// If explicitly defined, use this one
   361  		return labels.Instance{constants.IstioLabel: ingressSelector}
   362  	} else if ingressService != "istio-ingressgateway" && ingressService != "" {
   363  		// Otherwise, we will use the ingress service as the default. It is common for the selector and service
   364  		// to be the same, so this removes the need for two configurations
   365  		// However, if its istio-ingressgateway we need to use the old values for backwards compatibility
   366  		return labels.Instance{constants.IstioLabel: ingressService}
   367  	}
   368  	// If we have neither an explicitly defined ingressSelector or ingressService then use a selector
   369  	// pointing to the ingressgateway from the default installation
   370  	return labels.Instance{constants.IstioLabel: constants.IstioIngressLabelValue}
   371  }