github.com/cilium/cilium@v1.16.2/test/controlplane/suite/testcase.go (about) 1 // SPDX-License-Identifier: Apache-2.0 2 // Copyright Authors of Cilium 3 4 package suite 5 6 import ( 7 "fmt" 8 "os" 9 "strings" 10 "testing" 11 "time" 12 13 "github.com/cilium/hive/cell" 14 "github.com/cilium/hive/hivetest" 15 "github.com/cilium/statedb" 16 "github.com/spf13/cobra" 17 "github.com/spf13/viper" 18 corev1 "k8s.io/api/core/v1" 19 discov1 "k8s.io/api/discovery/v1" 20 discov1beta1 "k8s.io/api/discovery/v1beta1" 21 "k8s.io/apimachinery/pkg/api/meta" 22 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 24 "k8s.io/apimachinery/pkg/fields" 25 k8sRuntime "k8s.io/apimachinery/pkg/runtime" 26 "k8s.io/apimachinery/pkg/runtime/schema" 27 versionapi "k8s.io/apimachinery/pkg/version" 28 "k8s.io/apimachinery/pkg/watch" 29 fakediscovery "k8s.io/client-go/discovery/fake" 30 k8sTesting "k8s.io/client-go/testing" 31 32 agentCmd "github.com/cilium/cilium/daemon/cmd" 33 operatorCmd "github.com/cilium/cilium/operator/cmd" 34 operatorOption "github.com/cilium/cilium/operator/option" 35 fakeTypes "github.com/cilium/cilium/pkg/datapath/fake/types" 36 datapathTables "github.com/cilium/cilium/pkg/datapath/tables" 37 "github.com/cilium/cilium/pkg/k8s/apis" 38 cilium_v2 "github.com/cilium/cilium/pkg/k8s/apis/cilium.io/v2" 39 k8sClient "github.com/cilium/cilium/pkg/k8s/client" 40 slim_corev1 "github.com/cilium/cilium/pkg/k8s/slim/k8s/api/core/v1" 41 "github.com/cilium/cilium/pkg/k8s/version" 42 "github.com/cilium/cilium/pkg/lock" 43 "github.com/cilium/cilium/pkg/node/types" 44 agentOption "github.com/cilium/cilium/pkg/option" 45 ) 46 47 type trackerAndDecoder struct { 48 tracker k8sTesting.ObjectTracker 49 decoder k8sRuntime.Decoder 50 } 51 52 type ControlPlaneTest struct { 53 t *testing.T 54 tempDir string 55 validationTimeout time.Duration 56 57 nodeName string 58 clients *k8sClient.FakeClientset 59 trackers []trackerAndDecoder 60 agentHandle *agentHandle 61 operatorHandle *operatorHandle 62 Datapath *fakeTypes.FakeDatapath 63 establishedWatchers *lock.Map[string, struct{}] 64 } 65 66 func (cpt *ControlPlaneTest) AgentDB() (*statedb.DB, statedb.Table[datapathTables.NodeAddress]) { 67 return cpt.agentHandle.db, cpt.agentHandle.nodeAddrs 68 } 69 70 func NewControlPlaneTest(t *testing.T, nodeName string, k8sVersion string) *ControlPlaneTest { 71 clients, _ := k8sClient.NewFakeClientset() 72 var w lock.Map[string, struct{}] 73 clients.KubernetesFakeClientset = augmentTracker(clients.KubernetesFakeClientset, t, &w) 74 clients.SlimFakeClientset = augmentTracker(clients.SlimFakeClientset, t, &w) 75 clients.CiliumFakeClientset = augmentTracker(clients.CiliumFakeClientset, t, &w) 76 clients.APIExtFakeClientset = augmentTracker(clients.APIExtFakeClientset, t, &w) 77 fd := clients.KubernetesFakeClientset.Discovery().(*fakediscovery.FakeDiscovery) 78 fd.FakedServerVersion = toVersionInfo(k8sVersion) 79 80 resources, ok := apiResources[k8sVersion] 81 if !ok { 82 panic(fmt.Sprintf("k8s version %s not found in apiResources", k8sVersion)) 83 } 84 clients.KubernetesFakeClientset.Resources = resources 85 clients.SlimFakeClientset.Resources = resources 86 clients.CiliumFakeClientset.Resources = resources 87 clients.APIExtFakeClientset.Resources = resources 88 89 trackers := []trackerAndDecoder{ 90 {clients.KubernetesFakeClientset.Tracker(), coreDecoder}, 91 {clients.SlimFakeClientset.Tracker(), slimDecoder}, 92 {clients.CiliumFakeClientset.Tracker(), ciliumDecoder}, 93 } 94 95 return &ControlPlaneTest{ 96 t: t, 97 nodeName: nodeName, 98 clients: clients, 99 trackers: trackers, 100 establishedWatchers: &w, 101 } 102 } 103 104 // SetupEnvironment sets the fake k8s clients, creates the fake datapath and 105 // creates the test directories. 106 func (cpt *ControlPlaneTest) SetupEnvironment() *ControlPlaneTest { 107 types.SetName(cpt.nodeName) 108 109 // Configure k8s and perform capability detection with the fake client. 110 version.Update(cpt.clients, true) 111 112 cpt.tempDir = setupTestDirectories() 113 114 return cpt 115 } 116 117 // ClearEnvironment removes all the test directories. 118 func (cpt *ControlPlaneTest) ClearEnvironment() { 119 os.RemoveAll(cpt.tempDir) 120 } 121 122 func (cpt *ControlPlaneTest) StartAgent(modConfig func(*agentOption.DaemonConfig), extraCells ...cell.Cell) *ControlPlaneTest { 123 if cpt.agentHandle != nil { 124 cpt.t.Fatal("StartAgent() already called") 125 } 126 127 cpt.agentHandle = &agentHandle{ 128 t: cpt.t, 129 } 130 131 cpt.agentHandle.setupCiliumAgentHive(cpt.clients, cell.Group(extraCells...)) 132 133 mockCmd := &cobra.Command{} 134 cpt.agentHandle.hive.RegisterFlags(mockCmd.Flags()) 135 agentCmd.InitGlobalFlags(mockCmd, cpt.agentHandle.hive.Viper()) 136 137 cpt.agentHandle.populateCiliumAgentOptions(cpt.tempDir, modConfig) 138 139 cpt.agentHandle.log = hivetest.Logger(cpt.t) 140 daemon, err := cpt.agentHandle.startCiliumAgent() 141 if err != nil { 142 cpt.t.Fatalf("Failed to start cilium agent: %s", err) 143 } 144 cpt.agentHandle.d = daemon 145 cpt.Datapath = cpt.agentHandle.dp 146 147 return cpt 148 } 149 150 func (cpt *ControlPlaneTest) StopAgent() *ControlPlaneTest { 151 cpt.agentHandle.tearDown() 152 cpt.agentHandle = nil 153 cpt.Datapath = nil 154 155 return cpt 156 } 157 158 func (cpt *ControlPlaneTest) StartOperator( 159 modConfig func(*operatorOption.OperatorConfig), 160 modCellConfig func(vp *viper.Viper), 161 ) *ControlPlaneTest { 162 if cpt.operatorHandle != nil { 163 cpt.t.Fatal("StartOperator() already called") 164 } 165 166 h := setupCiliumOperatorHive(cpt.clients) 167 168 mockCmd := &cobra.Command{} 169 h.RegisterFlags(mockCmd.Flags()) 170 operatorCmd.InitGlobalFlags(mockCmd, h.Viper()) 171 172 populateCiliumOperatorOptions(h.Viper(), modConfig, modCellConfig) 173 174 h.Viper().Set(apis.SkipCRDCreation, true) 175 176 // Disable support for operator HA. This should be cleaned up 177 // by injecting the capabilities, or by supporting the leader 178 // election machinery in the controlplane tests. 179 version.DisableLeasesResourceLock() 180 181 log := hivetest.Logger(cpt.t) 182 err := startCiliumOperator(h, log) 183 if err != nil { 184 cpt.t.Fatalf("Failed to start operator: %s", err) 185 } 186 187 cpt.operatorHandle = &operatorHandle{ 188 t: cpt.t, 189 hive: h, 190 log: log, 191 } 192 193 return cpt 194 } 195 196 func (cpt *ControlPlaneTest) StopOperator() *ControlPlaneTest { 197 cpt.operatorHandle.tearDown() 198 cpt.operatorHandle = nil 199 200 return cpt 201 } 202 203 func (cpt *ControlPlaneTest) UpdateObjects(objs ...k8sRuntime.Object) *ControlPlaneTest { 204 t := cpt.t 205 for _, obj := range objs { 206 gvr, ns, name := gvrAndName(obj) 207 208 // Convert to unstructured form for JSON marshalling. 209 // TODO: simpler way? 210 uobj, ok := obj.(*unstructured.Unstructured) 211 if !ok { 212 fields, err := k8sRuntime.DefaultUnstructuredConverter.ToUnstructured(obj) 213 if err != nil { 214 t.Fatalf("Failed to convert %T to unstructured: %s", obj, err) 215 } 216 uobj = &unstructured.Unstructured{Object: fields} 217 } 218 219 // Marshal the object to JSON in order to allow decoding it in different ways, 220 // e.g. as v1.Node and as slim_corev1.Node. This avoids having to write both 221 // the core and slim versions of the object in the test case. 222 jsonBytes, err := uobj.MarshalJSON() 223 if err != nil { 224 t.Fatalf("Failed to marshal %T to JSON: %s", obj, err) 225 } 226 227 accepted := false 228 var errors []error 229 for _, td := range cpt.trackers { 230 if obj, _, err := td.decoder.Decode(jsonBytes, nil, nil); err == nil { 231 accepted = true 232 233 if _, err := td.tracker.Get(gvr, ns, name); err == nil { 234 if err := td.tracker.Update(gvr, obj, ns); err != nil { 235 t.Fatalf("Failed to update object %T: %s", obj, err) 236 } 237 } else { 238 if err := td.tracker.Add(obj); err != nil { 239 t.Fatalf("Failed to add object %T: %s", obj, err) 240 } 241 } 242 } else { 243 errors = append(errors, err) 244 } 245 } 246 if !accepted { 247 t.Fatalf("None of the decoders accepted %s: %v", gvr, errors) 248 } 249 } 250 return cpt 251 } 252 253 // Get retrieves a k8s object given its group-version-resource, namespace and name. 254 // All the mocked control plane trackers will be queried in the search: 255 // - core 256 // - slim 257 // - cilium 258 // The first match will be returned. 259 // If the object cannot be found, a non nil error is returned. 260 func (cpt *ControlPlaneTest) Get(gvr schema.GroupVersionResource, ns, name string) (k8sRuntime.Object, error) { 261 var ( 262 obj k8sRuntime.Object 263 err error 264 ) 265 for _, td := range cpt.trackers { 266 if obj, err = td.tracker.Get(gvr, ns, name); err == nil { 267 return obj, nil 268 } 269 } 270 return nil, err 271 } 272 273 // EnsureWatchers delays progress of the test until watchers for resources have been established on 274 // the clientset. 275 func (cpt *ControlPlaneTest) EnsureWatchers(resources ...string) *ControlPlaneTest { 276 cpt.retry(func() error { 277 for _, resource := range resources { 278 if _, ok := cpt.establishedWatchers.Load(resource); !ok { 279 return fmt.Errorf("no watcher for %s yet", resource) 280 } 281 } 282 return nil 283 }) 284 285 return cpt 286 } 287 288 func (cpt *ControlPlaneTest) UpdateObjectsFromFile(filename string) *ControlPlaneTest { 289 bs, err := os.ReadFile(filename) 290 if err != nil { 291 cpt.t.Fatalf("Failed to read %s: %s", filename, err) 292 } 293 objs, err := unmarshalList(bs) 294 if err != nil { 295 cpt.t.Fatalf("Failed to unmarshal objects from %s: %s", filename, err) 296 } 297 return cpt.UpdateObjects(objs...) 298 } 299 300 func (cpt *ControlPlaneTest) DeleteObjects(objs ...k8sRuntime.Object) *ControlPlaneTest { 301 for _, obj := range objs { 302 gvr, ns, name := gvrAndName(obj) 303 304 deleted := false 305 for _, td := range cpt.trackers { 306 if err := td.tracker.Delete(gvr, ns, name); err == nil { 307 deleted = true 308 } 309 } 310 if !deleted { 311 cpt.t.Fatalf("Failed to delete object %s/%s as it was not found", ns, name) 312 } 313 } 314 return cpt 315 } 316 317 func (cpt *ControlPlaneTest) WithValidationTimeout(d time.Duration) *ControlPlaneTest { 318 cpt.validationTimeout = d 319 return cpt 320 } 321 322 func (cpt *ControlPlaneTest) Eventually(check func() error) *ControlPlaneTest { 323 if err := cpt.retry(check); err != nil { 324 cpt.t.Fatal(err) 325 } 326 return cpt 327 } 328 329 func (cpt *ControlPlaneTest) Execute(task func() error) *ControlPlaneTest { 330 if err := task(); err != nil { 331 cpt.t.Fatal(err) 332 } 333 return cpt 334 } 335 336 func (cpt *ControlPlaneTest) retry(act func() error) error { 337 wait := 50 * time.Millisecond 338 end := time.Now().Add(cpt.validationTimeout) 339 340 // With validationTimeout set to 0, act will be retried without enforcing any timeout. 341 // This is useful to reduce controlplane tests flakyness in CI environment. 342 // Use WithValidationTimeout to set a custom timeout for local development. 343 for cpt.validationTimeout == 0 || time.Now().Add(wait).Before(end) { 344 time.Sleep(wait) 345 346 err := act() 347 if err == nil { 348 return nil 349 } 350 cpt.t.Logf("validation failed: %s", err) 351 352 wait *= 2 353 if wait > time.Second { 354 wait = time.Second 355 } 356 cpt.t.Logf("going to retry after %s...", wait) 357 } 358 359 time.Sleep(time.Until(end)) 360 return act() 361 } 362 363 func toVersionInfo(rawVersion string) *versionapi.Info { 364 parts := strings.Split(rawVersion, ".") 365 return &versionapi.Info{Major: parts[0], Minor: parts[1]} 366 } 367 368 func gvrAndName(obj k8sRuntime.Object) (gvr schema.GroupVersionResource, ns string, name string) { 369 gvk := obj.GetObjectKind().GroupVersionKind() 370 gvr, _ = meta.UnsafeGuessKindToResource(gvk) 371 objMeta, err := meta.Accessor(obj) 372 if err != nil { 373 panic(err) 374 } 375 ns = objMeta.GetNamespace() 376 name = objMeta.GetName() 377 return 378 } 379 380 var ( 381 corev1APIResources = &metav1.APIResourceList{ 382 GroupVersion: corev1.SchemeGroupVersion.String(), 383 APIResources: []metav1.APIResource{ 384 {Name: "nodes", Kind: "Node"}, 385 {Name: "pods", Namespaced: true, Kind: "Pod"}, 386 {Name: "services", Namespaced: true, Kind: "Service"}, 387 {Name: "endpoints", Namespaced: true, Kind: "Endpoint"}, 388 }, 389 } 390 391 ciliumv2APIResources = &metav1.APIResourceList{ 392 TypeMeta: metav1.TypeMeta{}, 393 GroupVersion: cilium_v2.SchemeGroupVersion.String(), 394 APIResources: []metav1.APIResource{ 395 {Name: cilium_v2.CNPluralName, Kind: cilium_v2.CNKindDefinition}, 396 {Name: cilium_v2.CEPPluralName, Namespaced: true, Kind: cilium_v2.CEPKindDefinition}, 397 {Name: cilium_v2.CIDPluralName, Namespaced: true, Kind: cilium_v2.CIDKindDefinition}, 398 {Name: cilium_v2.CEGPPluralName, Namespaced: true, Kind: cilium_v2.CEGPKindDefinition}, 399 {Name: cilium_v2.CNPPluralName, Namespaced: true, Kind: cilium_v2.CNPKindDefinition}, 400 {Name: cilium_v2.CCNPPluralName, Namespaced: true, Kind: cilium_v2.CCNPKindDefinition}, 401 {Name: cilium_v2.CLRPPluralName, Namespaced: true, Kind: cilium_v2.CLRPKindDefinition}, 402 {Name: cilium_v2.CEWPluralName, Namespaced: true, Kind: cilium_v2.CEWKindDefinition}, 403 {Name: cilium_v2.CCECPluralName, Namespaced: true, Kind: cilium_v2.CCECKindDefinition}, 404 {Name: cilium_v2.CECPluralName, Namespaced: true, Kind: cilium_v2.CECKindDefinition}, 405 }, 406 } 407 408 discoveryV1APIResources = &metav1.APIResourceList{ 409 TypeMeta: metav1.TypeMeta{}, 410 GroupVersion: discov1.SchemeGroupVersion.String(), 411 APIResources: []metav1.APIResource{ 412 {Name: "endpointslices", Namespaced: true, Kind: "EndpointSlice"}, 413 }, 414 } 415 416 discoveryV1beta1APIResources = &metav1.APIResourceList{ 417 GroupVersion: discov1beta1.SchemeGroupVersion.String(), 418 APIResources: []metav1.APIResource{ 419 {Name: "endpointslices", Namespaced: true, Kind: "EndpointSlice"}, 420 }, 421 } 422 423 // apiResources is the list of API resources for the k8s version that we're mocking. 424 // This is mostly relevant for the feature detection at pkg/k8s/version/version.go. 425 // The lists here are currently not exhaustive and expanded on need-by-need basis. 426 apiResources = map[string][]*metav1.APIResourceList{ 427 "1.24": { 428 corev1APIResources, 429 discoveryV1APIResources, 430 discoveryV1beta1APIResources, 431 ciliumv2APIResources, 432 }, 433 "1.25": { 434 corev1APIResources, 435 discoveryV1APIResources, 436 ciliumv2APIResources, 437 }, 438 "1.26": { 439 corev1APIResources, 440 discoveryV1APIResources, 441 ciliumv2APIResources, 442 }, 443 } 444 ) 445 446 func matchFieldSelector(obj k8sRuntime.Object, selector fields.Selector) bool { 447 if selector == nil { 448 return true 449 } 450 451 fs := fields.Set{} 452 acc, err := meta.Accessor(obj) 453 if err != nil { 454 panic(err) 455 } 456 fs["metadata.name"] = acc.GetName() 457 fs["metadata.namespace"] = acc.GetNamespace() 458 459 // Special handling for specific objects. Only add things here that k8s api-server 460 // handles, see for example ToSelectableFields() in pkg/registry/core/pod/strategy.go 461 // of kubernetes. We don't want to end up with tests passing with fake client and 462 // failing against the real API server. 463 if pod, ok := obj.(*corev1.Pod); ok { 464 fs["spec.nodeName"] = pod.Spec.NodeName 465 } 466 if pod, ok := obj.(*slim_corev1.Pod); ok { 467 fs["spec.nodeName"] = pod.Spec.NodeName 468 } 469 470 if !selector.Matches(fs) { 471 // Check if we failed because we were trying to match a field that doesn't exist. 472 // If so, we'll panic so that an exception can be added. 473 for _, req := range selector.Requirements() { 474 if _, ok := fs[req.Field]; !ok { 475 panic(fmt.Sprintf( 476 "Unknown field selector %q!\nPlease add handling for it to matchFieldSelector() in test/controlplane/suite/testcase.go", 477 req.Field)) 478 } 479 } 480 return false 481 } 482 return true 483 } 484 485 type fakeWithTracker interface { 486 PrependReactor(verb string, resource string, reaction k8sTesting.ReactionFunc) 487 PrependWatchReactor(resource string, reaction k8sTesting.WatchReactionFunc) 488 Tracker() k8sTesting.ObjectTracker 489 } 490 491 type filteringWatcher struct { 492 parent watch.Interface 493 events chan watch.Event 494 restrictions k8sTesting.WatchRestrictions 495 } 496 497 var _ watch.Interface = &filteringWatcher{} 498 499 func (fw *filteringWatcher) Stop() { 500 fw.parent.Stop() 501 } 502 503 func (fw *filteringWatcher) ResultChan() <-chan watch.Event { 504 if fw.events != nil { 505 return fw.events 506 } 507 508 fw.events = make(chan watch.Event) 509 selector := fw.restrictions.Fields 510 go func() { 511 for event := range fw.parent.ResultChan() { 512 if matchFieldSelector(event.Object, selector) { 513 fw.events <- event 514 } 515 } 516 close(fw.events) 517 }() 518 return fw.events 519 } 520 521 func filterList(obj k8sRuntime.Object, restrictions k8sTesting.ListRestrictions) { 522 selector := restrictions.Fields 523 if selector == nil || selector.Empty() { 524 return 525 } 526 527 switch obj := obj.(type) { 528 case *corev1.NodeList: 529 items := make([]corev1.Node, 0, len(obj.Items)) 530 for i := range obj.Items { 531 if matchFieldSelector(&obj.Items[i], selector) { 532 items = append(items, obj.Items[i]) 533 } 534 } 535 obj.Items = items 536 case *slim_corev1.NodeList: 537 items := make([]slim_corev1.Node, 0, len(obj.Items)) 538 for i := range obj.Items { 539 if matchFieldSelector(&obj.Items[i], selector) { 540 items = append(items, obj.Items[i]) 541 } 542 } 543 obj.Items = items 544 case *slim_corev1.EndpointsList: 545 items := make([]slim_corev1.Endpoints, 0, len(obj.Items)) 546 for i := range obj.Items { 547 if matchFieldSelector(&obj.Items[i], selector) { 548 items = append(items, obj.Items[i]) 549 } 550 } 551 obj.Items = items 552 case *slim_corev1.PodList: 553 items := make([]slim_corev1.Pod, 0, len(obj.Items)) 554 for i := range obj.Items { 555 if matchFieldSelector(&obj.Items[i], selector) { 556 items = append(items, obj.Items[i]) 557 } 558 } 559 obj.Items = items 560 case *cilium_v2.CiliumNodeList: 561 items := make([]cilium_v2.CiliumNode, 0, len(obj.Items)) 562 for i := range obj.Items { 563 if matchFieldSelector(&obj.Items[i], selector) { 564 items = append(items, obj.Items[i]) 565 } 566 } 567 obj.Items = items 568 default: 569 panic( 570 fmt.Sprintf("Unhandled type %T for field selector filtering!\nPlease add handling for it to filterList()", obj), 571 ) 572 } 573 } 574 575 // augmentTracker augments the fake clientset to support filtering with a field selector 576 // in List and Watch actions, as well as recording which watchers have been established. 577 // The reason we need to do this is the following: The k8s object tracker's implementation 578 // of Watch is not equivalent to Watch on a real api-server, as it does not respect the 579 // ResourceVersion from whence to start the watch. As a consequence, when informers (or 580 // reflectors) call ListAndWatch, they miss events which occur between the end of List and 581 // the establishment of Watch. 582 // 583 // To decrease the likelihood of this race occurring in the control plane tests, we 584 // install a mechanism to wait for watchers of specific resources: see also 585 // EnsureWatchers. This isn't a complete fix - if multiple watchers for the same resource 586 // are established, this may give false positives. 587 func augmentTracker[T fakeWithTracker](f T, t *testing.T, watchers *lock.Map[string, struct{}]) T { 588 o := f.Tracker() 589 objectReaction := k8sTesting.ObjectReaction(o) 590 591 // Prepend our own reactors that adds field selector filtering to 592 // the results. 593 f.PrependReactor("*", "*", func(action k8sTesting.Action) (handled bool, ret k8sRuntime.Object, err error) { 594 handled, ret, err = objectReaction(action) 595 596 switch action := action.(type) { 597 case k8sTesting.ListActionImpl: 598 filterList(ret, action.GetListRestrictions()) 599 } 600 return 601 602 }) 603 604 f.PrependWatchReactor( 605 "*", 606 func(action k8sTesting.Action) (handled bool, ret watch.Interface, err error) { 607 w := action.(k8sTesting.WatchAction) 608 gvr := w.GetResource() 609 ns := w.GetNamespace() 610 watch, err := o.Watch(gvr, ns) 611 if err != nil { 612 return false, nil, err 613 } 614 if _, ok := watchers.Load(gvr.Resource); ok { 615 t.Logf("Multiple watches for resource %q intercepted. This highlights a potential cause for flakes", gvr.Resource) 616 } 617 watchers.Store(gvr.Resource, struct{}{}) 618 619 fw := &filteringWatcher{ 620 parent: watch, 621 restrictions: w.GetWatchRestrictions(), 622 } 623 return true, fw, nil 624 625 }) 626 627 return f 628 }