istio.io/istio@v0.0.0-20240520182934-d79c90f27776/pilot/pkg/config/kube/gateway/deploymentcontroller_test.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 gateway
    16  
    17  import (
    18  	"bytes"
    19  	"fmt"
    20  	"path/filepath"
    21  	"testing"
    22  	"time"
    23  
    24  	"go.uber.org/atomic"
    25  	corev1 "k8s.io/api/core/v1"
    26  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    27  	"k8s.io/apimachinery/pkg/runtime"
    28  	"k8s.io/apimachinery/pkg/runtime/schema"
    29  	kubeVersion "k8s.io/apimachinery/pkg/version"
    30  	fakediscovery "k8s.io/client-go/discovery/fake"
    31  	k8s "sigs.k8s.io/gateway-api/apis/v1"
    32  	k8sbeta "sigs.k8s.io/gateway-api/apis/v1beta1"
    33  	"sigs.k8s.io/yaml"
    34  
    35  	istioio_networking_v1beta1 "istio.io/api/networking/v1beta1"
    36  	istio_type_v1beta1 "istio.io/api/type/v1beta1"
    37  	"istio.io/istio/pilot/pkg/features"
    38  	"istio.io/istio/pilot/pkg/model"
    39  	"istio.io/istio/pilot/test/util"
    40  	"istio.io/istio/pkg/cluster"
    41  	"istio.io/istio/pkg/config"
    42  	"istio.io/istio/pkg/config/constants"
    43  	"istio.io/istio/pkg/config/mesh"
    44  	"istio.io/istio/pkg/config/schema/gvk"
    45  	"istio.io/istio/pkg/config/schema/gvr"
    46  	"istio.io/istio/pkg/kube"
    47  	"istio.io/istio/pkg/kube/controllers"
    48  	"istio.io/istio/pkg/kube/inject"
    49  	"istio.io/istio/pkg/kube/kclient"
    50  	"istio.io/istio/pkg/kube/kclient/clienttest"
    51  	"istio.io/istio/pkg/kube/kubetypes"
    52  	istiolog "istio.io/istio/pkg/log"
    53  	"istio.io/istio/pkg/revisions"
    54  	"istio.io/istio/pkg/test"
    55  	"istio.io/istio/pkg/test/env"
    56  	"istio.io/istio/pkg/test/util/assert"
    57  	"istio.io/istio/pkg/test/util/file"
    58  	"istio.io/istio/pkg/test/util/retry"
    59  )
    60  
    61  func TestConfigureIstioGateway(t *testing.T) {
    62  	discoveryNamespacesFilter := buildFilter("default")
    63  	defaultNamespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}
    64  	customClass := &k8sbeta.GatewayClass{
    65  		ObjectMeta: metav1.ObjectMeta{
    66  			Name: "custom",
    67  		},
    68  		Spec: k8s.GatewayClassSpec{
    69  			ControllerName: k8s.GatewayController(features.ManagedGatewayController),
    70  		},
    71  	}
    72  	defaultObjects := []runtime.Object{defaultNamespace}
    73  	store := model.NewFakeStore()
    74  	if _, err := store.Create(config.Config{
    75  		Meta: config.Meta{
    76  			GroupVersionKind: gvk.ProxyConfig,
    77  			Name:             "test",
    78  			Namespace:        "default",
    79  		},
    80  		Spec: &istioio_networking_v1beta1.ProxyConfig{
    81  			Selector: &istio_type_v1beta1.WorkloadSelector{
    82  				MatchLabels: map[string]string{
    83  					"gateway.networking.k8s.io/gateway-name": "default",
    84  				},
    85  			},
    86  			Image: &istioio_networking_v1beta1.ProxyImage{
    87  				ImageType: "distroless",
    88  			},
    89  		},
    90  	}); err != nil {
    91  		t.Fatalf("failed to create ProxyConfigs: %s", err)
    92  	}
    93  	proxyConfig := model.GetProxyConfigs(store, mesh.DefaultMeshConfig())
    94  	tests := []struct {
    95  		name                     string
    96  		gw                       k8sbeta.Gateway
    97  		objects                  []runtime.Object
    98  		pcs                      *model.ProxyConfigs
    99  		values                   string
   100  		discoveryNamespaceFilter kubetypes.DynamicObjectFilter
   101  		ignore                   bool
   102  	}{
   103  		{
   104  			name: "simple",
   105  			gw: k8sbeta.Gateway{
   106  				ObjectMeta: metav1.ObjectMeta{
   107  					Name:        "default",
   108  					Namespace:   "default",
   109  					Labels:      map[string]string{"should": "see"},
   110  					Annotations: map[string]string{"should": "see"},
   111  				},
   112  				Spec: k8s.GatewaySpec{
   113  					GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
   114  				},
   115  			},
   116  			objects:                  defaultObjects,
   117  			discoveryNamespaceFilter: discoveryNamespacesFilter,
   118  		},
   119  		{
   120  			name: "simple",
   121  			gw: k8sbeta.Gateway{
   122  				ObjectMeta: metav1.ObjectMeta{
   123  					Name:      "default",
   124  					Namespace: "default",
   125  				},
   126  				Spec: k8s.GatewaySpec{
   127  					GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
   128  				},
   129  			},
   130  			objects:                  defaultObjects,
   131  			discoveryNamespaceFilter: buildFilter("not-default"),
   132  			ignore:                   true,
   133  		},
   134  		{
   135  			name: "manual-sa",
   136  			gw: k8sbeta.Gateway{
   137  				ObjectMeta: metav1.ObjectMeta{
   138  					Name:        "default",
   139  					Namespace:   "default",
   140  					Annotations: map[string]string{gatewaySAOverride: "custom-sa"},
   141  				},
   142  				Spec: k8s.GatewaySpec{
   143  					GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
   144  				},
   145  			},
   146  			objects:                  defaultObjects,
   147  			discoveryNamespaceFilter: discoveryNamespacesFilter,
   148  		},
   149  		{
   150  			name: "manual-ip",
   151  			gw: k8sbeta.Gateway{
   152  				ObjectMeta: metav1.ObjectMeta{
   153  					Name:        "default",
   154  					Namespace:   "default",
   155  					Annotations: map[string]string{gatewayNameOverride: "default"},
   156  				},
   157  				Spec: k8s.GatewaySpec{
   158  					GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
   159  					Addresses: []k8s.GatewayAddress{{
   160  						Type:  func() *k8s.AddressType { x := k8s.IPAddressType; return &x }(),
   161  						Value: "1.2.3.4",
   162  					}},
   163  				},
   164  			},
   165  			objects:                  defaultObjects,
   166  			discoveryNamespaceFilter: discoveryNamespacesFilter,
   167  		},
   168  		{
   169  			name: "cluster-ip",
   170  			gw: k8sbeta.Gateway{
   171  				ObjectMeta: metav1.ObjectMeta{
   172  					Name:      "default",
   173  					Namespace: "default",
   174  					Annotations: map[string]string{
   175  						"networking.istio.io/service-type": string(corev1.ServiceTypeClusterIP),
   176  						gatewayNameOverride:                "default",
   177  					},
   178  				},
   179  				Spec: k8s.GatewaySpec{
   180  					GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
   181  					Listeners: []k8s.Listener{{
   182  						Name:     "http",
   183  						Port:     k8s.PortNumber(80),
   184  						Protocol: k8s.HTTPProtocolType,
   185  					}},
   186  				},
   187  			},
   188  			objects:                  defaultObjects,
   189  			discoveryNamespaceFilter: discoveryNamespacesFilter,
   190  		},
   191  		{
   192  			name: "multinetwork",
   193  			gw: k8sbeta.Gateway{
   194  				ObjectMeta: metav1.ObjectMeta{
   195  					Name:        "default",
   196  					Namespace:   "default",
   197  					Labels:      map[string]string{"topology.istio.io/network": "network-1"},
   198  					Annotations: map[string]string{gatewayNameOverride: "default"},
   199  				},
   200  				Spec: k8s.GatewaySpec{
   201  					GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
   202  					Listeners: []k8s.Listener{{
   203  						Name:     "http",
   204  						Port:     k8s.PortNumber(80),
   205  						Protocol: k8s.HTTPProtocolType,
   206  					}},
   207  				},
   208  			},
   209  			objects:                  defaultObjects,
   210  			discoveryNamespaceFilter: discoveryNamespacesFilter,
   211  		},
   212  		{
   213  			name: "waypoint",
   214  			gw: k8sbeta.Gateway{
   215  				ObjectMeta: metav1.ObjectMeta{
   216  					Name:      "namespace",
   217  					Namespace: "default",
   218  					Labels: map[string]string{
   219  						"topology.istio.io/network": "network-1", // explicitly set network won't be overwritten
   220  					},
   221  				},
   222  				Spec: k8s.GatewaySpec{
   223  					GatewayClassName: constants.WaypointGatewayClassName,
   224  					Listeners: []k8s.Listener{{
   225  						Name:     "mesh",
   226  						Port:     k8s.PortNumber(15008),
   227  						Protocol: "ALL",
   228  					}},
   229  				},
   230  			},
   231  			objects: defaultObjects,
   232  			values: `global:
   233    hub: test
   234    tag: test
   235    network: network-2`,
   236  		},
   237  		{
   238  			name: "waypoint-no-network-label",
   239  			gw: k8sbeta.Gateway{
   240  				ObjectMeta: metav1.ObjectMeta{
   241  					Name:      "namespace",
   242  					Namespace: "default",
   243  				},
   244  				Spec: k8s.GatewaySpec{
   245  					GatewayClassName: constants.WaypointGatewayClassName,
   246  					Listeners: []k8s.Listener{{
   247  						Name:     "mesh",
   248  						Port:     k8s.PortNumber(15008),
   249  						Protocol: "ALL",
   250  					}},
   251  				},
   252  			},
   253  			objects: defaultObjects,
   254  			values: `global:
   255    hub: test
   256    tag: test
   257    network: network-1`,
   258  		},
   259  		{
   260  			name: "proxy-config-crd",
   261  			gw: k8sbeta.Gateway{
   262  				ObjectMeta: metav1.ObjectMeta{
   263  					Name:      "default",
   264  					Namespace: "default",
   265  				},
   266  				Spec: k8s.GatewaySpec{
   267  					GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
   268  				},
   269  			},
   270  			objects: defaultObjects,
   271  			pcs:     proxyConfig,
   272  		},
   273  		{
   274  			name: "custom-class",
   275  			gw: k8sbeta.Gateway{
   276  				ObjectMeta: metav1.ObjectMeta{
   277  					Name:      "default",
   278  					Namespace: "default",
   279  				},
   280  				Spec: k8s.GatewaySpec{
   281  					GatewayClassName: k8s.ObjectName(customClass.Name),
   282  				},
   283  			},
   284  			objects: defaultObjects,
   285  		},
   286  		{
   287  			name: "infrastructure-labels-annotations",
   288  			gw: k8sbeta.Gateway{
   289  				ObjectMeta: metav1.ObjectMeta{
   290  					Name:        "default",
   291  					Namespace:   "default",
   292  					Labels:      map[string]string{"should-not": "see"},
   293  					Annotations: map[string]string{"should-not": "see"},
   294  				},
   295  				Spec: k8s.GatewaySpec{
   296  					GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
   297  					Infrastructure: &k8s.GatewayInfrastructure{
   298  						Labels:      map[k8s.AnnotationKey]k8s.AnnotationValue{"foo": "bar", "gateway.networking.k8s.io/ignore": "true"},
   299  						Annotations: map[k8s.AnnotationKey]k8s.AnnotationValue{"fizz": "buzz", "gateway.networking.k8s.io/ignore": "true"},
   300  					},
   301  				},
   302  			},
   303  			objects: defaultObjects,
   304  		},
   305  		{
   306  			name: "kube-gateway-ambient-redirect",
   307  			gw: k8sbeta.Gateway{
   308  				ObjectMeta: metav1.ObjectMeta{
   309  					Name:      "default",
   310  					Namespace: "default",
   311  					// TODO why are we setting this on gateways?
   312  					Labels: map[string]string{
   313  						constants.DataplaneModeLabel: constants.DataplaneModeAmbient,
   314  					},
   315  				},
   316  				Spec: k8s.GatewaySpec{
   317  					GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
   318  				},
   319  			},
   320  			objects: defaultObjects,
   321  		},
   322  		{
   323  			name: "kube-gateway-ambient-redirect-infra",
   324  			gw: k8sbeta.Gateway{
   325  				ObjectMeta: metav1.ObjectMeta{
   326  					Name:      "default",
   327  					Namespace: "default",
   328  				},
   329  				Spec: k8s.GatewaySpec{
   330  					GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
   331  					Infrastructure: &k8s.GatewayInfrastructure{
   332  						// TODO why are we setting this on gateways?
   333  						Labels: map[k8s.AnnotationKey]k8s.AnnotationValue{
   334  							constants.DataplaneModeLabel: constants.DataplaneModeAmbient,
   335  						},
   336  					},
   337  				},
   338  			},
   339  			objects: defaultObjects,
   340  		},
   341  	}
   342  	for _, tt := range tests {
   343  		t.Run(tt.name, func(t *testing.T) {
   344  			buf := &bytes.Buffer{}
   345  			client := kube.NewFakeClient(tt.objects...)
   346  			kube.SetObjectFilter(client, tt.discoveryNamespaceFilter)
   347  			client.Kube().Discovery().(*fakediscovery.FakeDiscovery).FakedServerVersion = &kubeVersion.Info{Major: "1", Minor: "28"}
   348  			kclient.NewWriteClient[*k8sbeta.GatewayClass](client).Create(customClass)
   349  			kclient.NewWriteClient[*k8sbeta.Gateway](client).Create(tt.gw.DeepCopy())
   350  			stop := test.NewStop(t)
   351  			env := model.NewEnvironment()
   352  			env.PushContext().ProxyConfigs = tt.pcs
   353  			tw := revisions.NewTagWatcher(client, "")
   354  			go tw.Run(stop)
   355  			d := NewDeploymentController(
   356  				client, cluster.ID(features.ClusterName), env, testInjectionConfig(t, tt.values), func(fn func()) {
   357  				}, tw, "")
   358  			d.patcher = func(gvr schema.GroupVersionResource, name string, namespace string, data []byte, subresources ...string) error {
   359  				b, err := yaml.JSONToYAML(data)
   360  				if err != nil {
   361  					return err
   362  				}
   363  				buf.Write(b)
   364  				buf.WriteString("---\n")
   365  				return nil
   366  			}
   367  			client.RunAndWait(stop)
   368  			go d.Run(stop)
   369  			kube.WaitForCacheSync("test", stop, d.queue.HasSynced)
   370  
   371  			if tt.ignore {
   372  				assert.Equal(t, buf.String(), "")
   373  			} else {
   374  				resp := timestampRegex.ReplaceAll(buf.Bytes(), []byte("lastTransitionTime: fake"))
   375  				util.CompareContent(t, resp, filepath.Join("testdata", "deployment", tt.name+".yaml"))
   376  			}
   377  			// ensure we didn't mutate the object
   378  			if !tt.ignore {
   379  				assert.Equal(t, d.gateways.Get(tt.gw.Name, tt.gw.Namespace), &tt.gw)
   380  			}
   381  		})
   382  	}
   383  }
   384  
   385  func buildFilter(allowedNamespace string) kubetypes.DynamicObjectFilter {
   386  	return kubetypes.NewStaticObjectFilter(func(obj any) bool {
   387  		if ns, ok := obj.(string); ok {
   388  			return ns == allowedNamespace
   389  		}
   390  		object := controllers.ExtractObject(obj)
   391  		if object == nil {
   392  			return false
   393  		}
   394  		ns := object.GetNamespace()
   395  		if _, ok := object.(*corev1.Namespace); ok {
   396  			ns = object.GetName()
   397  		}
   398  		return ns == allowedNamespace
   399  	})
   400  }
   401  
   402  func TestVersionManagement(t *testing.T) {
   403  	log.SetOutputLevel(istiolog.DebugLevel)
   404  	writes := make(chan string, 10)
   405  	c := kube.NewFakeClient(&corev1.Namespace{
   406  		ObjectMeta: metav1.ObjectMeta{
   407  			Name: "default",
   408  		},
   409  	})
   410  	tw := revisions.NewTagWatcher(c, "default")
   411  	env := &model.Environment{}
   412  	d := NewDeploymentController(c, "", env, testInjectionConfig(t, ""), func(fn func()) {}, tw, "")
   413  	reconciles := atomic.NewInt32(0)
   414  	wantReconcile := int32(0)
   415  	expectReconciled := func() {
   416  		t.Helper()
   417  		wantReconcile++
   418  		assert.EventuallyEqual(t, reconciles.Load, wantReconcile, retry.Timeout(time.Second*5), retry.Message("no reconciliation"))
   419  	}
   420  
   421  	d.patcher = func(g schema.GroupVersionResource, name string, namespace string, data []byte, subresources ...string) error {
   422  		if g == gvr.Service {
   423  			reconciles.Inc()
   424  		}
   425  		if g == gvr.KubernetesGateway {
   426  			b, err := yaml.JSONToYAML(data)
   427  			if err != nil {
   428  				return err
   429  			}
   430  			writes <- string(b)
   431  		}
   432  		return nil
   433  	}
   434  	stop := test.NewStop(t)
   435  	gws := clienttest.Wrap(t, d.gateways)
   436  	go tw.Run(stop)
   437  	go d.Run(stop)
   438  	c.RunAndWait(stop)
   439  	kube.WaitForCacheSync("test", stop, d.queue.HasSynced)
   440  	// Create a gateway, we should mark our ownership
   441  	defaultGateway := &k8sbeta.Gateway{
   442  		ObjectMeta: metav1.ObjectMeta{
   443  			Name:      "gw",
   444  			Namespace: "default",
   445  		},
   446  		Spec: k8s.GatewaySpec{
   447  			GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
   448  		},
   449  	}
   450  	gws.Create(defaultGateway)
   451  	assert.Equal(t, assert.ChannelHasItem(t, writes), buildPatch(ControllerVersion))
   452  	expectReconciled()
   453  	assert.ChannelIsEmpty(t, writes)
   454  	// Test fake doesn't actual do Apply, so manually do this
   455  	defaultGateway.Annotations = map[string]string{ControllerVersionAnnotation: fmt.Sprint(ControllerVersion)}
   456  	gws.Update(defaultGateway)
   457  	expectReconciled()
   458  	// We shouldn't write in response to our write.
   459  	assert.ChannelIsEmpty(t, writes)
   460  
   461  	defaultGateway.Annotations["foo"] = "bar"
   462  	gws.Update(defaultGateway)
   463  	expectReconciled()
   464  	// We should not be updating the version, its already set. Setting it introduces a possible race condition
   465  	// since we use SSA so there is no conflict checks.
   466  	assert.ChannelIsEmpty(t, writes)
   467  
   468  	// Somehow the annotation is removed - it should be added back
   469  	defaultGateway.Annotations = map[string]string{}
   470  	gws.Update(defaultGateway)
   471  	expectReconciled()
   472  	assert.Equal(t, assert.ChannelHasItem(t, writes), buildPatch(ControllerVersion))
   473  	assert.ChannelIsEmpty(t, writes)
   474  	// Test fake doesn't actual do Apply, so manually do this
   475  	defaultGateway.Annotations = map[string]string{ControllerVersionAnnotation: fmt.Sprint(ControllerVersion)}
   476  	gws.Update(defaultGateway)
   477  	expectReconciled()
   478  	// We shouldn't write in response to our write.
   479  	assert.ChannelIsEmpty(t, writes)
   480  
   481  	// Somehow the annotation is set to an older version - it should be added back
   482  	defaultGateway.Annotations = map[string]string{ControllerVersionAnnotation: fmt.Sprint(1)}
   483  	gws.Update(defaultGateway)
   484  	expectReconciled()
   485  	assert.Equal(t, assert.ChannelHasItem(t, writes), buildPatch(ControllerVersion))
   486  	assert.ChannelIsEmpty(t, writes)
   487  	// Test fake doesn't actual do Apply, so manually do this
   488  	defaultGateway.Annotations = map[string]string{ControllerVersionAnnotation: fmt.Sprint(ControllerVersion)}
   489  	gws.Update(defaultGateway)
   490  	expectReconciled()
   491  	// We shouldn't write in response to our write.
   492  	assert.ChannelIsEmpty(t, writes)
   493  
   494  	// Somehow the annotation is set to an new version - we should do nothing
   495  	defaultGateway.Annotations = map[string]string{ControllerVersionAnnotation: fmt.Sprint(10)}
   496  	gws.Update(defaultGateway)
   497  	assert.ChannelIsEmpty(t, writes)
   498  	// Do not expect reconcile
   499  	assert.Equal(t, reconciles.Load(), wantReconcile)
   500  }
   501  
   502  func testInjectionConfig(t test.Failer, values string) func() inject.WebhookConfig {
   503  	var vc inject.ValuesConfig
   504  	var err error
   505  	if values != "" {
   506  		vc, err = inject.NewValuesConfig(values)
   507  		if err != nil {
   508  			t.Fatal(err)
   509  		}
   510  	} else {
   511  		vc, err = inject.NewValuesConfig(`
   512  global:
   513    hub: test
   514    tag: test`)
   515  		if err != nil {
   516  			t.Fatal(err)
   517  		}
   518  
   519  	}
   520  	tmpl, err := inject.ParseTemplates(map[string]string{
   521  		"kube-gateway": file.AsStringOrFail(t, filepath.Join(env.IstioSrc, "manifests/charts/istio-control/istio-discovery/files/kube-gateway.yaml")),
   522  		"waypoint":     file.AsStringOrFail(t, filepath.Join(env.IstioSrc, "manifests/charts/istio-control/istio-discovery/files/waypoint.yaml")),
   523  	})
   524  	if err != nil {
   525  		t.Fatal(err)
   526  	}
   527  	injConfig := func() inject.WebhookConfig {
   528  		return inject.WebhookConfig{
   529  			Templates:  tmpl,
   530  			Values:     vc,
   531  			MeshConfig: mesh.DefaultMeshConfig(),
   532  		}
   533  	}
   534  	return injConfig
   535  }
   536  
   537  func buildPatch(version int) string {
   538  	return fmt.Sprintf(`apiVersion: gateway.networking.k8s.io/v1beta1
   539  kind: Gateway
   540  metadata:
   541    annotations:
   542      gateway.istio.io/controller-version: "%d"
   543  `, version)
   544  }