From be63dd1b21b6f90c6847bec4ddd885188b5d6dec Mon Sep 17 00:00:00 2001 From: Brian McMahon Date: Tue, 16 Jun 2026 08:36:24 -0700 Subject: [PATCH] Goldmane: trust Cloud Linseed signer for flow uploads (#4913) On Calico OSS clusters connected to Calico Cloud via ManagementClusterConnection, Goldmane's flow emitter could not verify Guardian and failed with "certificate signed by unknown authority". The Goldmane controller builds its trusted CA bundle from render.VoltronLinseedPublicCert. PR #4153 renamed that constant from tigera-voltron-linseed-certs-public to calico-voltron-linseed-certs-public, but the management cluster (Calico Cloud) still delivers the operator-signer to managed clusters under the legacy tigera- name. The name mismatch caused GetCertificate to return nil, which AddCertificates silently skipped, leaving the bundle with only the local signer. Trust whichever of the current/legacy Linseed public cert secrets is present, and degrade (ResourceNotReady) when connected to a management cluster but neither exists, so this class of misconfiguration surfaces early instead of failing silently. Also migrate the whisker controller tests to ginkgo v2. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/controller/goldmane/controller.go | 27 ++- pkg/controller/goldmane/controller_test.go | 186 +++++++++++++++++++++ pkg/controller/goldmane/suite_test.go | 29 ++++ pkg/controller/whisker/controller_test.go | 139 +++++++++++++++ pkg/controller/whisker/suite_test.go | 35 ++++ 5 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 pkg/controller/goldmane/controller_test.go create mode 100644 pkg/controller/goldmane/suite_test.go create mode 100644 pkg/controller/whisker/controller_test.go create mode 100644 pkg/controller/whisker/suite_test.go diff --git a/pkg/controller/goldmane/controller.go b/pkg/controller/goldmane/controller.go index d3f3cb54a4..0ea7c59a9c 100644 --- a/pkg/controller/goldmane/controller.go +++ b/pkg/controller/goldmane/controller.go @@ -72,6 +72,7 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { certificatemanagement.CASecretName, whisker.WhiskerBackendKeyPairSecret, render.VoltronLinseedPublicCert, + render.LegacyVoltronLinseedPublicCert, } { if err = utils.AddSecretsWatch(c, secretName, common.OperatorNamespace()); err != nil { return fmt.Errorf("failed to add watch for secret %s/%s: %w", common.OperatorNamespace(), secretName, err) @@ -208,12 +209,36 @@ func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) ( trustedBundle, err := certificateManager.CreateNamedTrustedBundleFromSecrets(goldmane.GoldmaneDeploymentName, r.cli, common.OperatorNamespace(), false, - whisker.WhiskerBackendKeyPairSecret, render.VoltronLinseedPublicCert) + whisker.WhiskerBackendKeyPairSecret) if err != nil { r.status.SetDegraded(operatorv1.ResourceCreateError, "Unable to create the trusted bundle", err, reqLogger) + return reconcile.Result{}, err } trustedBundle.AddCertificates(keyPair, nodeCA) + // When connected to a management cluster, Goldmane's flow emitter must trust the management cluster's + // Linseed signer. Its secret is named calico-voltron-linseed-certs-public (current) or + // tigera-voltron-linseed-certs-public (legacy) depending on the management cluster's operator version, so + // trust whichever is present and degrade if neither is - without it flow uploads fail TLS verification. + if mgmtClusterConnectionCR != nil { + var linseedCerts []certificatemanagement.CertificateInterface + for _, secretName := range []string{render.VoltronLinseedPublicCert, render.LegacyVoltronLinseedPublicCert} { + cert, err := certificateManager.GetCertificate(r.cli, secretName, common.OperatorNamespace()) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceReadError, fmt.Sprintf("Failed to retrieve %s", secretName), err, reqLogger) + return reconcile.Result{}, err + } + if cert != nil { + linseedCerts = append(linseedCerts, cert) + } + } + if len(linseedCerts) == 0 { + r.status.SetDegraded(operatorv1.ResourceNotReady, fmt.Sprintf("Waiting for the Linseed public certificate (%s or %s) required to verify flow uploads to the management cluster", render.VoltronLinseedPublicCert, render.LegacyVoltronLinseedPublicCert), nil, reqLogger) + return reconcile.Result{}, nil + } + trustedBundle.AddCertificates(linseedCerts...) + } + certComponent := rcertificatemanagement.CertificateManagement(&rcertificatemanagement.Config{ Namespace: goldmane.GoldmaneNamespace, TruthNamespace: common.OperatorNamespace(), diff --git a/pkg/controller/goldmane/controller_test.go b/pkg/controller/goldmane/controller_test.go new file mode 100644 index 0000000000..c7c4aa0925 --- /dev/null +++ b/pkg/controller/goldmane/controller_test.go @@ -0,0 +1,186 @@ +// Copyright (c) 2026 Tigera, Inc. 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 goldmane + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stretchr/testify/mock" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + operatorv1 "github.com/tigera/operator/api/v1" + "github.com/tigera/operator/pkg/apis" + "github.com/tigera/operator/pkg/common" + "github.com/tigera/operator/pkg/controller/certificatemanager" + "github.com/tigera/operator/pkg/controller/status" + ctrlrfake "github.com/tigera/operator/pkg/ctrlruntime/client/fake" + "github.com/tigera/operator/pkg/render" + rtest "github.com/tigera/operator/pkg/render/common/test" + "github.com/tigera/operator/pkg/render/goldmane" + "github.com/tigera/operator/pkg/tls/certificatemanagement" +) + +var _ = Describe("Goldmane controller tests", func() { + var ( + cli client.Client + scheme *runtime.Scheme + ctx context.Context + mockStatus *status.MockStatus + reconciler Reconciler + ) + + // reconcileRequest reconciles the default Goldmane CR. + reconcileRequest := func() (reconcile.Result, error) { + return reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: "default"}}) + } + + // bundleData returns the rendered goldmane trusted bundle ConfigMap data, or "" if it was not created. + bundleData := func() string { + cm := &corev1.ConfigMap{} + name := certificatemanagement.TrustedBundleName(goldmane.GoldmaneDeploymentName, false) + err := cli.Get(ctx, types.NamespacedName{Name: name, Namespace: goldmane.GoldmaneNamespace}, cm) + if err != nil { + return "" + } + return cm.Data[certificatemanagement.TrustedCertConfigMapKeyName] + } + + // setDegradedCalledWith reports whether SetDegraded was invoked with the given reason. + setDegradedCalledWith := func(reason operatorv1.TigeraStatusReason) bool { + for _, c := range mockStatus.Calls { + if c.Method == "SetDegraded" && len(c.Arguments) > 0 && c.Arguments[0] == reason { + return true + } + } + return false + } + + BeforeEach(func() { + scheme = runtime.NewScheme() + Expect(apis.AddToScheme(scheme, false)).ShouldNot(HaveOccurred()) + Expect(appsv1.SchemeBuilder.AddToScheme(scheme)).ShouldNot(HaveOccurred()) + Expect(rbacv1.SchemeBuilder.AddToScheme(scheme)).ShouldNot(HaveOccurred()) + + ctx = context.Background() + cli = ctrlrfake.DefaultFakeClientBuilder(scheme).Build() + + // Prerequisites for a successful reconcile. + Expect(cli.Create(ctx, &operatorv1.Goldmane{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + })).ToNot(HaveOccurred()) + Expect(cli.Create(ctx, &operatorv1.Installation{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Status: operatorv1.InstallationStatus{ + Variant: operatorv1.Calico, + Computed: &operatorv1.InstallationSpec{}, + }, + Spec: operatorv1.InstallationSpec{Variant: operatorv1.Calico}, + })).ToNot(HaveOccurred()) + + // Persist the operator root CA so the trusted bundle can be signed. + certificateManager, err := certificatemanager.Create(cli, nil, "cluster.local", common.OperatorNamespace(), certificatemanager.AllowCACreation()) + Expect(err).NotTo(HaveOccurred()) + Expect(cli.Create(ctx, certificateManager.KeyPair().Secret(common.OperatorNamespace()))).NotTo(HaveOccurred()) + + mockStatus = &status.MockStatus{} + mockStatus.On("OnCRFound").Return() + mockStatus.On("OnCRNotFound").Return().Maybe() + mockStatus.On("SetMetaData", mock.Anything).Return() + mockStatus.On("ReadyToMonitor").Return() + mockStatus.On("ClearDegraded").Return() + mockStatus.On("AddDeployments", mock.Anything).Return().Maybe() + mockStatus.On("AddDaemonsets", mock.Anything).Return().Maybe() + mockStatus.On("AddStatefulSets", mock.Anything).Return().Maybe() + mockStatus.On("AddCronJobs", mock.Anything).Return().Maybe() + mockStatus.On("IsAvailable").Return(true).Maybe() + mockStatus.On("AddCertificateSigningRequests", mock.Anything).Return().Maybe() + mockStatus.On("RemoveCertificateSigningRequests", mock.Anything).Return().Maybe() + mockStatus.On("SetDegraded", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() + + reconciler = Reconciler{ + cli: cli, + scheme: scheme, + provider: operatorv1.ProviderNone, + status: mockStatus, + clusterDomain: "cluster.local", + } + }) + + // createLinseedPublicCert creates a Voltron Linseed public certificate secret under the given name, + // mimicking the cert that the management cluster (e.g. Calico Cloud) delivers to a managed cluster. It is + // signed by a CA distinct from the local operator signer, so it must be added to the trusted bundle. + createLinseedPublicCert := func(secretName string) { + Expect(cli.Create(ctx, rtest.CreateCertSecret(secretName, common.OperatorNamespace(), secretName))).NotTo(HaveOccurred()) + } + + Context("connected to a management cluster", func() { + BeforeEach(func() { + Expect(cli.Create(ctx, &operatorv1.ManagementClusterConnection{ + ObjectMeta: metav1.ObjectMeta{Name: "tigera-secure"}, + })).ToNot(HaveOccurred()) + }) + + It("trusts the legacy tigera-voltron-linseed-certs-public secret", func() { + createLinseedPublicCert(render.LegacyVoltronLinseedPublicCert) + + _, err := reconcileRequest() + Expect(err).ShouldNot(HaveOccurred()) + + // The cloud signer must be present in the trusted bundle so the flow emitter can verify Guardian. + Expect(bundleData()).To(ContainSubstring(render.LegacyVoltronLinseedPublicCert)) + }) + + It("trusts the current calico-voltron-linseed-certs-public secret", func() { + createLinseedPublicCert(render.VoltronLinseedPublicCert) + + _, err := reconcileRequest() + Expect(err).ShouldNot(HaveOccurred()) + + Expect(bundleData()).To(ContainSubstring(render.VoltronLinseedPublicCert)) + }) + + It("degrades when no Linseed public certificate is present", func() { + result, err := reconcileRequest() + + // We should degrade and requeue rather than silently ship a bundle missing the cloud signer. + Expect(err).ShouldNot(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + Expect(setDegradedCalledWith(operatorv1.ResourceNotReady)).To(BeTrue()) + + // The bundle should not have been rendered, since we returned early. + Expect(bundleData()).To(BeEmpty()) + }) + }) + + Context("standalone cluster (no management cluster connection)", func() { + It("does not require the Linseed public certificate", func() { + // Even if a cloud cert exists, a standalone goldmane does not emit flows and should not degrade on it. + _, err := reconcileRequest() + Expect(err).ShouldNot(HaveOccurred()) + Expect(setDegradedCalledWith(operatorv1.ResourceNotReady)).To(BeFalse()) + Expect(bundleData()).ToNot(ContainSubstring(render.LegacyVoltronLinseedPublicCert)) + }) + }) +}) diff --git a/pkg/controller/goldmane/suite_test.go b/pkg/controller/goldmane/suite_test.go new file mode 100644 index 0000000000..0cbd693507 --- /dev/null +++ b/pkg/controller/goldmane/suite_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Tigera, Inc. 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 goldmane + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +func TestStatus(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + suiteConfig, reporterConfig := ginkgo.GinkgoConfiguration() + reporterConfig.JUnitReport = "../../../report/ut/goldmane_controller_suite.xml" + ginkgo.RunSpecs(t, "pkg/controller/goldmane Controller Suite", suiteConfig, reporterConfig) +} diff --git a/pkg/controller/whisker/controller_test.go b/pkg/controller/whisker/controller_test.go new file mode 100644 index 0000000000..2afd2b896a --- /dev/null +++ b/pkg/controller/whisker/controller_test.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Tigera, Inc. 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 whisker + +import ( + "context" + + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stretchr/testify/mock" + "github.com/tigera/operator/pkg/common" + "github.com/tigera/operator/pkg/controller/certificatemanager" + ctrlrfake "github.com/tigera/operator/pkg/ctrlruntime/client/fake" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/tls" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + operatorv1 "github.com/tigera/operator/api/v1" + "github.com/tigera/operator/pkg/apis" + "github.com/tigera/operator/pkg/controller/status" + admregv1 "k8s.io/api/admissionregistration/v1" + appsv1 "k8s.io/api/apps/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +var _ = Describe("whisker controller tests", func() { + var ( + cli client.Client + scheme *runtime.Scheme + ctx context.Context + mockStatus *status.MockStatus + installation *operatorv1.Installation + certificateManagement *operatorv1.CertificateManagement + ) + + BeforeEach(func() { + // Set up the scheme + scheme = runtime.NewScheme() + Expect(apis.AddToScheme(scheme, false)).ShouldNot(HaveOccurred()) + Expect(appsv1.SchemeBuilder.AddToScheme(scheme)).ShouldNot(HaveOccurred()) + Expect(rbacv1.SchemeBuilder.AddToScheme(scheme)).ShouldNot(HaveOccurred()) + Expect(admregv1.SchemeBuilder.AddToScheme(scheme)).ShouldNot(HaveOccurred()) + Expect(networkingv1.SchemeBuilder.AddToScheme(scheme)).ShouldNot(HaveOccurred()) + + ctx = context.Background() + cli = ctrlrfake.DefaultFakeClientBuilder(scheme).Build() + + // Create a CertificateManagement instance for tests that need it. + ca, err := tls.MakeCA(rmeta.DefaultOperatorCASignerName()) + Expect(err).NotTo(HaveOccurred()) + cert, _, _ := ca.Config.GetPEMBytes() // create a valid pem block + certificateManagement = &operatorv1.CertificateManagement{CACert: cert} + + replicas := int32(2) + installation = &operatorv1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + Generation: 2, + }, + Status: operatorv1.InstallationStatus{ + Variant: operatorv1.TigeraSecureEnterprise, + Computed: &operatorv1.InstallationSpec{}, + }, + Spec: operatorv1.InstallationSpec{ + ControlPlaneReplicas: &replicas, + Variant: operatorv1.Calico, + Registry: "some.registry.org/", + }, + } + + // Apply prerequisites for the basic reconcile to succeed. + certificateManager, err := certificatemanager.Create(cli, nil, "cluster.local", common.OperatorNamespace(), certificatemanager.AllowCACreation()) + Expect(err).NotTo(HaveOccurred()) + Expect(cli.Create(context.Background(), certificateManager.KeyPair().Secret(common.OperatorNamespace()))).NotTo(HaveOccurred()) + Expect(cli.Create(ctx, &operatorv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{Name: "tigera-secure"}, + })).ToNot(HaveOccurred()) + + Expect(cli.Create(ctx, &operatorv1.Goldmane{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + })).ToNot(HaveOccurred()) + + Expect(cli.Create(ctx, &operatorv1.Whisker{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + })).ToNot(HaveOccurred()) + + // Set up a mock status + mockStatus = &status.MockStatus{} + mockStatus.On("AddDaemonsets", mock.Anything).Return() + mockStatus.On("AddDeployments", mock.Anything).Return() + mockStatus.On("AddStatefulSets", mock.Anything).Return() + mockStatus.On("AddCronJobs", mock.Anything) + mockStatus.On("IsAvailable").Return(true) + mockStatus.On("OnCRFound").Return() + mockStatus.On("ClearDegraded") + mockStatus.On("AddCertificateSigningRequests", mock.Anything) + mockStatus.On("RemoveCertificateSigningRequests", mock.Anything) + mockStatus.On("ReadyToMonitor") + mockStatus.On("SetMetaData", mock.Anything).Return() + mockStatus.On("SetDegraded", operatorv1.ResourceReadError, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() + mockStatus.On("SetDegraded", operatorv1.ResourceNotReady, mock.Anything, mock.Anything, mock.Anything).Return().Maybe() + }) + + Context("verify reconciliation", func() { + It("should use builtin images", func() { + installation.Spec.CertificateManagement = certificateManagement + Expect(cli.Create(ctx, installation)).To(BeNil()) + reconciler := Reconciler{ + cli: cli, + scheme: scheme, + provider: operatorv1.ProviderNone, + status: mockStatus, + } + _, err := reconciler.Reconcile(context.Background(), reconcile.Request{NamespacedName: types.NamespacedName{Name: "default", Namespace: "calico-system"}}) + Expect(err).ShouldNot(HaveOccurred()) + Expect(cli.Get(ctx, types.NamespacedName{Name: installation.Name}, installation)).ShouldNot(HaveOccurred()) + _ = installation + }) + }) +}) diff --git a/pkg/controller/whisker/suite_test.go b/pkg/controller/whisker/suite_test.go new file mode 100644 index 0000000000..0c9d51cb36 --- /dev/null +++ b/pkg/controller/whisker/suite_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2019-2026 Tigera, Inc. 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 whisker + +import ( + "testing" + + uzap "go.uber.org/zap" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +func TestStatus(t *testing.T) { + logf.SetLogger(zap.New(zap.WriteTo(ginkgo.GinkgoWriter), zap.UseDevMode(true), zap.Level(uzap.NewAtomicLevelAt(uzap.DebugLevel)))) + gomega.RegisterFailHandler(ginkgo.Fail) + suiteConfig, reporterConfig := ginkgo.GinkgoConfiguration() + reporterConfig.JUnitReport = "../../../report/ut/whisker_suite.xml" + ginkgo.RunSpecs(t, "pkg/controller/whisker Suite", suiteConfig, reporterConfig) +}