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 }