diff --git a/api/v1/tigerastatus_types.go b/api/v1/tigerastatus_types.go index eddb7c1f51..9206a9bac9 100644 --- a/api/v1/tigerastatus_types.go +++ b/api/v1/tigerastatus_types.go @@ -45,6 +45,7 @@ type TigeraStatusStatus struct { // +kubebuilder:printcolumn:name="Progressing",type="string",JSONPath=".status.conditions[?(@.type=='Progressing')].status",description="Whether the component is processing changes." // +kubebuilder:printcolumn:name="Degraded",type="string",JSONPath=".status.conditions[?(@.type=='Degraded')].status",description="Whether the component is degraded." // +kubebuilder:printcolumn:name="Since",type="date",JSONPath=".status.conditions[?(@.type=='Available')].lastTransitionTime",description="The time the component's Available status last changed." +// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.conditions[?(@.type=='Degraded')].message",description="Error message when the component is degraded.",priority=0 type TigeraStatus struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/pkg/common/components.go b/pkg/common/components.go index 4a2eab59c9..4597534278 100644 --- a/pkg/common/components.go +++ b/pkg/common/components.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022-2024 Tigera, Inc. All rights reserved. +// Copyright (c) 2022-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. @@ -22,17 +22,22 @@ import ( // MergeMaps merges current and desired maps. If both current and desired maps contain the same key, the // desired map's value is used. -// MergeMaps does not copy hash.operator.tigera.io annotations from the current map, since those are managed by the operator. +// MergeMaps does not copy operator-managed annotations from the current map. func MergeMaps(current, desired map[string]string) map[string]string { for k, v := range current { - // Copy over key/value that should be copied. - if _, ok := desired[k]; !ok && !strings.Contains(k, "hash.operator.tigera.io") { + if _, ok := desired[k]; !ok && !isOperatorManaged(k) { desired[k] = v } } return desired } +// isOperatorManaged returns true if the given annotation key is managed by the operator +// and should not be copied from the current map during merges. +func isOperatorManaged(key string) bool { + return strings.Contains(key, "operator.tigera.io") +} + // MapExistsOrInitialize returns the given map if non-nil or returns an empty map. func MapExistsOrInitialize(m map[string]string) map[string]string { if m != nil { diff --git a/pkg/controller/apiserver/apiserver_controller.go b/pkg/controller/apiserver/apiserver_controller.go index 5151c738d7..c56aa15276 100644 --- a/pkg/controller/apiserver/apiserver_controller.go +++ b/pkg/controller/apiserver/apiserver_controller.go @@ -464,6 +464,7 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re var components []render.Component + var webhooksTLS certificatemanagement.KeyPairInterface certKeyPairOptions := []rcertificatemanagement.KeyPairOption{ rcertificatemanagement.NewKeyPairOption(tlsSecret, true, true), } @@ -481,7 +482,7 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re // // The network policy is included within the webhooks component so it is reconciled alongside // the Deployment. The TLS keypair is provisioned by the CertificateManagement component below. - webhooksTLS, err := certificateManager.GetOrCreateKeyPair( + webhooksTLS, err = certificateManager.GetOrCreateKeyPair( r.client, webhooks.WebhooksTLSSecretName, common.OperatorNamespace(), @@ -538,6 +539,12 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re } } + // Check BYO certificate expiry warnings. + certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ + render.CalicoAPIServerTLSSecretName: tlsSecret, + webhooks.WebhooksTLSSecretName: webhooksTLS, + }, r.status) + // Clear the degraded bit if we've reached this far. r.status.ClearDegraded() diff --git a/pkg/controller/apiserver/apiserver_controller_test.go b/pkg/controller/apiserver/apiserver_controller_test.go index dafa6867bb..2308c33461 100644 --- a/pkg/controller/apiserver/apiserver_controller_test.go +++ b/pkg/controller/apiserver/apiserver_controller_test.go @@ -147,6 +147,8 @@ var _ = Describe("apiserver controller tests", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("AddCertificateSigningRequests", mock.Anything) mockStatus.On("RemoveCertificateSigningRequests", mock.Anything) mockStatus.On("RemoveDeployments", mock.Anything) diff --git a/pkg/controller/authentication/authentication_controller.go b/pkg/controller/authentication/authentication_controller.go index 455ac386a5..7c91d0b861 100644 --- a/pkg/controller/authentication/authentication_controller.go +++ b/pkg/controller/authentication/authentication_controller.go @@ -429,6 +429,11 @@ func (r *ReconcileAuthentication) Reconcile(ctx context.Context, request reconci } } + // Check BYO certificate expiry warnings. + certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ + render.DexTLSSecretName: tlsKeyPair, + }, r.status) + // Clear the degraded bit if we've reached this far. r.status.ClearDegraded() diff --git a/pkg/controller/authentication/authentication_controller_test.go b/pkg/controller/authentication/authentication_controller_test.go index 18276efb08..63352a4881 100644 --- a/pkg/controller/authentication/authentication_controller_test.go +++ b/pkg/controller/authentication/authentication_controller_test.go @@ -86,6 +86,8 @@ var _ = Describe("authentication controller tests", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("SetDegraded", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return() mockStatus.On("ReadyToMonitor") mockStatus.On("OnCRNotFound").Return() @@ -489,6 +491,8 @@ var _ = Describe("authentication controller tests", func() { mockStatus = &status.MockStatus{} mockStatus.On("OnCRFound").Return() + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return().Maybe() + mockStatus.On("ClearWarning", mock.Anything).Return().Maybe() r = &ReconcileAuthentication{ client: cli, scheme: scheme, diff --git a/pkg/controller/certificatemanager/certificatemanager_test.go b/pkg/controller/certificatemanager/certificatemanager_test.go index b318a2d498..0908d15bf7 100644 --- a/pkg/controller/certificatemanager/certificatemanager_test.go +++ b/pkg/controller/certificatemanager/certificatemanager_test.go @@ -432,6 +432,52 @@ var _ = Describe("Test CertificateManagement suite", func() { Expect(len(keyPair.HashAnnotationValue())).NotTo(BeNil()) }) + It("should add cert metadata labels and annotations to the secret", func() { + By("creating a key pair signed by certificateManager") + keyPair, err := certificateManager.GetOrCreateKeyPair(cli, appSecretName, appNs, appDNSNames) + Expect(err).NotTo(HaveOccurred()) + secret := keyPair.Secret(appNs) + + By("verifying the signer label is set") + Expect(secret.Labels).To(HaveKey("certificates.operator.tigera.io/signer")) + signerLabel := secret.Labels["certificates.operator.tigera.io/signer"] + Expect(signerLabel).To(ContainSubstring("tigera-operator-signer")) + + By("verifying cert metadata annotations are present and correct") + Expect(secret.Annotations).To(HaveKey("certificates.operator.tigera.io/issuer")) + Expect(secret.Annotations).To(HaveKey("certificates.operator.tigera.io/signer")) + Expect(secret.Annotations).To(HaveKey("certificates.operator.tigera.io/expiry")) + Expect(secret.Annotations["certificates.operator.tigera.io/issuer"]).To(ContainSubstring("tigera-operator-signer")) + Expect(secret.Annotations["certificates.operator.tigera.io/signer"]).To(Equal(secret.Annotations["certificates.operator.tigera.io/issuer"])) + Expect(secret.Annotations["certificates.operator.tigera.io/expiry"]).To(MatchRegexp(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$`)) + + By("verifying DNS names annotation contains expected names") + Expect(secret.Annotations).To(HaveKey("certificates.operator.tigera.io/dns-names")) + Expect(secret.Annotations["certificates.operator.tigera.io/dns-names"]).To(ContainSubstring(appSecretName)) + + By("verifying the hash annotation is on the secret") + Expect(secret.Annotations).To(HaveKey(keyPair.HashAnnotationKey())) + Expect(secret.Annotations[keyPair.HashAnnotationKey()]).To(Equal(keyPair.HashAnnotationValue())) + }) + + It("should add cert metadata for BYO secrets", func() { + By("creating a BYO secret and fetching it via certificateManager") + Expect(cli.Create(ctx, byoSecret)).NotTo(HaveOccurred()) + keyPair, err := certificateManager.GetOrCreateKeyPair(cli, appSecretName, appNs, appDNSNames) + Expect(err).NotTo(HaveOccurred()) + Expect(keyPair.BYO()).To(BeTrue()) + secret := keyPair.Secret(appNs) + + By("verifying the signer label reflects the BYO CA") + Expect(secret.Labels).To(HaveKey("certificates.operator.tigera.io/signer")) + Expect(secret.Labels["certificates.operator.tigera.io/signer"]).To(ContainSubstring("byo-ca")) + + By("verifying cert metadata annotations reflect the BYO certificate") + Expect(secret.Annotations).To(HaveKey("certificates.operator.tigera.io/issuer")) + Expect(secret.Annotations["certificates.operator.tigera.io/issuer"]).To(ContainSubstring("byo-ca")) + Expect(secret.Annotations).To(HaveKey("certificates.operator.tigera.io/expiry")) + }) + It("renders the right spec for certificate management", func() { By("creating a key pair w/ certificate management") installation.CertificateManagement = cm diff --git a/pkg/controller/compliance/compliance_controller.go b/pkg/controller/compliance/compliance_controller.go index 2e122b1ab7..27894db556 100644 --- a/pkg/controller/compliance/compliance_controller.go +++ b/pkg/controller/compliance/compliance_controller.go @@ -512,6 +512,15 @@ func (r *ReconcileCompliance) Reconcile(ctx context.Context, request reconcile.R return reconcile.Result{}, nil } + // Check BYO certificate expiry warnings. + certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ + render.ComplianceServerCertSecret: complianceServerKeyPair, + render.ComplianceSnapshotterSecret: snapshotterKeyPair.Interface, + render.ComplianceBenchmarkerSecret: benchmarkerKeyPair.Interface, + render.ComplianceReporterSecret: reporterKeyPair.Interface, + render.ComplianceControllerSecret: controllerKeyPair.Interface, + }, r.status) + // Clear the degraded bit if we've reached this far. r.status.ClearDegraded() diff --git a/pkg/controller/compliance/compliance_controller_test.go b/pkg/controller/compliance/compliance_controller_test.go index b466365c19..090e6bdd9f 100644 --- a/pkg/controller/compliance/compliance_controller_test.go +++ b/pkg/controller/compliance/compliance_controller_test.go @@ -87,6 +87,8 @@ var _ = Describe("Compliance controller tests", func() { mockStatus.On("OnCRFound").Return() mockStatus.On("AddCertificateSigningRequests", mock.Anything).Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("ReadyToMonitor") mockStatus.On("SetMetaData", mock.Anything).Return() diff --git a/pkg/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index 2b5a550046..1e302099fe 100644 --- a/pkg/controller/installation/core_controller.go +++ b/pkg/controller/installation/core_controller.go @@ -1696,6 +1696,15 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile // Tell the status manager that we're ready to monitor the resources we've told it about and receive statuses. r.status.ReadyToMonitor() + // Check BYO certificate expiry warnings and propagate them to the status manager. + certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ + render.TyphaTLSSecretName: typhaNodeTLS.TyphaSecret, + render.NodeTLSSecretName: typhaNodeTLS.NodeSecret, + render.TyphaTLSSecretName + render.TyphaNonClusterHostSuffix: typhaNodeTLS.TyphaSecretNonClusterHost, + render.NodePrometheusTLSServerSecret: nodePrometheusTLS, + kubecontrollers.KubeControllerPrometheusTLSSecret: kubeControllerTLS, + }, r.status) + // We can clear the degraded state now since as far as we know everything is in order. r.status.ClearDegraded() diff --git a/pkg/controller/installation/core_controller_test.go b/pkg/controller/installation/core_controller_test.go index 246cce62d2..6964f531d9 100644 --- a/pkg/controller/installation/core_controller_test.go +++ b/pkg/controller/installation/core_controller_test.go @@ -168,6 +168,8 @@ var _ = Describe("Testing core-controller installation", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("AddCertificateSigningRequests", mock.Anything) mockStatus.On("RemoveCertificateSigningRequests", mock.Anything) mockStatus.On("ReadyToMonitor") @@ -785,6 +787,8 @@ var _ = Describe("Testing core-controller installation", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("AddCertificateSigningRequests", mock.Anything) mockStatus.On("RemoveCertificateSigningRequests", mock.Anything) mockStatus.On("ReadyToMonitor") @@ -1004,6 +1008,8 @@ var _ = Describe("Testing core-controller installation", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("AddCertificateSigningRequests", mock.Anything) mockStatus.On("ReadyToMonitor") mockStatus.On("SetMetaData", mock.Anything).Return() @@ -2139,6 +2145,8 @@ var _ = Describe("Testing core-controller installation", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("AddCertificateSigningRequests", mock.Anything) mockStatus.On("RemoveCertificateSigningRequests", mock.Anything) mockStatus.On("ReadyToMonitor") @@ -2273,6 +2281,8 @@ var _ = Describe("Testing core-controller installation", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("AddCertificateSigningRequests", mock.Anything) mockStatus.On("RemoveCertificateSigningRequests", mock.Anything) mockStatus.On("ReadyToMonitor") diff --git a/pkg/controller/intrusiondetection/intrusiondetection_controller.go b/pkg/controller/intrusiondetection/intrusiondetection_controller.go index 72017ebcca..0904ea4d30 100644 --- a/pkg/controller/intrusiondetection/intrusiondetection_controller.go +++ b/pkg/controller/intrusiondetection/intrusiondetection_controller.go @@ -499,6 +499,7 @@ func (r *ReconcileIntrusionDetection) Reconcile(ctx context.Context, request rec intrusionDetectionComponent, } + var dpiKeyPair certificatemanagement.KeyPairInterface if !r.opts.MultiTenant { // FIXME: core controller creates TyphaNodeTLSConfig, this controller should only get it. // But changing the call from GetOrCreateTyphaNodeTLSConfig() to GetTyphaNodeTLSConfig() @@ -511,7 +512,7 @@ func (r *ReconcileIntrusionDetection) Reconcile(ctx context.Context, request rec typhaNodeTLS.TrustedBundle.AddCertificates(linseedCertificate) // dpiKeyPair is the key pair dpi presents to identify itself - dpiKeyPair, err := certificateManager.GetOrCreateKeyPair(r.client, render.DPITLSSecretName, helper.TruthNamespace(), []string{render.IntrusionDetectionTLSSecretName}) + dpiKeyPair, err = certificateManager.GetOrCreateKeyPair(r.client, render.DPITLSSecretName, helper.TruthNamespace(), []string{render.IntrusionDetectionTLSSecretName}) if err != nil { r.status.SetDegraded(operatorv1.ResourceCreateError, "Error creating TLS certificate", err, reqLogger) return reconcile.Result{}, err @@ -581,6 +582,12 @@ func (r *ReconcileIntrusionDetection) Reconcile(ctx context.Context, request rec return reconcile.Result{}, nil } + // Check BYO certificate expiry warnings. + certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ + render.IntrusionDetectionTLSSecretName: intrusionDetectionKeyPair, + render.DPITLSSecretName: dpiKeyPair, + }, r.status) + // Clear the degraded bit if we've reached this far. r.status.ClearDegraded() diff --git a/pkg/controller/intrusiondetection/intrusiondetection_controller_test.go b/pkg/controller/intrusiondetection/intrusiondetection_controller_test.go index 265f099122..1da34907cd 100644 --- a/pkg/controller/intrusiondetection/intrusiondetection_controller_test.go +++ b/pkg/controller/intrusiondetection/intrusiondetection_controller_test.go @@ -89,6 +89,8 @@ var _ = Describe("IntrusionDetection controller tests", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("SetDegraded", operatorv1.InvalidConfigurationError, mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("SetDegraded", operatorv1.ResourceReadError, mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("SetDegraded", operatorv1.ResourceUpdateError, mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return().Maybe() diff --git a/pkg/controller/logcollector/logcollector_controller.go b/pkg/controller/logcollector/logcollector_controller.go index 31534b2f14..3a95d83d40 100644 --- a/pkg/controller/logcollector/logcollector_controller.go +++ b/pkg/controller/logcollector/logcollector_controller.go @@ -704,6 +704,12 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile return reconcile.Result{}, nil } + // Check BYO certificate expiry warnings. + certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ + render.FluentdPrometheusTLSSecretName: fluentdKeyPair, + render.EKSLogForwarderTLSSecretName: eksLogForwarderKeyPair, + }, r.status) + // Clear the degraded bit if we've reached this far. r.status.ClearDegraded() diff --git a/pkg/controller/logcollector/logcollector_controller_test.go b/pkg/controller/logcollector/logcollector_controller_test.go index 47371ca82b..a82c20da43 100644 --- a/pkg/controller/logcollector/logcollector_controller_test.go +++ b/pkg/controller/logcollector/logcollector_controller_test.go @@ -81,6 +81,8 @@ var _ = Describe("LogCollector controller tests", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("SetDegraded", operatorv1.ResourceNotReady, "Waiting for LicenseKeyAPI to be ready", mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("ReadyToMonitor") mockStatus.On("SetMetaData", mock.Anything).Return() diff --git a/pkg/controller/manager/manager_controller.go b/pkg/controller/manager/manager_controller.go index 01819f7bcb..c3c4958426 100644 --- a/pkg/controller/manager/manager_controller.go +++ b/pkg/controller/manager/manager_controller.go @@ -722,6 +722,13 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ } } + // Check BYO certificate expiry warnings. + certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ + render.ManagerTLSSecretName: tlsSecret, + render.ManagerInternalTLSSecretName: internalTrafficSecret, + render.VoltronLinseedTLS: linseedVoltronServerCert, + }, r.status) + // Clear the degraded bit if we've reached this far. r.status.ClearDegraded() instance.Status.State = operatorv1.TigeraStatusReady diff --git a/pkg/controller/manager/manager_controller_test.go b/pkg/controller/manager/manager_controller_test.go index 122ed69e79..1a0b2487e4 100644 --- a/pkg/controller/manager/manager_controller_test.go +++ b/pkg/controller/manager/manager_controller_test.go @@ -146,6 +146,8 @@ var _ = Describe("Manager controller tests", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("SetDegraded", operatorv1.ResourceNotReady, "Waiting for LicenseKeyAPI to be ready", mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("SetDegraded", operatorv1.ResourceNotReady, "Waiting for secret 'tigera-packetcapture-server-tls' to become available", mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("SetDegraded", operatorv1.ResourceNotReady, "Waiting for secret 'tigera-secure-linseed-cert' to become available", mock.Anything, mock.Anything).Return().Maybe() @@ -525,6 +527,8 @@ var _ = Describe("Manager controller tests", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("SetDegraded", operatorv1.ResourceNotReady, "Waiting for LicenseKeyAPI to be ready", mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("SetDegraded", operatorv1.ResourceNotReady, "Waiting for secret 'calico-node-prometheus-tls' to become available", mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("SetDegraded", operatorv1.ResourceNotReady, "Waiting for secret 'tigera-packetcapture-server-tls' to become available", mock.Anything, mock.Anything).Return().Maybe() @@ -763,6 +767,8 @@ var _ = Describe("Manager controller tests", func() { mockStatus.On("AddDeployments", mock.Anything) mockStatus.On("RemoveDeployments", []types.NamespacedName{{Name: render.LegacyManagerDeploymentName, Namespace: render.LegacyManagerNamespace}}).Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return().Maybe() + mockStatus.On("ClearWarning", mock.Anything).Return().Maybe() mockStatus.On("SetDegraded", operatorv1.ResourceNotReady, "Compliance is not ready", mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("RemoveCertificateSigningRequests", mock.Anything) mockStatus.On("ReadyToMonitor") @@ -1192,6 +1198,8 @@ var _ = Describe("Manager controller tests", func() { mockStatus.On("RemoveDeployments", []types.NamespacedName{{Name: render.LegacyManagerDeploymentName, Namespace: tenantBNamespace}}).Return() mockStatus.On("ReadyToMonitor") mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("IsAvailable").Return(true) r.opts.MultiTenant = true diff --git a/pkg/controller/monitor/monitor_controller.go b/pkg/controller/monitor/monitor_controller.go index 93dbc3ea56..123e27d8bf 100644 --- a/pkg/controller/monitor/monitor_controller.go +++ b/pkg/controller/monitor/monitor_controller.go @@ -471,6 +471,12 @@ func (r *ReconcileMonitor) Reconcile(ctx context.Context, request reconcile.Requ return reconcile.Result{}, nil } + // Check BYO certificate expiry warnings. + certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ + monitor.PrometheusServerTLSSecretName: serverTLSSecret, + monitor.PrometheusClientTLSSecretName: clientTLSSecret, + }, r.status) + r.status.ClearDegraded() if !r.status.IsAvailable() { diff --git a/pkg/controller/monitor/monitor_controller_test.go b/pkg/controller/monitor/monitor_controller_test.go index 9c8df546c3..1265840e67 100644 --- a/pkg/controller/monitor/monitor_controller_test.go +++ b/pkg/controller/monitor/monitor_controller_test.go @@ -82,6 +82,8 @@ var _ = Describe("Monitor controller tests", func() { mockStatus.On("AddDeployments", mock.Anything).Return() mockStatus.On("AddStatefulSets", mock.Anything) mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ReadyToMonitor") diff --git a/pkg/controller/packetcapture/packetcapture_controller.go b/pkg/controller/packetcapture/packetcapture_controller.go index 5aad77c761..1ffeb4c151 100644 --- a/pkg/controller/packetcapture/packetcapture_controller.go +++ b/pkg/controller/packetcapture/packetcapture_controller.go @@ -303,6 +303,11 @@ func (r *ReconcilePacketCapture) Reconcile(ctx context.Context, request reconcil } } + // Check BYO certificate expiry warnings. + certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ + render.PacketCaptureServerCert: packetCaptureCertSecret, + }, r.status) + // Clear the degraded bit if we've reached this far. r.status.ClearDegraded() diff --git a/pkg/controller/packetcapture/packetcapture_controller_test.go b/pkg/controller/packetcapture/packetcapture_controller_test.go index 74145ce2d8..246553f190 100644 --- a/pkg/controller/packetcapture/packetcapture_controller_test.go +++ b/pkg/controller/packetcapture/packetcapture_controller_test.go @@ -139,6 +139,8 @@ var _ = Describe("packet capture controller tests", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("ReadyToMonitor") mockStatus.On("SetMetaData", mock.Anything).Return() mockStatus.On("SetDegraded", operatorv1.ResourceValidationError, mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return().Maybe() diff --git a/pkg/controller/policyrecommendation/policyrecommendation_controller.go b/pkg/controller/policyrecommendation/policyrecommendation_controller.go index d22ec45557..3bfa067782 100644 --- a/pkg/controller/policyrecommendation/policyrecommendation_controller.go +++ b/pkg/controller/policyrecommendation/policyrecommendation_controller.go @@ -474,6 +474,11 @@ func (r *ReconcilePolicyRecommendation) Reconcile(ctx context.Context, request r } } + // Check BYO certificate expiry warnings. + certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ + render.PolicyRecommendationTLSSecretName: policyRecommendationKeyPair, + }, r.status) + // Clear the degraded bit if we've reached this far. r.status.ClearDegraded() diff --git a/pkg/controller/policyrecommendation/policyrecommendation_controller_test.go b/pkg/controller/policyrecommendation/policyrecommendation_controller_test.go index ebf0746b42..4189337bb6 100644 --- a/pkg/controller/policyrecommendation/policyrecommendation_controller_test.go +++ b/pkg/controller/policyrecommendation/policyrecommendation_controller_test.go @@ -83,6 +83,8 @@ var _ = Describe("PolicyRecommendation controller tests", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("SetDegraded", operatorv1.ResourceValidationError, mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("SetDegraded", operatorv1.ResourceReadError, mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("SetDegraded", operatorv1.ResourceUpdateError, mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return().Maybe() @@ -495,6 +497,8 @@ var _ = Describe("PolicyRecommendation controller tests", func() { mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() mockStatus.On("ClearDegraded") + mockStatus.On("SetWarning", mock.Anything, mock.Anything).Return() + mockStatus.On("ClearWarning", mock.Anything).Return() mockStatus.On("SetDegraded", operatorv1.ResourceValidationError, mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("SetDegraded", operatorv1.ResourceReadError, mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return().Maybe() mockStatus.On("SetDegraded", operatorv1.ResourceUpdateError, mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return().Maybe() diff --git a/pkg/controller/status/mock.go b/pkg/controller/status/mock.go index 3e70f301ff..048f58267d 100644 --- a/pkg/controller/status/mock.go +++ b/pkg/controller/status/mock.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2024 Tigera, Inc. All rights reserved. +// 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. @@ -99,6 +99,14 @@ func (m *MockStatus) ClearDegraded() { m.Called() } +func (m *MockStatus) SetWarning(key string, msg string) { + m.Called(key, msg) +} + +func (m *MockStatus) ClearWarning(key string) { + m.Called(key) +} + func (m *MockStatus) IsAvailable() bool { return m.Called().Bool(0) } diff --git a/pkg/controller/status/status.go b/pkg/controller/status/status.go index 60a26a57c0..1a9e00650d 100644 --- a/pkg/controller/status/status.go +++ b/pkg/controller/status/status.go @@ -18,6 +18,7 @@ import ( "context" "fmt" "reflect" + "sort" "strings" "sync" "time" @@ -71,6 +72,8 @@ type StatusManager interface { RemoveCertificateSigningRequests(name string) SetDegraded(reason operator.TigeraStatusReason, msg string, err error, log logr.Logger) ClearDegraded() + SetWarning(key string, msg string) + ClearWarning(key string) IsAvailable() bool IsProgressing() bool IsDegraded() bool @@ -95,6 +98,9 @@ type statusManager struct { explicitDegradedMsg string explicitDegradedReason operator.TigeraStatusReason + // warnings stores warning messages keyed by component/secret name. + warnings map[string]string + // Keep track of currently calculated status. progressing []string failing []string @@ -132,6 +138,7 @@ func New(client client.Client, component string, kubernetesVersion *common.Versi statefulsets: make(map[string]types.NamespacedName), cronjobs: make(map[string]types.NamespacedName), certificatestatusrequests: make(map[string]map[string]string), + warnings: make(map[string]string), kubernetesVersion: kubernetesVersion, crExists: crExists, } @@ -160,8 +167,9 @@ func (m *statusManager) updateStatus() { // We've collected knowledge about the current state of the objects we're monitoring. // Now, use that to update the TigeraStatus object for this manager. available := m.IsAvailable() - if m.IsAvailable() { - m.setAvailable(operator.AllObjectsAvailable, "All objects available") + availableMsg := m.availableMessage() + if available { + m.setAvailable(operator.AllObjectsAvailable, availableMsg) } else { m.clearAvailable() } @@ -170,7 +178,7 @@ func (m *statusManager) updateStatus() { m.setProgressing(operator.ResourceNotReady, m.progressingMessage()) } else { if available { - m.clearProgressingWithReason(operator.AllObjectsAvailable, "All Objects Available") + m.clearProgressingWithReason(operator.AllObjectsAvailable, availableMsg) } else { m.clearProgressing() } @@ -180,7 +188,7 @@ func (m *statusManager) updateStatus() { m.setDegraded(m.degradedReason(), m.degradedMessage()) } else { if available { - m.clearDegradedWithReason(operator.AllObjectsAvailable, "All Objects Available") + m.clearDegradedWithReason(operator.AllObjectsAvailable, availableMsg) } else { m.clearDegraded() } @@ -262,6 +270,7 @@ func (m *statusManager) OnCRNotFound() { m.deployments = make(map[string]types.NamespacedName) m.statefulsets = make(map[string]types.NamespacedName) m.cronjobs = make(map[string]types.NamespacedName) + m.warnings = make(map[string]string) } // AddDaemonsets tells the status manager to monitor the health of the given daemonsets. @@ -377,6 +386,40 @@ func (m *statusManager) ClearDegraded() { m.explicitDegradedMsg = "" } +// SetWarning sets a warning message for the given key. Warnings are appended to the Available +// condition message so they are visible in `kubectl get tigerastatus`. +func (m *statusManager) SetWarning(key string, msg string) { + m.lock.Lock() + defer m.lock.Unlock() + m.warnings[key] = msg +} + +// ClearWarning removes the warning for the given key. +func (m *statusManager) ClearWarning(key string) { + m.lock.Lock() + defer m.lock.Unlock() + delete(m.warnings, key) +} + +// warningMessage returns all warning messages joined by "; ", or empty if there are none. +func (m *statusManager) warningMessage() string { + m.lock.Lock() + defer m.lock.Unlock() + if len(m.warnings) == 0 { + return "" + } + keys := make([]string, 0, len(m.warnings)) + for k := range m.warnings { + keys = append(keys, k) + } + sort.Strings(keys) + msgs := make([]string, 0, len(keys)) + for _, k := range keys { + msgs = append(msgs, m.warnings[k]) + } + return strings.Join(msgs, "; ") +} + // IsAvailable returns true if the component is available and false otherwise. func (m *statusManager) IsAvailable() bool { m.lock.Lock() @@ -802,6 +845,14 @@ func (m *statusManager) clearAvailable() { m.set(true, conditions...) } +func (m *statusManager) availableMessage() string { + msg := "All objects available" + if w := m.warningMessage(); w != "" { + msg = msg + "; " + w + } + return msg +} + func (m *statusManager) progressingMessage() string { m.lock.Lock() defer m.lock.Unlock() diff --git a/pkg/controller/status/status_test.go b/pkg/controller/status/status_test.go index c503e10508..5484e32e0c 100644 --- a/pkg/controller/status/status_test.go +++ b/pkg/controller/status/status_test.go @@ -442,6 +442,48 @@ var _ = Describe("Status reporting tests", func() { Expect(sm.IsProgressing()).To(BeFalse()) }) + It("should include warnings in Available message", func() { + sm.ReadyToMonitor() + Expect(sm.IsAvailable()).To(BeTrue()) + + sm.SetWarning("cert-a", "BYO certificate \"a\" expires in 10 days") + sm.updateStatus() + + stat := &operator.TigeraStatus{} + err := client.Get(context.TODO(), types.NamespacedName{Name: "test-component"}, stat) + Expect(err).NotTo(HaveOccurred()) + for _, c := range stat.Status.Conditions { + if c.Type == operator.ComponentAvailable && c.Status == operator.ConditionTrue { + Expect(c.Message).To(ContainSubstring("All objects available")) + Expect(c.Message).To(ContainSubstring("BYO certificate \"a\" expires in 10 days")) + } + } + }) + + It("should clear warnings from Available message", func() { + sm.ReadyToMonitor() + sm.SetWarning("cert-a", "BYO certificate \"a\" expires in 10 days") + sm.updateStatus() + sm.ClearWarning("cert-a") + sm.updateStatus() + + stat := &operator.TigeraStatus{} + err := client.Get(context.TODO(), types.NamespacedName{Name: "test-component"}, stat) + Expect(err).NotTo(HaveOccurred()) + for _, c := range stat.Status.Conditions { + if c.Type == operator.ComponentAvailable && c.Status == operator.ConditionTrue { + Expect(c.Message).To(Equal("All objects available")) + } + } + }) + + It("should sort multiple warnings deterministically", func() { + sm.ReadyToMonitor() + sm.SetWarning("cert-b", "warning B") + sm.SetWarning("cert-a", "warning A") + Expect(sm.warningMessage()).To(Equal("warning A; warning B")) + }) + It("should prioritize explicit degraded reason over pod failure", func() { Expect(sm.degradedReason()).To(Equal(operator.Unknown)) sm.failing = []string{"This pod has died"} diff --git a/pkg/imports/crds/operator/operator.tigera.io_tigerastatuses.yaml b/pkg/imports/crds/operator/operator.tigera.io_tigerastatuses.yaml index 282b0a1e6c..838626ba16 100644 --- a/pkg/imports/crds/operator/operator.tigera.io_tigerastatuses.yaml +++ b/pkg/imports/crds/operator/operator.tigera.io_tigerastatuses.yaml @@ -30,6 +30,10 @@ spec: jsonPath: .status.conditions[?(@.type=='Available')].lastTransitionTime name: Since type: date + - description: Error message when the component is degraded. + jsonPath: .status.conditions[?(@.type=='Degraded')].message + name: Message + type: string name: v1 schema: openAPIV3Schema: diff --git a/pkg/tls/certificatemanagement/certificatemanagement_suite_test.go b/pkg/tls/certificatemanagement/certificatemanagement_suite_test.go new file mode 100644 index 0000000000..c2cb89f215 --- /dev/null +++ b/pkg/tls/certificatemanagement/certificatemanagement_suite_test.go @@ -0,0 +1,33 @@ +// 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 certificatemanagement_test + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + uzap "go.uber.org/zap" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +func TestCertificateManagement(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/tls_certificatemanagement_suite.xml" + ginkgo.RunSpecs(t, "pkg/tls/certificatemanagement Suite", suiteConfig, reporterConfig) +} diff --git a/pkg/tls/certificatemanagement/interface.go b/pkg/tls/certificatemanagement/interface.go index 9d8b403d6d..903caf47ca 100644 --- a/pkg/tls/certificatemanagement/interface.go +++ b/pkg/tls/certificatemanagement/interface.go @@ -59,6 +59,9 @@ type KeyPairInterface interface { Secret(namespace string) *corev1.Secret HashAnnotationKey() string HashAnnotationValue() string + // Warnings returns a warning message if the certificate requires attention (e.g., a BYO secret + // expiring within 30 days). Returns an empty string if there are no warnings. + Warnings() string CertificateInterface } diff --git a/pkg/tls/certificatemanagement/keypair.go b/pkg/tls/certificatemanagement/keypair.go index dd328bceef..b42b3957d1 100644 --- a/pkg/tls/certificatemanagement/keypair.go +++ b/pkg/tls/certificatemanagement/keypair.go @@ -19,6 +19,9 @@ import ( "encoding/pem" "errors" "fmt" + "net" + "strings" + "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -79,10 +82,21 @@ func (k *KeyPair) Secret(namespace string) *corev1.Secret { } data[corev1.TLSPrivateKeyKey] = k.PrivateKeyPEM data[corev1.TLSCertKey] = k.CertificatePEM + + labels, annotations := tlsSecretMetadata(k.CertificatePEM) + if v := k.HashAnnotationValue(); v != "" { + annotations[k.HashAnnotationKey()] = v + } + return &corev1.Secret{ - TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{Name: k.GetName(), Namespace: namespace}, - Data: data, + TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: k.GetName(), + Namespace: namespace, + Labels: labels, + Annotations: annotations, + }, + Data: data, } } @@ -100,6 +114,22 @@ func (k *KeyPair) HashAnnotationValue() string { return rmeta.AnnotationHash(rmeta.AnnotationHash(k.CertificatePEM)) } +// Warnings returns a warning string if this is a BYO certificate expiring within 30 days. +func (k *KeyPair) Warnings() string { + if !k.BYO() { + return "" + } + cert, err := ParseCertificate(k.CertificatePEM) + if err != nil { + return "" + } + remaining := time.Until(cert.NotAfter) + if remaining <= 30*24*time.Hour { + return fmt.Sprintf("Warning: user provided certificate %q expires in %d days", k.Name, int(remaining.Hours()/24)) + } + return "" +} + func (k *KeyPair) Volume() corev1.Volume { volumeSource := CertificateVolumeSource(k.CertificateManagement, k.GetName()) return corev1.Volume{ @@ -147,6 +177,57 @@ func (k *KeyPair) InitContainer(namespace string, securityContext *corev1.Securi return initContainer } +// tlsSecretMetadata parses certPEM and returns labels and annotations that surface +// certificate metadata on the owning Secret. On parse failure it returns empty maps. +func tlsSecretMetadata(certPEM []byte) (labels map[string]string, annotations map[string]string) { + labels = map[string]string{} + annotations = map[string]string{} + + cert, err := ParseCertificate(certPEM) + if err != nil { + return labels, annotations + } + + // --- labels --- + labels["certificates.operator.tigera.io/signer"] = signerLabelValue(cert.Issuer.CommonName) + + // --- annotations --- + issuerCN := cert.Issuer.CommonName + annotations["certificates.operator.tigera.io/issuer"] = issuerCN + annotations["certificates.operator.tigera.io/signer"] = issuerCN + annotations["certificates.operator.tigera.io/expiry"] = cert.NotAfter.UTC().Format("2006-01-02T15:04:05Z") + + if len(cert.DNSNames) > 0 { + annotations["certificates.operator.tigera.io/dns-names"] = strings.Join(cert.DNSNames, ",") + } + if len(cert.IPAddresses) > 0 { + annotations["certificates.operator.tigera.io/ip-sans"] = strings.Join(ipStrings(cert.IPAddresses), ",") + } + + return labels, annotations +} + +// signerLabelValue returns a Kubernetes-label-safe signer identifier from the +// issuer CN, truncated to 63 chars to satisfy the label value length limit. +func signerLabelValue(cn string) string { + if cn == "" { + return "unknown" + } + if len(cn) > 63 { + return cn[:63] + } + return cn +} + +// ipStrings converts a slice of net.IP to their string representations. +func ipStrings(ips []net.IP) []string { + out := make([]string, len(ips)) + for i, ip := range ips { + out[i] = ip.String() + } + return out +} + func ParseCertificate(certBytes []byte) (*x509.Certificate, error) { pemBlock, _ := pem.Decode(certBytes) if pemBlock == nil { @@ -193,6 +274,26 @@ func GetKeyCertPEM(secret *corev1.Secret) ([]byte, []byte) { return nil, nil } +// WarningReporter is a minimal interface for reporting certificate warnings to a status manager. +type WarningReporter interface { + SetWarning(key string, msg string) + ClearWarning(key string) +} + +// CheckKeyPairWarnings checks each keypair for BYO certificate expiry warnings and reports them +// to the status manager. For nil keypairs or keypairs without warnings, the warning is cleared. +func CheckKeyPairWarnings(keyPairs map[string]KeyPairInterface, status WarningReporter) { + for key, kp := range keyPairs { + if kp != nil { + if w := kp.Warnings(); w != "" { + status.SetWarning(key, w) + continue + } + } + status.ClearWarning(key) + } +} + // NewKeyPair returns a KeyPair, which wraps a Secret object that contains a private key and a certificate. Whether certificate // management is configured or not, KeyPair returns the right InitContainer, Volumemount or Volume (when applicable). func NewKeyPair(secret *corev1.Secret, dnsNames []string, clusterDomain string) KeyPairInterface { diff --git a/pkg/tls/certificatemanagement/keypair_test.go b/pkg/tls/certificatemanagement/keypair_test.go new file mode 100644 index 0000000000..9cbeba8134 --- /dev/null +++ b/pkg/tls/certificatemanagement/keypair_test.go @@ -0,0 +1,177 @@ +// 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 certificatemanagement_test + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/tigera/operator/pkg/tls/certificatemanagement" +) + +var _ = Describe("TLS secret metadata", func() { + Describe("KeyPair.Secret()", func() { + It("should add labels and annotations to the secret from a valid cert", func() { + secret, err := certificatemanagement.CreateSelfSignedSecret("test-secret", "test-ns", "test-cn", []string{"foo.example.com", "bar.example.com"}) + Expect(err).NotTo(HaveOccurred()) + + kp := certificatemanagement.NewKeyPair(secret, []string{"foo.example.com"}, "cluster.local") + s := kp.Secret("test-ns") + + By("verifying labels are present") + Expect(s.Labels).To(HaveKey("certificates.operator.tigera.io/signer")) + + By("verifying annotations are present") + Expect(s.Annotations).To(HaveKey("certificates.operator.tigera.io/issuer")) + Expect(s.Annotations).To(HaveKey("certificates.operator.tigera.io/signer")) + Expect(s.Annotations).To(HaveKey("certificates.operator.tigera.io/expiry")) + Expect(s.Annotations).To(HaveKey("certificates.operator.tigera.io/dns-names")) + + By("verifying the issuer matches the CN we used") + Expect(s.Annotations["certificates.operator.tigera.io/issuer"]).To(Equal("test-cn")) + Expect(s.Annotations["certificates.operator.tigera.io/signer"]).To(Equal("test-cn")) + + By("verifying the DNS names annotation contains our DNS names") + dnsNames := s.Annotations["certificates.operator.tigera.io/dns-names"] + Expect(dnsNames).To(ContainSubstring("foo.example.com")) + Expect(dnsNames).To(ContainSubstring("bar.example.com")) + + By("verifying cert-expiry is in RFC3339 format") + Expect(s.Annotations["certificates.operator.tigera.io/expiry"]).To(MatchRegexp(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$`)) + + By("verifying the hash annotation is present") + Expect(s.Annotations).To(HaveKey(kp.HashAnnotationKey())) + Expect(s.Annotations[kp.HashAnnotationKey()]).To(Equal(kp.HashAnnotationValue())) + }) + + It("should omit cert-ip-sans when there are no IP SANs", func() { + secret, err := certificatemanagement.CreateSelfSignedSecret("test-secret", "test-ns", "test-cn", []string{"foo.example.com"}) + Expect(err).NotTo(HaveOccurred()) + + kp := certificatemanagement.NewKeyPair(secret, []string{"foo.example.com"}, "cluster.local") + s := kp.Secret("test-ns") + + Expect(s.Annotations).NotTo(HaveKey("certificates.operator.tigera.io/ip-sans")) + }) + + It("should only have hash annotation when cert PEM is empty", func() { + kp := &certificatemanagement.KeyPair{ + Name: "empty-cert", + PrivateKeyPEM: []byte("fake-key"), + CertificatePEM: []byte{}, + } + s := kp.Secret("test-ns") + + Expect(s.Labels).To(BeEmpty()) + Expect(s.Annotations).To(HaveLen(1)) + Expect(s.Annotations).To(HaveKey(kp.HashAnnotationKey())) + }) + }) + + Describe("KeyPair.Warnings()", func() { + createCertPEM := func(notAfter time.Time) []byte { + key, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: notAfter, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + Expect(err).NotTo(HaveOccurred()) + var buf bytes.Buffer + Expect(pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der})).NotTo(HaveOccurred()) + return buf.Bytes() + } + + It("should return a warning for a BYO cert expiring within 30 days", func() { + kp := &certificatemanagement.KeyPair{ + Name: "my-tls-secret", + CertificatePEM: createCertPEM(time.Now().Add(10 * 24 * time.Hour)), + } + Expect(kp.BYO()).To(BeTrue()) + warning := kp.Warnings() + Expect(warning).To(ContainSubstring("user provided certificate")) + Expect(warning).To(ContainSubstring("my-tls-secret")) + Expect(warning).To(ContainSubstring("expires in")) + }) + + It("should return empty for a BYO cert expiring in more than 30 days", func() { + kp := &certificatemanagement.KeyPair{ + Name: "my-tls-secret", + CertificatePEM: createCertPEM(time.Now().Add(60 * 24 * time.Hour)), + } + Expect(kp.BYO()).To(BeTrue()) + Expect(kp.Warnings()).To(BeEmpty()) + }) + + It("should return empty for a non-BYO cert even if expiring soon", func() { + secret, err := certificatemanagement.CreateSelfSignedSecret("test-secret", "test-ns", "test-cn", nil) + Expect(err).NotTo(HaveOccurred()) + // NewKeyPair creates a BYO keypair (no issuer, no cert management) + // but a KeyPair with an Issuer is not BYO. + issuer := certificatemanagement.NewKeyPair(secret, nil, "cluster.local") + kp := &certificatemanagement.KeyPair{ + Name: "managed-secret", + CertificatePEM: createCertPEM(time.Now().Add(10 * 24 * time.Hour)), + Issuer: issuer, + } + Expect(kp.BYO()).To(BeFalse()) + Expect(kp.Warnings()).To(BeEmpty()) + }) + }) + + Describe("CreateSelfSignedSecret()", func() { + It("should include labels and annotations on the created secret", func() { + secret, err := certificatemanagement.CreateSelfSignedSecret("my-secret", "my-ns", "my-issuer@1234567890", []string{"dns1.example.com"}) + Expect(err).NotTo(HaveOccurred()) + + By("verifying labels") + Expect(secret.Labels).To(HaveKeyWithValue("certificates.operator.tigera.io/signer", "my-issuer@1234567890")) + + By("verifying annotations") + Expect(secret.Annotations["certificates.operator.tigera.io/issuer"]).To(Equal("my-issuer@1234567890")) + Expect(secret.Annotations["certificates.operator.tigera.io/signer"]).To(Equal("my-issuer@1234567890")) + Expect(secret.Annotations).To(HaveKey("certificates.operator.tigera.io/expiry")) + Expect(secret.Annotations["certificates.operator.tigera.io/dns-names"]).To(Equal("dns1.example.com")) + }) + + It("should handle the signer label with no @ in the CN", func() { + secret, err := certificatemanagement.CreateSelfSignedSecret("my-secret", "my-ns", "simple-signer", nil) + Expect(err).NotTo(HaveOccurred()) + + Expect(secret.Labels["certificates.operator.tigera.io/signer"]).To(Equal("simple-signer")) + }) + + It("should truncate the signer label to 63 characters", func() { + longCN := strings.Repeat("a", 100) + secret, err := certificatemanagement.CreateSelfSignedSecret("my-secret", "my-ns", longCN, nil) + Expect(err).NotTo(HaveOccurred()) + + Expect(len(secret.Labels["certificates.operator.tigera.io/signer"])).To(Equal(63)) + }) + }) +}) diff --git a/pkg/tls/certificatemanagement/tls.go b/pkg/tls/certificatemanagement/tls.go index d3ff6ecd6c..61a0023c3e 100644 --- a/pkg/tls/certificatemanagement/tls.go +++ b/pkg/tls/certificatemanagement/tls.go @@ -60,11 +60,15 @@ func CreateSelfSignedSecret(secretName, namespace, cn string, altNames []string) if err := pem.Encode(&certPem, &pem.Block{Type: blockTypeCert, Bytes: cert}); err != nil { panic(err) } + labels, annotations := tlsSecretMetadata(certPem.Bytes()) + return &corev1.Secret{ TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespace, + Name: secretName, + Namespace: namespace, + Labels: labels, + Annotations: annotations, }, Data: map[string][]byte{ corev1.TLSCertKey: certPem.Bytes(),