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) +}