sigs.k8s.io/controller-runtime@v0.18.2/pkg/manager/internal/integration/manager_test.go (about) 1 /* 2 Copyright 2023 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package integration 18 19 import ( 20 "context" 21 "fmt" 22 "net" 23 "net/http" 24 "reflect" 25 "sync/atomic" 26 "time" 27 "unsafe" 28 29 . "github.com/onsi/ginkgo/v2" 30 . "github.com/onsi/gomega" 31 apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 32 apierrors "k8s.io/apimachinery/pkg/api/errors" 33 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 34 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 35 "k8s.io/apimachinery/pkg/runtime" 36 clientgoscheme "k8s.io/client-go/kubernetes/scheme" 37 metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 38 39 ctrl "sigs.k8s.io/controller-runtime" 40 "sigs.k8s.io/controller-runtime/pkg/client" 41 "sigs.k8s.io/controller-runtime/pkg/envtest" 42 logf "sigs.k8s.io/controller-runtime/pkg/log" 43 "sigs.k8s.io/controller-runtime/pkg/log/zap" 44 "sigs.k8s.io/controller-runtime/pkg/manager" 45 crewv1 "sigs.k8s.io/controller-runtime/pkg/manager/internal/integration/api/v1" 46 crewv2 "sigs.k8s.io/controller-runtime/pkg/manager/internal/integration/api/v2" 47 "sigs.k8s.io/controller-runtime/pkg/reconcile" 48 "sigs.k8s.io/controller-runtime/pkg/webhook" 49 "sigs.k8s.io/controller-runtime/pkg/webhook/conversion" 50 ) 51 52 var ( 53 scheme = runtime.NewScheme() 54 55 driverCRD = &apiextensionsv1.CustomResourceDefinition{ 56 ObjectMeta: metav1.ObjectMeta{ 57 Name: "drivers.crew.example.com", 58 }, 59 Spec: apiextensionsv1.CustomResourceDefinitionSpec{ 60 Group: crewv1.GroupVersion.Group, 61 Names: apiextensionsv1.CustomResourceDefinitionNames{ 62 Plural: "drivers", 63 Singular: "driver", 64 Kind: "Driver", 65 }, 66 Scope: apiextensionsv1.NamespaceScoped, 67 Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ 68 { 69 Name: crewv1.GroupVersion.Version, 70 Served: true, 71 // v1 will be the storage version. 72 // Reconciler and index will use v2 so we can validate the conversion webhook works. 73 Storage: true, 74 Schema: &apiextensionsv1.CustomResourceValidation{ 75 OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ 76 Type: "object", 77 }, 78 }, 79 }, 80 { 81 Name: crewv2.GroupVersion.Version, 82 Served: true, 83 Storage: false, 84 Schema: &apiextensionsv1.CustomResourceValidation{ 85 OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ 86 Type: "object", 87 }, 88 }, 89 }, 90 }, 91 }, 92 } 93 ) 94 95 var _ = Describe("manger.Manager Start", func() { 96 // This test ensure the Manager starts without running into any deadlocks as it can be very tricky 97 // to start health probes, webhooks, caches (including informers) and reconcilers in the right order. 98 // 99 // To verify this we set up a test environment in the following state: 100 // * Ensure Informer sync requires a functioning conversion webhook (and thus readiness probe) 101 // * Driver CRD is deployed with v1 as storage version 102 // * A Driver CR is created and stored in the v1 version 103 // * Setup manager: 104 // * Set up health probes 105 // * Set up a Driver v2 reconciler to verify reconciliation works 106 // * Set up a conversion webhook which only works if readiness probe succeeds (just like via a Kubernetes service) 107 // * Add an index on v2 Driver to ensure we start and wait for an informer during cache.Start (as part of manager.Start) 108 // * Note: cache.Start would fail if the conversion webhook doesn't work (which in turn depends on the readiness probe) 109 // * Note: Adding the index for v2 ensures the Driver list call during Informer sync goes through conversion. 110 It("should start all components without deadlock", func() { 111 ctx := ctrl.SetupSignalHandler() 112 113 // Set up schema. 114 Expect(clientgoscheme.AddToScheme(scheme)).To(Succeed()) 115 Expect(apiextensionsv1.AddToScheme(scheme)).To(Succeed()) 116 Expect(crewv1.AddToScheme(scheme)).To(Succeed()) 117 Expect(crewv2.AddToScheme(scheme)).To(Succeed()) 118 119 // Set up test environment. 120 env := &envtest.Environment{ 121 Scheme: scheme, 122 CRDInstallOptions: envtest.CRDInstallOptions{ 123 CRDs: []*apiextensionsv1.CustomResourceDefinition{driverCRD}, 124 }, 125 } 126 // Note: The test env configures a conversion webhook on driverCRD during Start. 127 cfg, err := env.Start() 128 Expect(err).NotTo(HaveOccurred()) 129 Expect(cfg).NotTo(BeNil()) 130 defer func() { 131 Expect(env.Stop()).To(Succeed()) 132 }() 133 c, err := client.New(cfg, client.Options{}) 134 Expect(err).NotTo(HaveOccurred()) 135 136 // Create driver CR (which is stored as v1). 137 driverV1 := &unstructured.Unstructured{} 138 driverV1.SetGroupVersionKind(crewv1.GroupVersion.WithKind("Driver")) 139 driverV1.SetName("driver1") 140 driverV1.SetNamespace(metav1.NamespaceDefault) 141 Expect(c.Create(ctx, driverV1)).To(Succeed()) 142 143 // Set up Manager. 144 ctrl.SetLogger(zap.New()) 145 mgr, err := manager.New(env.Config, manager.Options{ 146 Scheme: scheme, 147 HealthProbeBindAddress: ":0", 148 // Disable metrics to avoid port conflicts. 149 Metrics: metricsserver.Options{BindAddress: "0"}, 150 WebhookServer: webhook.NewServer(webhook.Options{ 151 Port: env.WebhookInstallOptions.LocalServingPort, 152 Host: env.WebhookInstallOptions.LocalServingHost, 153 CertDir: env.WebhookInstallOptions.LocalServingCertDir, 154 }), 155 }) 156 Expect(err).NotTo(HaveOccurred()) 157 158 // Configure health probes. 159 Expect(mgr.AddReadyzCheck("webhook", mgr.GetWebhookServer().StartedChecker())).To(Succeed()) 160 Expect(mgr.AddHealthzCheck("webhook", mgr.GetWebhookServer().StartedChecker())).To(Succeed()) 161 162 // Set up Driver reconciler (using v2). 163 driverReconciler := &DriverReconciler{ 164 Client: mgr.GetClient(), 165 } 166 Expect(ctrl.NewControllerManagedBy(mgr).For(&crewv2.Driver{}).Complete(driverReconciler)).To(Succeed()) 167 168 // Set up a conversion webhook. 169 conversionWebhook := createConversionWebhook(mgr) 170 mgr.GetWebhookServer().Register("/convert", conversionWebhook) 171 172 // Add an index on Driver (using v2). 173 // Note: This triggers the creation of an Informer for Driver v2. 174 Expect(mgr.GetCache().IndexField(ctx, &crewv2.Driver{}, "name", func(object client.Object) []string { 175 return []string{object.GetName()} 176 })).To(Succeed()) 177 178 // Start the Manager. 179 ctx, cancel := context.WithCancel(ctx) 180 go func() { 181 defer GinkgoRecover() 182 Expect(mgr.Start(ctx)).To(Succeed()) 183 }() 184 185 // Verify manager.Start successfully started health probes, webhooks, caches (including informers) and reconcilers. 186 // Notes: 187 // * The cache will only start successfully if the informer for v2 Driver is synced. 188 // * The informer for v2 Driver will only sync if a list on v2 Driver succeeds (which requires a working conversion webhook) 189 select { 190 case <-time.After(30 * time.Second): 191 // Don't wait forever if the manager doesn't come up. 192 Fail("Manager didn't start in time") 193 case <-mgr.Elected(): 194 } 195 196 // Verify the reconciler reconciles. 197 Eventually(func(g Gomega) { 198 g.Expect(atomic.LoadUint64(&driverReconciler.ReconcileCount)).Should(BeNumerically(">", 0)) 199 }, 10*time.Second).Should(Succeed()) 200 201 // Verify conversion webhook was called. 202 Expect(atomic.LoadUint64(&conversionWebhook.ConversionCount)).Should(BeNumerically(">", 0)) 203 204 // Verify the conversion webhook works by getting the Driver as v1 and v2. 205 Expect(c.Get(ctx, client.ObjectKeyFromObject(driverV1), driverV1)).To(Succeed()) 206 driverV2 := &unstructured.Unstructured{} 207 driverV2.SetGroupVersionKind(crewv2.GroupVersion.WithKind("Driver")) 208 driverV2.SetName("driver1") 209 driverV2.SetNamespace(metav1.NamespaceDefault) 210 Expect(c.Get(ctx, client.ObjectKeyFromObject(driverV2), driverV2)).To(Succeed()) 211 212 // Shutdown the server 213 cancel() 214 }) 215 }) 216 217 type DriverReconciler struct { 218 Client client.Client 219 ReconcileCount uint64 220 } 221 222 func (r *DriverReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { 223 log := ctrl.LoggerFrom(ctx) 224 log.Info("Reconciling") 225 226 // Fetch the Driver instance. 227 cluster := &crewv2.Driver{} 228 if err := r.Client.Get(ctx, req.NamespacedName, cluster); err != nil { 229 if apierrors.IsNotFound(err) { 230 return ctrl.Result{}, nil 231 } 232 233 // Error reading the object - requeue the request. 234 return ctrl.Result{}, err 235 } 236 237 atomic.AddUint64(&r.ReconcileCount, 1) 238 239 return reconcile.Result{}, nil 240 } 241 242 // ConversionWebhook is just a shim around the conversion handler from 243 // the webhook package. We use it to simulate the behavior of a conversion 244 // webhook in a real cluster, i.e. the conversion webhook only works after the 245 // controller Pod is ready (the readiness probe is up). 246 type ConversionWebhook struct { 247 httpClient http.Client 248 conversionHandler http.Handler 249 readinessEndpoint string 250 ConversionCount uint64 251 } 252 253 func createConversionWebhook(mgr manager.Manager) *ConversionWebhook { 254 conversionHandler := conversion.NewWebhookHandler(mgr.GetScheme()) 255 httpClient := http.Client{ 256 // Setting a timeout to not get stuck when calling the readiness probe. 257 Timeout: 5 * time.Second, 258 } 259 260 // Read the unexported healthProbeListener field of the manager to get the listener address. 261 // This is a hack but it's better than using a hard-coded port. 262 v := reflect.ValueOf(mgr).Elem() 263 field := v.FieldByName("healthProbeListener") 264 healthProbeListener := *(*net.Listener)(unsafe.Pointer(field.UnsafeAddr())) //nolint:gosec 265 readinessEndpoint := fmt.Sprint("http://", healthProbeListener.Addr().String(), "/readyz") 266 267 return &ConversionWebhook{ 268 httpClient: httpClient, 269 conversionHandler: conversionHandler, 270 readinessEndpoint: readinessEndpoint, 271 } 272 } 273 274 func (c *ConversionWebhook) ServeHTTP(w http.ResponseWriter, r *http.Request) { 275 resp, err := c.httpClient.Get(c.readinessEndpoint) 276 if err != nil { 277 logf.Log.WithName("conversion-webhook").Error(err, "failed to serve conversion: readiness endpoint is not up") 278 w.WriteHeader(http.StatusInternalServerError) 279 return 280 } 281 282 Expect(err).NotTo(HaveOccurred()) 283 defer resp.Body.Close() 284 285 if resp.StatusCode != http.StatusOK { 286 // This simulates the behavior in Kubernetes that conversion webhooks are only served after 287 // the controller is ready (and thus the Kubernetes service sends requests to the controller). 288 logf.Log.WithName("conversion-webhook").Info("failed to serve conversion: controller is not ready yet") 289 w.WriteHeader(http.StatusInternalServerError) 290 return 291 } 292 293 atomic.AddUint64(&c.ConversionCount, 1) 294 c.conversionHandler.ServeHTTP(w, r) 295 }