diff --git a/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go b/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go index e07befef6..c4035c020 100644 --- a/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go +++ b/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go @@ -308,6 +308,21 @@ type TelemetryConfig struct { // Logging configuration for the telemetry log ingestion path. Logging TelemetryLoggingConfig `json:"logging,omitempty"` + + // gRPC configuration for the telemetry service. + // Use this to configure TLS when not using cert-manager. + GRPC TelemetryGRPCConfig `json:"grpc,omitempty"` +} + +// TelemetryGRPCConfig defines gRPC configuration for the telemetry service. +// This is a simplified version of GRPCConfig since telemetry is internal-only +// (ClusterIP) and doesn't need external endpoints or keepalive settings. +type TelemetryGRPCConfig struct { + // TLS configuration for secure gRPC communication with the telemetry service. + // When spec.certManager.enabled is true, this is ignored and certificates are + // automatically managed by cert-manager. + // When spec.certManager.enabled is false, you can provide your own TLS secret here. + TLS TLSConfig `json:"tls,omitempty"` } // TelemetryLoggingConfig configures the log push path to the telemetry service. diff --git a/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go b/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go index b542068db..56f9f7d77 100644 --- a/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -901,6 +901,7 @@ func (in *TelemetryConfig) DeepCopyInto(out *TelemetryConfig) { } in.Resources.DeepCopyInto(&out.Resources) out.Logging = in.Logging + out.GRPC = in.GRPC } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TelemetryConfig. @@ -913,6 +914,22 @@ func (in *TelemetryConfig) DeepCopy() *TelemetryConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TelemetryGRPCConfig) DeepCopyInto(out *TelemetryGRPCConfig) { + *out = *in + out.TLS = in.TLS +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TelemetryGRPCConfig. +func (in *TelemetryGRPCConfig) DeepCopy() *TelemetryGRPCConfig { + if in == nil { + return nil + } + out := new(TelemetryGRPCConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TelemetryLoggingConfig) DeepCopyInto(out *TelemetryLoggingConfig) { *out = *in diff --git a/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml b/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml index 5dc9cf8d4..4bfcab3df 100644 --- a/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml +++ b/controller/deploy/operator/config/crd/bases/operator.jumpstarter.dev_jumpstarters.yaml @@ -2104,6 +2104,28 @@ spec: When enabled, the operator deploys a jumpstarter-telemetry pod and a ClusterIP Service, and configures the controller to advertise the endpoint to exporters. type: boolean + grpc: + description: |- + gRPC configuration for the telemetry service. + Use this to configure TLS when not using cert-manager. + properties: + tls: + description: |- + TLS configuration for secure gRPC communication with the telemetry service. + When spec.certManager.enabled is true, this is ignored and certificates are + automatically managed by cert-manager. + When spec.certManager.enabled is false, you can provide your own TLS secret here. + properties: + certSecret: + description: |- + Name of the Kubernetes secret containing the TLS certificate and private key. + The secret must contain 'tls.crt' and 'tls.key' keys. + If spec.certManager.enabled is true, this secret will be automatically managed and + configured by cert-manager. + pattern: ^[a-z0-9]([a-z0-9\-\.]*[a-z0-9])?$ + type: string + type: object + type: object image: default: quay.io/jumpstarter-dev/jumpstarter-telemetry:latest description: Container image for the telemetry pod in 'registry/repository/image:tag' diff --git a/controller/deploy/operator/internal/controller/jumpstarter/certificates.go b/controller/deploy/operator/internal/controller/jumpstarter/certificates.go index 47c0c6d78..d86d14889 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/certificates.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/certificates.go @@ -378,7 +378,7 @@ func (r *JumpstarterReconciler) reconcileRouterCertificate(ctx context.Context, // reconcileTelemetryCertificate creates the TLS certificate for the telemetry service. func (r *JumpstarterReconciler) reconcileTelemetryCertificate(ctx context.Context, js *operatorv1alpha1.Jumpstarter, issuerRef cmmeta.ObjectReference) error { - certName := getTelemetryCertSecretName(js) + certName := GetTelemetryCertSecretName(js) includeInternalNames := !isExternalIssuer(js) dnsNames := r.collectTelemetryDNSNames(js, includeInternalNames) return r.reconcileServerCertificate(ctx, js, issuerRef, certName, "telemetry", dnsNames, nil) diff --git a/controller/deploy/operator/internal/controller/jumpstarter/hash_annotations_test.go b/controller/deploy/operator/internal/controller/jumpstarter/hash_annotations_test.go index ce391cb3a..535a902e9 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/hash_annotations_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/hash_annotations_test.go @@ -429,3 +429,166 @@ var _ = Describe("getRouterTLSSecretHash", func() { Expect(hashMissing).To(BeEmpty()) }) }) + +var _ = Describe("getTelemetryTLSSecretHash", func() { + var r *JumpstarterReconciler + + BeforeEach(func() { + r = &JumpstarterReconciler{Client: k8sClient} + }) + + It("should return empty hash when no TLS is configured", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: false}, + Telemetry: &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + // No TLS.CertSecret configured + }, + }, + } + hash, err := r.getTelemetryTLSSecretHash(ctx, js) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(BeEmpty()) + }) + + It("should return empty hash when cert-manager secret does not exist yet", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-telemetry-hash", + Namespace: "default", + }, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: true}, + }, + } + hash, err := r.getTelemetryTLSSecretHash(ctx, js) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(BeEmpty()) + }) + + It("should return hash when cert-manager telemetry TLS secret exists", func() { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-telemetry-tls-telemetry-tls", + Namespace: "default", + }, + Data: map[string][]byte{ + "tls.crt": []byte("telemetry-cert"), + "tls.key": []byte("telemetry-key"), + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + defer func() { + Expect(k8sClient.Delete(ctx, secret)).To(Succeed()) + }() + + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-telemetry-tls", + Namespace: "default", + }, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: true}, + }, + } + hash, err := r.getTelemetryTLSSecretHash(ctx, js) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(HaveLen(64)) + }) + + It("should return hash when manual telemetry TLS secret exists", func() { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-manual-telemetry-tls", + Namespace: "default", + }, + Data: map[string][]byte{ + "tls.crt": []byte("manual-telemetry-cert"), + "tls.key": []byte("manual-telemetry-key"), + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + defer func() { + Expect(k8sClient.Delete(ctx, secret)).To(Succeed()) + }() + + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-manual-tls", + Namespace: "default", + }, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: false}, + Telemetry: &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + GRPC: operatorv1alpha1.TelemetryGRPCConfig{ + TLS: operatorv1alpha1.TLSConfig{ + CertSecret: "my-manual-telemetry-tls", + }, + }, + }, + }, + } + hash, err := r.getTelemetryTLSSecretHash(ctx, js) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(HaveLen(64)) + }) +}) + +var _ = Describe("telemetryTLSSecretName", func() { + It("returns cert-manager secret name when cert-manager is enabled", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "my-js"}, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: true}, + }, + } + Expect(telemetryTLSSecretName(js)).To(Equal("my-js-telemetry-tls")) + }) + + It("returns manual CertSecret when cert-manager is disabled", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "my-js"}, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: false}, + Telemetry: &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + GRPC: operatorv1alpha1.TelemetryGRPCConfig{ + TLS: operatorv1alpha1.TLSConfig{ + CertSecret: "custom-tls-secret", + }, + }, + }, + }, + } + Expect(telemetryTLSSecretName(js)).To(Equal("custom-tls-secret")) + }) + + It("returns empty string when no TLS is configured", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "my-js"}, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: false}, + Telemetry: &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + }, + }, + } + Expect(telemetryTLSSecretName(js)).To(BeEmpty()) + }) + + It("returns empty string when telemetry is nil", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "my-js"}, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: false}, + }, + } + Expect(telemetryTLSSecretName(js)).To(BeEmpty()) + }) +}) diff --git a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go index db92b2943..2009d920d 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go @@ -246,8 +246,13 @@ func (r *JumpstarterReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, err } - // Requeue after 30 minutes to check for changes - return ctrl.Result{RequeueAfter: 30 * time.Minute}, nil + // Requeue periodically to pick up changes. Use a shorter interval while the + // telemetry CA secret is not yet ready so the controller ConfigMap converges quickly. + requeueAfter := 30 * time.Minute + if r.telemetryCANeedsRequeue(ctx, &jumpstarter) { + requeueAfter = telemetryCARequeueInterval + } + return ctrl.Result{RequeueAfter: requeueAfter}, nil } // emitEventf emits a Kubernetes event on the Jumpstarter object. @@ -1305,7 +1310,6 @@ func (r *JumpstarterReconciler) buildConfig(ctx context.Context, jumpstarter *op } // Telemetry configuration. - // Certificate is intentionally omitted until the telemetry binary supports TLS serving. if jumpstarter.Spec.Telemetry != nil && jumpstarter.Spec.Telemetry.Enabled { t := jumpstarter.Spec.Telemetry telemetryCfg := &config.Telemetry{ @@ -1315,6 +1319,19 @@ func (r *JumpstarterReconciler) buildConfig(ctx context.Context, jumpstarter *op if t.Logging.Filter.MinSeverity != "" { telemetryCfg.Logging.Filter.MinSeverity = t.Logging.Filter.MinSeverity } + // Include CA certificate when cert-manager is enabled so exporters can verify TLS + if jumpstarter.Spec.CertManager.Enabled { + caCert, err := r.resolveTelemetryCA(ctx, jumpstarter) + if err != nil { + // Log at default verbosity so operators notice during initial cert-manager setup. + // Reconciliation continues without a certificate; telemetryCANeedsRequeue + // triggers a short requeue until the CA secret is ready. + logf.FromContext(ctx).Info("Could not resolve telemetry CA certificate; exporters cannot verify telemetry TLS until the CA is available", + "error", err) + } else if caCert != "" { + telemetryCfg.Certificate = caCert + } + } cfg.Telemetry = telemetryCfg } @@ -1728,6 +1745,15 @@ func (r *JumpstarterReconciler) SetupWithManager(mgr ctrl.Manager) error { keys = append(keys, jumpstarter.Namespace+"/"+s) } + // Telemetry TLS cert secret + if jumpstarter.Spec.Telemetry != nil && jumpstarter.Spec.Telemetry.Enabled { + if jumpstarter.Spec.CertManager.Enabled { + keys = append(keys, jumpstarter.Namespace+"/"+GetTelemetryCertSecretName(jumpstarter)) + } else if s := jumpstarter.Spec.Telemetry.GRPC.TLS.CertSecret; s != "" { + keys = append(keys, jumpstarter.Namespace+"/"+s) + } + } + return keys }, ); err != nil { diff --git a/controller/deploy/operator/internal/controller/jumpstarter/telemetry.go b/controller/deploy/operator/internal/controller/jumpstarter/telemetry.go index c1dbcc532..c94945b17 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/telemetry.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/telemetry.go @@ -19,6 +19,7 @@ package jumpstarter import ( "context" "fmt" + "time" certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" appsv1 "k8s.io/api/apps/v1" @@ -37,12 +38,13 @@ import ( ) const ( - telemetryPort = 9093 - telemetryCertSuffix = "-telemetry-tls" - telemetryServiceName = "jumpstarter-telemetry" - telemetryComponentApp = "jumpstarter-telemetry" - telemetrySASuffix = "-telemetry" - grpcPortName = "grpc" + telemetryPort = 9093 + telemetryCertSuffix = "-telemetry-tls" + telemetryServiceName = "jumpstarter-telemetry" + telemetryComponentApp = "jumpstarter-telemetry" + telemetrySASuffix = "-telemetry" + grpcPortName = "grpc" + telemetryCARequeueInterval = 30 * time.Second ) // reconcileTelemetryDeploymentStage reconciles only the telemetry Deployment (and cleanup). @@ -127,10 +129,38 @@ func (r *JumpstarterReconciler) cleanupTelemetryService(ctx context.Context, jum return nil } +// telemetryTLSSecretName returns the TLS secret name for telemetry. +// Returns the cert-manager managed secret if enabled, otherwise the manual CertSecret. +// An empty string means no TLS secret is configured. +func telemetryTLSSecretName(jumpstarter *operatorv1alpha1.Jumpstarter) string { + if jumpstarter.Spec.CertManager.Enabled { + return GetTelemetryCertSecretName(jumpstarter) + } + if jumpstarter.Spec.Telemetry != nil { + return jumpstarter.Spec.Telemetry.GRPC.TLS.CertSecret + } + return "" +} + +// getTelemetryTLSSecretHash resolves the telemetry TLS secret name and returns its data hash. +// This ensures that when cert-manager renews a certificate (or a manual secret changes), +// the hash changes and triggers a rolling restart so the pod picks up the new cert. +func (r *JumpstarterReconciler) getTelemetryTLSSecretHash(ctx context.Context, jumpstarter *operatorv1alpha1.Jumpstarter) (string, error) { + tlsSecretName := telemetryTLSSecretName(jumpstarter) + return r.getTLSSecretHash(ctx, jumpstarter.Namespace, tlsSecretName) +} + // reconcileTelemetryDeployment creates or updates the telemetry Deployment. func (r *JumpstarterReconciler) reconcileTelemetryDeployment(ctx context.Context, jumpstarter *operatorv1alpha1.Jumpstarter) error { log := logf.FromContext(ctx) - desiredDeployment := createTelemetryDeployment(jumpstarter) + + tlsSecretHash, err := r.getTelemetryTLSSecretHash(ctx, jumpstarter) + if err != nil { + log.Error(err, "Failed to compute telemetry TLS secret hash") + return err + } + + desiredDeployment := createTelemetryDeployment(jumpstarter, tlsSecretHash) existingDeployment := &appsv1.Deployment{} existingDeployment.Name = desiredDeployment.Name @@ -264,7 +294,8 @@ func (r *JumpstarterReconciler) reconcileTelemetryService(ctx context.Context, j } // createTelemetryDeployment builds the desired Deployment for the telemetry service. -func createTelemetryDeployment(jumpstarter *operatorv1alpha1.Jumpstarter) *appsv1.Deployment { +// tlsSecretHash is included as a pod annotation to trigger rolling restarts on cert renewal. +func createTelemetryDeployment(jumpstarter *operatorv1alpha1.Jumpstarter, tlsSecretHash string) *appsv1.Deployment { t := jumpstarter.Spec.Telemetry labels := telemetryLabels(jumpstarter) @@ -273,6 +304,57 @@ func createTelemetryDeployment(jumpstarter *operatorv1alpha1.Jumpstarter) *appsv replicas = *t.Replicas } + // Build pod annotations for TLS hash (triggers rolling restart on cert renewal) + var podAnnotations map[string]string + if tlsSecretHash != "" { + podAnnotations = map[string]string{ + "jumpstarter.dev/tls-secret-sha256": tlsSecretHash, + } + } + + // Base environment variables - CONTROLLER_KEY is always required for token validation + envVars := []corev1.EnvVar{ + { + Name: "CONTROLLER_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "jumpstarter-controller-secret", + }, + Key: "key", + }, + }, + }, + } + + var volumeMounts []corev1.VolumeMount + var volumes []corev1.Volume + + // Add TLS certificate mount when TLS is configured (cert-manager or manual) + tlsSecretName := telemetryTLSSecretName(jumpstarter) + if tlsSecretName != "" { + envVars = append(envVars, + corev1.EnvVar{Name: "EXTERNAL_CERT_PEM", Value: "/tls/tls.crt"}, + corev1.EnvVar{Name: "EXTERNAL_KEY_PEM", Value: "/tls/tls.key"}, + ) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: "tls-certs", + MountPath: "/tls", + ReadOnly: true, + }) + // Set DefaultMode explicitly to avoid reconciliation loop + defaultMode := int32(420) + volumes = append(volumes, corev1.Volume{ + Name: "tls-certs", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: tlsSecretName, + DefaultMode: &defaultMode, + }, + }, + }) + } + return &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("%s-telemetry", jumpstarter.Name), @@ -295,7 +377,8 @@ func createTelemetryDeployment(jumpstarter *operatorv1alpha1.Jumpstarter) *appsv }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ - Labels: labels, + Labels: labels, + Annotations: podAnnotations, }, Spec: corev1.PodSpec{ RestartPolicy: corev1.RestartPolicyAlways, @@ -310,19 +393,8 @@ func createTelemetryDeployment(jumpstarter *operatorv1alpha1.Jumpstarter) *appsv Args: []string{ fmt.Sprintf("--grpc-bind=:%d", telemetryPort), }, - Env: []corev1.EnvVar{ - { - Name: "CONTROLLER_KEY", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "jumpstarter-controller-secret", - }, - Key: "key", - }, - }, - }, - }, + Env: envVars, + VolumeMounts: volumeMounts, Ports: []corev1.ContainerPort{ { ContainerPort: int32(telemetryPort), @@ -365,6 +437,7 @@ func createTelemetryDeployment(jumpstarter *operatorv1alpha1.Jumpstarter) *appsv }, }, }, + Volumes: volumes, SecurityContext: &corev1.PodSecurityContext{ RunAsNonRoot: ptr.To(true), SeccompProfile: &corev1.SeccompProfile{ @@ -396,7 +469,7 @@ func (r *JumpstarterReconciler) cleanupTelemetry(ctx context.Context, jumpstarte "Telemetry deployment deleted: name=%s", deploymentName) } - certName := getTelemetryCertSecretName(jumpstarter) + certName := GetTelemetryCertSecretName(jumpstarter) cert := &certmanagerv1.Certificate{} cert.Name = certName cert.Namespace = jumpstarter.Namespace @@ -419,8 +492,8 @@ func (r *JumpstarterReconciler) cleanupTelemetry(ctx context.Context, jumpstarte return nil } -// getTelemetryCertSecretName returns the name of the telemetry TLS secret. -func getTelemetryCertSecretName(js *operatorv1alpha1.Jumpstarter) string { +// GetTelemetryCertSecretName returns the name of the telemetry TLS secret. +func GetTelemetryCertSecretName(js *operatorv1alpha1.Jumpstarter) string { return js.Name + telemetryCertSuffix } @@ -447,6 +520,21 @@ func (r *JumpstarterReconciler) resolveTelemetryCA(ctx context.Context, jumpstar return "", fmt.Errorf("CA secret %s missing tls.crt", caSecretName) } +// telemetryCANeedsRequeue reports whether reconciliation should be requeued soon +// because the telemetry CA certificate is not yet available for the controller +// ConfigMap. External issuers without a CABundle intentionally have no inlined CA +// (exporters use the system trust store), so they never need a short requeue. +func (r *JumpstarterReconciler) telemetryCANeedsRequeue(ctx context.Context, jumpstarter *operatorv1alpha1.Jumpstarter) bool { + if jumpstarter.Spec.Telemetry == nil || !jumpstarter.Spec.Telemetry.Enabled { + return false + } + if !jumpstarter.Spec.CertManager.Enabled || isExternalIssuer(jumpstarter) { + return false + } + caCert, err := r.resolveTelemetryCA(ctx, jumpstarter) + return err != nil || caCert == "" +} + // telemetryEndpointFor returns the in-cluster gRPC endpoint for the telemetry service. func telemetryEndpointFor(namespace string) string { return fmt.Sprintf("%s.%s.svc:%d", telemetryServiceName, namespace, telemetryPort) diff --git a/controller/deploy/operator/internal/controller/jumpstarter/telemetry_test.go b/controller/deploy/operator/internal/controller/jumpstarter/telemetry_test.go index 66e86d113..77b2d2c25 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/telemetry_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/telemetry_test.go @@ -341,6 +341,25 @@ var _ = Describe("Telemetry Lifecycle", func() { Expect(configData).To(ContainSubstring("warning")) }) + It("does not include telemetry certificate in ConfigMap when cert-manager is disabled", func() { + By("creating a Jumpstarter CR with telemetry enabled but cert-manager disabled") + spec := makeJumpstarterSpec() + spec.Telemetry = &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + Image: "quay.io/jumpstarter-dev/jumpstarter-telemetry:latest", + } + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + configData := getConfigData() + Expect(configData).To(ContainSubstring("telemetry")) + Expect(configData).NotTo(ContainSubstring("certificate:")) + }) + It("does not include telemetry in ConfigMap when disabled", func() { By("creating a Jumpstarter CR without telemetry") spec := makeJumpstarterSpec() @@ -402,10 +421,7 @@ var _ = Describe("Telemetry Lifecycle", func() { Expect(cond.Reason).To(Equal("DeploymentAvailable")) }) - It("does not mount TLS certs even when cert-manager is enabled (TLS serving not yet supported by the binary)", func() { - // EXTERNAL_CERT_PEM/EXTERNAL_KEY_PEM and the tls-certs volume are intentionally - // omitted until the telemetry binary is updated to serve TLS. - // CONTROLLER_KEY is always set for token validation (not TLS-related). + It("mounts TLS certs when cert-manager is enabled", func() { js := &operatorv1alpha1.Jumpstarter{ ObjectMeta: metav1.ObjectMeta{Name: "test-tls", Namespace: "default"}, Spec: operatorv1alpha1.JumpstarterSpec{ @@ -418,19 +434,110 @@ var _ = Describe("Telemetry Lifecycle", func() { }, } - dep := createTelemetryDeployment(js) + dep := createTelemetryDeployment(js, "fake-tls-hash") container := dep.Spec.Template.Spec.Containers[0] - // CONTROLLER_KEY should be set for token validation - Expect(container.Env).To(HaveLen(1)) - Expect(container.Env[0].Name).To(Equal("CONTROLLER_KEY")) - // TLS-related env vars should NOT be set + + // Should have CONTROLLER_KEY + TLS env vars + Expect(container.Env).To(HaveLen(3)) + envNames := make(map[string]string) + for _, env := range container.Env { + envNames[env.Name] = env.Value + } + Expect(envNames).To(HaveKey("CONTROLLER_KEY")) + Expect(envNames).To(HaveKeyWithValue("EXTERNAL_CERT_PEM", "/tls/tls.crt")) + Expect(envNames).To(HaveKeyWithValue("EXTERNAL_KEY_PEM", "/tls/tls.key")) + + // Should have TLS volume mount + Expect(container.VolumeMounts).To(HaveLen(1)) + Expect(container.VolumeMounts[0].Name).To(Equal("tls-certs")) + Expect(container.VolumeMounts[0].MountPath).To(Equal("/tls")) + Expect(container.VolumeMounts[0].ReadOnly).To(BeTrue()) + + // Should have TLS volume + Expect(dep.Spec.Template.Spec.Volumes).To(HaveLen(1)) + Expect(dep.Spec.Template.Spec.Volumes[0].Name).To(Equal("tls-certs")) + Expect(dep.Spec.Template.Spec.Volumes[0].Secret.SecretName).To(Equal("test-tls-telemetry-tls")) + + // Should have TLS hash annotation for rolling restart on cert renewal + Expect(dep.Spec.Template.Annotations).To(HaveKeyWithValue("jumpstarter.dev/tls-secret-sha256", "fake-tls-hash")) + }) + + It("mounts TLS certs with manual CertSecret when cert-manager is disabled", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "test-manual-tls", Namespace: "default"}, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: false}, + Telemetry: &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + Image: "quay.io/jumpstarter-dev/jumpstarter-telemetry:latest", + ImagePullPolicy: corev1.PullIfNotPresent, + GRPC: operatorv1alpha1.TelemetryGRPCConfig{ + TLS: operatorv1alpha1.TLSConfig{ + CertSecret: "my-custom-telemetry-tls", + }, + }, + }, + }, + } + + dep := createTelemetryDeployment(js, "manual-tls-hash") + + container := dep.Spec.Template.Spec.Containers[0] + + // Should have CONTROLLER_KEY + TLS env vars + Expect(container.Env).To(HaveLen(3)) + envNames := make(map[string]string) for _, env := range container.Env { - Expect(env.Name).NotTo(Equal("EXTERNAL_CERT_PEM")) - Expect(env.Name).NotTo(Equal("EXTERNAL_KEY_PEM")) + envNames[env.Name] = env.Value } - Expect(container.VolumeMounts).To(BeNil()) - Expect(dep.Spec.Template.Spec.Volumes).To(BeNil()) + Expect(envNames).To(HaveKey("CONTROLLER_KEY")) + Expect(envNames).To(HaveKeyWithValue("EXTERNAL_CERT_PEM", "/tls/tls.crt")) + Expect(envNames).To(HaveKeyWithValue("EXTERNAL_KEY_PEM", "/tls/tls.key")) + + // Should have TLS volume mount + Expect(container.VolumeMounts).To(HaveLen(1)) + Expect(container.VolumeMounts[0].Name).To(Equal("tls-certs")) + Expect(container.VolumeMounts[0].MountPath).To(Equal("/tls")) + + // Should have TLS volume with manual secret name + Expect(dep.Spec.Template.Spec.Volumes).To(HaveLen(1)) + Expect(dep.Spec.Template.Spec.Volumes[0].Name).To(Equal("tls-certs")) + Expect(dep.Spec.Template.Spec.Volumes[0].Secret.SecretName).To(Equal("my-custom-telemetry-tls")) + + // Should have TLS hash annotation + Expect(dep.Spec.Template.Annotations).To(HaveKeyWithValue("jumpstarter.dev/tls-secret-sha256", "manual-tls-hash")) + }) + + It("does not mount TLS certs when no TLS is configured", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "test-no-tls", Namespace: "default"}, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: false}, + Telemetry: &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + Image: "quay.io/jumpstarter-dev/jumpstarter-telemetry:latest", + ImagePullPolicy: corev1.PullIfNotPresent, + // No TLS.CertSecret configured + }, + }, + } + + // Empty hash when no TLS is configured + dep := createTelemetryDeployment(js, "") + + container := dep.Spec.Template.Spec.Containers[0] + + // Only CONTROLLER_KEY should be set (no TLS env vars) + Expect(container.Env).To(HaveLen(1)) + Expect(container.Env[0].Name).To(Equal("CONTROLLER_KEY")) + + // No volume mounts or volumes + Expect(container.VolumeMounts).To(BeEmpty()) + Expect(dep.Spec.Template.Spec.Volumes).To(BeEmpty()) + + // No TLS hash annotation when no TLS is configured + Expect(dep.Spec.Template.Annotations).To(BeNil()) }) It("uses a dedicated service account separate from the controller", func() { @@ -524,12 +631,12 @@ var _ = Describe("telemetryLabels", func() { }) }) -var _ = Describe("getTelemetryCertSecretName", func() { +var _ = Describe("GetTelemetryCertSecretName", func() { It("returns the correct secret name", func() { js := &operatorv1alpha1.Jumpstarter{ ObjectMeta: metav1.ObjectMeta{Name: "jumpstarter"}, } - Expect(getTelemetryCertSecretName(js)).To(Equal("jumpstarter-telemetry-tls")) + Expect(GetTelemetryCertSecretName(js)).To(Equal("jumpstarter-telemetry-tls")) }) }) @@ -750,3 +857,212 @@ var _ = Describe("resolveTelemetryCA", func() { Expect(ca).To(BeEmpty()) }) }) + +var _ = Describe("buildConfig telemetry certificate", func() { + var crNamespace string + ctx := context.Background() + + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "config-test-"}} + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + crNamespace = ns.Name + }) + + AfterEach(func() { + _ = k8sClient.Delete(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: crNamespace}, + }) + }) + + It("includes certificate in telemetry config when cert-manager is enabled and CA exists", func() { + By("creating the CA secret") + caSecretName := "test-cfg" + caCertificateSuffix + Expect(k8sClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: caSecretName, Namespace: crNamespace}, + Data: map[string][]byte{"tls.crt": []byte(testPEM)}, + })).To(Succeed()) + + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cfg", Namespace: crNamespace}, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: true}, + Telemetry: &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + Image: "quay.io/jumpstarter-dev/jumpstarter-telemetry:latest", + }, + }, + } + + r := &JumpstarterReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + cfg, err := r.buildConfig(ctx, js) + Expect(err).NotTo(HaveOccurred()) + + Expect(cfg.Telemetry).NotTo(BeNil()) + Expect(cfg.Telemetry.Enabled).To(BeTrue()) + Expect(cfg.Telemetry.Certificate).To(ContainSubstring("BEGIN CERTIFICATE")) + }) + + It("includes CABundle from external issuer in telemetry config", func() { + externalCABundle := "-----BEGIN CERTIFICATE-----\nEXTERNAL-CA-BUNDLE\n-----END CERTIFICATE-----" + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cfg-external", Namespace: crNamespace}, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{ + Enabled: true, + Server: &operatorv1alpha1.ServerCertConfig{ + IssuerRef: &operatorv1alpha1.IssuerReference{ + Name: "my-external-issuer", + Kind: "ClusterIssuer", + CABundle: []byte(externalCABundle), + }, + }, + }, + Telemetry: &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + Image: "quay.io/jumpstarter-dev/jumpstarter-telemetry:latest", + }, + }, + } + + r := &JumpstarterReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + cfg, err := r.buildConfig(ctx, js) + Expect(err).NotTo(HaveOccurred()) + + Expect(cfg.Telemetry).NotTo(BeNil()) + Expect(cfg.Telemetry.Enabled).To(BeTrue()) + Expect(cfg.Telemetry.Certificate).To(Equal(externalCABundle)) + }) + + It("does not include certificate when cert-manager is disabled", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cfg-no-tls", Namespace: crNamespace}, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: false}, + Telemetry: &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + Image: "quay.io/jumpstarter-dev/jumpstarter-telemetry:latest", + }, + }, + } + + r := &JumpstarterReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + cfg, err := r.buildConfig(ctx, js) + Expect(err).NotTo(HaveOccurred()) + + Expect(cfg.Telemetry).NotTo(BeNil()) + Expect(cfg.Telemetry.Enabled).To(BeTrue()) + Expect(cfg.Telemetry.Certificate).To(BeEmpty()) + }) + + It("does not fail when CA secret is missing (logs at default verbosity instead)", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cfg-missing-ca", Namespace: crNamespace}, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: true}, + Telemetry: &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + Image: "quay.io/jumpstarter-dev/jumpstarter-telemetry:latest", + }, + }, + } + + r := &JumpstarterReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + cfg, err := r.buildConfig(ctx, js) + // Should not fail - logs at default verbosity but continues + Expect(err).NotTo(HaveOccurred()) + + Expect(cfg.Telemetry).NotTo(BeNil()) + Expect(cfg.Telemetry.Enabled).To(BeTrue()) + // Certificate is empty because CA secret doesn't exist + Expect(cfg.Telemetry.Certificate).To(BeEmpty()) + }) +}) + +var _ = Describe("telemetryCANeedsRequeue", func() { + var crNamespace string + ctx := context.Background() + + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "tel-requeue-test-"}} + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + crNamespace = ns.Name + }) + + AfterEach(func() { + _ = k8sClient.Delete(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: crNamespace}, + }) + }) + + telemetryEnabledSpec := func() operatorv1alpha1.JumpstarterSpec { + return operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: true}, + Telemetry: &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + Image: "quay.io/jumpstarter-dev/jumpstarter-telemetry:latest", + }, + } + } + + It("returns false when telemetry is disabled", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "no-telemetry", Namespace: crNamespace}, + Spec: operatorv1alpha1.JumpstarterSpec{ + CertManager: operatorv1alpha1.CertManagerConfig{Enabled: true}, + }, + } + r := &JumpstarterReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + Expect(r.telemetryCANeedsRequeue(ctx, js)).To(BeFalse()) + }) + + It("returns false when cert-manager is disabled", func() { + spec := telemetryEnabledSpec() + spec.CertManager.Enabled = false + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "no-cm", Namespace: crNamespace}, + Spec: spec, + } + r := &JumpstarterReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + Expect(r.telemetryCANeedsRequeue(ctx, js)).To(BeFalse()) + }) + + It("returns false for external issuer without CABundle", func() { + spec := telemetryEnabledSpec() + spec.CertManager.Server = &operatorv1alpha1.ServerCertConfig{ + IssuerRef: &operatorv1alpha1.IssuerReference{ + Name: "letsencrypt-prod", + Kind: "ClusterIssuer", + }, + } + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "external-issuer", Namespace: crNamespace}, + Spec: spec, + } + r := &JumpstarterReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + Expect(r.telemetryCANeedsRequeue(ctx, js)).To(BeFalse()) + }) + + It("returns true when self-signed CA secret is missing", func() { + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "missing-ca", Namespace: crNamespace}, + Spec: telemetryEnabledSpec(), + } + r := &JumpstarterReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + Expect(r.telemetryCANeedsRequeue(ctx, js)).To(BeTrue()) + }) + + It("returns false when self-signed CA secret exists", func() { + caSecretName := "ready-ca" + caCertificateSuffix + Expect(k8sClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: caSecretName, Namespace: crNamespace}, + Data: map[string][]byte{"tls.crt": []byte(testPEM)}, + })).To(Succeed()) + + js := &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: "ready-ca", Namespace: crNamespace}, + Spec: telemetryEnabledSpec(), + } + r := &JumpstarterReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + Expect(r.telemetryCANeedsRequeue(ctx, js)).To(BeFalse()) + }) +})