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  }