diff --git a/charts/kafka-operator/README.md b/charts/kafka-operator/README.md index 7e64b0deb..d375a85fa 100644 --- a/charts/kafka-operator/README.md +++ b/charts/kafka-operator/README.md @@ -87,6 +87,7 @@ The command removes all the Kubernetes components associated with the chart and | webhook.certs.secret | string | `"kafka-operator-serving-cert"` | Helm chart will use the secret name applied here for the cert | | certManager.enabled | bool | `false` | Operator will integrate with the cert manager | | certManager.namespace | string | `"cert-manager"` | Operator will look for the cert manager in this namespace namespace field specifies the Cert-manager's Cluster Resource Namespace. https://cert-manager.io/docs/configuration/ | +| contour.enabled | bool | `false` | Enable Project Contour ingress integration. Only enable this when the Kafka cluster uses `ingressController: contour`. When enabled, Project Contour's HTTPProxy CRD (projectcontour.io/v1) must be installed in the cluster, otherwise the operator fails to start. https://projectcontour.io | | certSigning.enabled | bool | `true` | Enable native certificate signing integration | | alertManager.enable | bool | `true` | AlertManager can be enabled | | alertManager.port | int | `9001` | AlertManager port | diff --git a/charts/kafka-operator/templates/operator-deployment-with-webhook.yaml b/charts/kafka-operator/templates/operator-deployment-with-webhook.yaml index 0de92a3ed..9d82ddd3e 100644 --- a/charts/kafka-operator/templates/operator-deployment-with-webhook.yaml +++ b/charts/kafka-operator/templates/operator-deployment-with-webhook.yaml @@ -173,6 +173,7 @@ spec: - --enable-leader-election - --cert-manager-namespace={{ .Values.certManager.namespace }} - --cert-manager-enabled={{ .Values.certManager.enabled }} + - --contour-enabled={{ .Values.contour.enabled }} {{- if not .Values.certSigning.enabled }} - --disable-cert-signing-support {{- end }} diff --git a/charts/kafka-operator/values.yaml b/charts/kafka-operator/values.yaml index 0bc9ee464..35ee018b5 100644 --- a/charts/kafka-operator/values.yaml +++ b/charts/kafka-operator/values.yaml @@ -72,6 +72,14 @@ certManager: # https://cert-manager.io/docs/configuration/ namespace: "cert-manager" +contour: + # -- Enable Project Contour ingress integration. Only enable this when the + # Kafka cluster uses `ingressController: contour`. When enabled, Project + # Contour's HTTPProxy CRD (projectcontour.io/v1) must be installed in the + # cluster, otherwise the operator fails to start. + # https://projectcontour.io + enabled: false + certSigning: # -- Enable native certificate signing integration enabled: true diff --git a/controllers/kafkacluster_controller.go b/controllers/kafkacluster_controller.go index a688c152d..4b825d773 100644 --- a/controllers/kafkacluster_controller.go +++ b/controllers/kafkacluster_controller.go @@ -360,7 +360,7 @@ func (r *KafkaClusterReconciler) updateAndFetchLatest(ctx context.Context, clust } // SetupKafkaClusterWithManager registers kafka cluster controller to the manager -func SetupKafkaClusterWithManager(mgr ctrl.Manager) *ctrl.Builder { +func SetupKafkaClusterWithManager(mgr ctrl.Manager, contourEnabled bool) *ctrl.Builder { log := mgr.GetLogger() builder := ctrl.NewControllerManagedBy(mgr). For(&v1beta1.KafkaCluster{}). @@ -369,7 +369,14 @@ func SetupKafkaClusterWithManager(mgr ctrl.Manager) *ctrl.Builder { kafkaWatches(builder) envoyWatches(builder) - contourWatches(builder) + // The Contour watch (Owns(&contour.HTTPProxy{})) is only registered when + // Contour ingress is enabled. Registering it unconditionally makes the + // manager depend on Project Contour's HTTPProxy CRD being installed: when it + // is absent the informer never syncs and the operator crash-loops. + // See https://github.com/adobe/koperator/issues/229. + if contourEnabled { + contourWatches(builder) + } cruiseControlWatches(builder) builder.WithEventFilter( diff --git a/controllers/tests/clusterregistry/suite_test.go b/controllers/tests/clusterregistry/suite_test.go index aa30f80ff..62f4fe57a 100644 --- a/controllers/tests/clusterregistry/suite_test.go +++ b/controllers/tests/clusterregistry/suite_test.go @@ -145,7 +145,7 @@ var _ = BeforeSuite(func() { Expect(mgr).ToNot(BeNil()) kafkaClusterReconciler = NewTestReconciler() - err = controllers.SetupKafkaClusterWithManager(mgr).Named("KafkaCluster").Complete(kafkaClusterReconciler) + err = controllers.SetupKafkaClusterWithManager(mgr, true).Named("KafkaCluster").Complete(kafkaClusterReconciler) Expect(err).NotTo(HaveOccurred()) kafkaTopicReconciler = NewTestReconciler() diff --git a/controllers/tests/contourwatch/contour_watch_test.go b/controllers/tests/contourwatch/contour_watch_test.go new file mode 100644 index 000000000..f47a2f948 --- /dev/null +++ b/controllers/tests/contourwatch/contour_watch_test.go @@ -0,0 +1,138 @@ +// Copyright 2026 Cisco Systems, Inc. and/or its affiliates +// Copyright 2026 Adobe. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package contourwatch contains a focused regression test for +// https://github.com/adobe/koperator/issues/229: the operator must not +// require Project Contour's HTTPProxy CRD unless Contour ingress is enabled. +// +// The KafkaCluster controller Owns(&contour.HTTPProxy{}); if that watch is +// registered while the CRD is absent, controller-runtime never syncs the +// informer, mgr.Start returns an error and the operator pod CrashLoopBackOffs. +// This test boots an envtest API server WITHOUT the Contour CRD and asserts +// the manager stays healthy when Contour is disabled. +package contourwatch + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "k8s.io/apimachinery/pkg/runtime" + k8sscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/config" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + contour "github.com/projectcontour/contour/apis/projectcontour/v1" + + banzaicloudv1beta1 "github.com/banzaicloud/koperator/api/v1beta1" + "github.com/banzaicloud/koperator/controllers" + "github.com/banzaicloud/koperator/pkg/kafkaclient" +) + +const ( + // cacheSyncTimeout bounds how long a controller waits for its informers to + // sync. When the Contour watch is (incorrectly) active without the CRD, the + // manager reports the sync failure after this timeout. + cacheSyncTimeout = 15 * time.Second + // healthyGrace must exceed cacheSyncTimeout: if the manager survives this + // long without exiting, its caches synced, meaning the HTTPProxy watch was + // not registered. + healthyGrace = 20 * time.Second +) + +// TestContourDisabledManagerStartsWithoutContourCRD reproduces issue #229: with +// Contour disabled and the projectcontour HTTPProxy CRD absent, the manager must +// start and keep running instead of crash-looping on a never-syncing informer. +func TestContourDisabledManagerStartsWithoutContourCRD(t *testing.T) { + testEnv := &envtest.Environment{ + ErrorIfCRDPathMissing: true, + // Deliberately install only Koperator's own CRDs — no projectcontour — + // to mirror a cluster that does not run Project Contour. + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "config", "base", "crds"), + }, + } + + cfg, err := testEnv.Start() + if err != nil { + t.Fatalf("failed to start envtest control plane: %v", err) + } + t.Cleanup(func() { + if stopErr := testEnv.Stop(); stopErr != nil { + t.Logf("failed to stop envtest control plane: %v", stopErr) + } + }) + + scheme := runtime.NewScheme() + if err := k8sscheme.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add client-go scheme: %v", err) + } + if err := banzaicloudv1beta1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add koperator scheme: %v", err) + } + // The Contour types are registered on the scheme exactly as in main.go, so + // the only thing that differs between the crash-looping and fixed behavior + // is whether the HTTPProxy watch is wired up. + if err := contour.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add contour scheme: %v", err) + } + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme, + Metrics: server.Options{BindAddress: "0"}, + LeaderElection: false, + Controller: config.Controller{CacheSyncTimeout: cacheSyncTimeout}, + }) + if err != nil { + t.Fatalf("failed to create manager: %v", err) + } + + reconciler := &controllers.KafkaClusterReconciler{ + Client: mgr.GetClient(), + DirectClient: mgr.GetAPIReader(), + KafkaClientProvider: kafkaclient.NewMockProvider(), + } + + const contourEnabled = false + if err := controllers.SetupKafkaClusterWithManager(mgr, contourEnabled).Complete(reconciler); err != nil { + t.Fatalf("failed to set up KafkaCluster controller: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + startErr := make(chan error, 1) + go func() { + startErr <- mgr.Start(ctx) + }() + + select { + case err := <-startErr: + t.Fatalf("manager exited before becoming healthy (regression of issue #229 — "+ + "the Contour HTTPProxy watch is active without the Contour CRD installed): %v", err) + case <-time.After(healthyGrace): + // Manager survived past the cache-sync timeout: informers synced, so the + // HTTPProxy watch was correctly skipped while Contour is disabled. + } + + cancel() + if err := <-startErr; err != nil && !errors.Is(err, context.Canceled) { + t.Fatalf("manager Start returned an unexpected error on shutdown: %v", err) + } +} diff --git a/controllers/tests/suite_test.go b/controllers/tests/suite_test.go index cad14d2d3..10ecaa739 100644 --- a/controllers/tests/suite_test.go +++ b/controllers/tests/suite_test.go @@ -163,7 +163,7 @@ var _ = BeforeSuite(func(ctx SpecContext) { KafkaClientProvider: kafkaclient.NewMockProvider(), } - err = controllers.SetupKafkaClusterWithManager(mgr).Complete(&kafkaClusterReconciler) + err = controllers.SetupKafkaClusterWithManager(mgr, true).Complete(&kafkaClusterReconciler) Expect(err).NotTo(HaveOccurred()) kafkaTopicReconciler := &controllers.KafkaTopicReconciler{ diff --git a/main.go b/main.go index e2d65eee9..0a62f715a 100644 --- a/main.go +++ b/main.go @@ -89,6 +89,7 @@ func main() { verboseLogging bool certSigningDisabled bool certManagerEnabled bool + contourEnabled bool maxKafkaTopicConcurrentReconciles int healthProbesAddr string ) @@ -103,6 +104,7 @@ func main() { flag.BoolVar(&developmentLogging, "development", false, "Enable development logging") flag.BoolVar(&verboseLogging, "verbose", false, "Enable verbose logging") flag.BoolVar(&certManagerEnabled, "cert-manager-enabled", false, "Enable cert-manager integration") + flag.BoolVar(&contourEnabled, "contour-enabled", false, "Enable Project Contour ingress integration. Requires Contour's HTTPProxy CRD to be installed in the cluster.") flag.BoolVar(&certSigningDisabled, "disable-cert-signing-support", false, "Disable native certificate signing integration") flag.IntVar(&maxKafkaTopicConcurrentReconciles, "max-kafka-topic-concurrent-reconciles", 10, "Define max amount of concurrent KafkaTopic reconciles") flag.StringVar(&healthProbesAddr, "health-probes-addr", ":8081", "The address the probe endpoint binds to.") @@ -180,7 +182,7 @@ func main() { KafkaClientProvider: kafkaclient.NewDefaultProvider(), } - if err = controllers.SetupKafkaClusterWithManager(mgr).Complete(kafkaClusterReconciler); err != nil { + if err = controllers.SetupKafkaClusterWithManager(mgr, contourEnabled).Complete(kafkaClusterReconciler); err != nil { setupLog.Error(err, "unable to create controller", "controller", "KafkaCluster") os.Exit(1) } diff --git a/tests/e2e/const.go b/tests/e2e/const.go index a69e562c6..bf56999d3 100644 --- a/tests/e2e/const.go +++ b/tests/e2e/const.go @@ -90,6 +90,7 @@ const ( // Common string values and CLI flags/keys. falseString = "false" + trueString = "true" timeoutFlag = "--timeout" timeoutValue = "10m" outputFlag = "--output" diff --git a/tests/e2e/global.go b/tests/e2e/global.go index 0c6bb1dd1..fcdeff1c5 100644 --- a/tests/e2e/global.go +++ b/tests/e2e/global.go @@ -70,6 +70,11 @@ var ( "crds/kafkatopics.yaml", "crds/kafkausers.yaml", }, + // Contour is installed as a dependency above (with its HTTPProxy + // CRD), so enable the operator's Contour integration to exercise it. + SetValues: map[string]string{ + "contour.enabled": trueString, + }, } // Set helm chart values for Koperator to be able to use custom image koperatorImagePath := os.Getenv("IMG_E2E") @@ -83,10 +88,8 @@ var ( koperatorImageTag = koperatorImagePathSplit[1] } - koperatorLocalHelmDescriptor.SetValues = map[string]string{ - "operator.image.repository": koperatorImageRepository, - "operator.image.tag": koperatorImageTag, - } + koperatorLocalHelmDescriptor.SetValues["operator.image.repository"] = koperatorImageRepository + koperatorLocalHelmDescriptor.SetValues["operator.image.tag"] = koperatorImageTag } return koperatorLocalHelmDescriptor