From 75e235aa0a0f7430a76662a76bf37594a4f5be52 Mon Sep 17 00:00:00 2001 From: Bella Khizgiyaev Date: Tue, 25 Aug 2026 12:10:59 +0300 Subject: [PATCH 1/2] feat: allow replica=0 on jumpstarter components Signed-off-by: Bella Khizgiyaev --- .../api/v1alpha1/jumpstarter_types.go | 22 +- .../api/v1alpha1/zz_generated.deepcopy.go | 10 + ...operator.jumpstarter.dev_jumpstarters.yaml | 24 +- .../controller/jumpstarter/certificates.go | 6 +- .../jumpstarter/exporterset_test.go | 31 ++ .../jumpstarter/jumpstarter_controller.go | 122 ++++- .../jumpstarter_controller_test.go | 430 +++++++++++++++- .../controller/jumpstarter/router_test.go | 457 ++++++++++++++++++ .../internal/controller/jumpstarter/status.go | 112 +++-- .../controller/jumpstarter/telemetry_test.go | 124 ++++- 10 files changed, 1252 insertions(+), 86 deletions(-) create mode 100644 controller/deploy/operator/internal/controller/jumpstarter/router_test.go diff --git a/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go b/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go index 30e3a7279..04aaf6af1 100644 --- a/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go +++ b/controller/deploy/operator/api/v1alpha1/jumpstarter_types.go @@ -258,8 +258,9 @@ type ProvisionerConfig struct { Image string `json:"image,omitempty"` // Replicas for this provisioner controller Deployment. + // Set to 0 to suspend the provisioner (Deployment stays but no pods run). // +kubebuilder:default=1 - // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Minimum=0 Replicas *int32 `json:"replicas,omitempty"` // Resources overrides the global exporterSets.resources for this provisioner. @@ -299,8 +300,10 @@ type TelemetryConfig struct { // Multiple replicas provide HA; each exporter connects to exactly one replica // via a persistent MetricsStream, so Prometheus sum-by queries across replicas // yield exact totals without double-counting (see JEP-0013 DD-8). + // Set to 0 to suspend the telemetry deployment (Deployment stays but no pods run, + // Service and other resources are preserved). // +kubebuilder:default=1 - // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Minimum=0 Replicas *int32 `json:"replicas,omitempty"` // Resource requirements for the telemetry pod. @@ -352,10 +355,12 @@ type RoutersConfig struct { Resources corev1.ResourceRequirements `json:"resources,omitempty"` // Number of router replicas to run. - // Must be a positive integer. Minimum recommended value is 3 for high availability. + // Set to 0 to suspend all routers (existing Deployments are scaled to 0 pods, Services and + // certificates are preserved so the configuration can be restored without reconfiguration). + // Minimum recommended value is 3 for high availability. // +kubebuilder:default=3 - // +kubebuilder:validation:Minimum=1 - Replicas int32 `json:"replicas,omitempty"` + // +kubebuilder:validation:Minimum=0 + Replicas *int32 `json:"replicas,omitempty"` // Custom annotations to add to router pod templates. PodAnnotations map[string]string `json:"podAnnotations,omitempty"` @@ -390,13 +395,14 @@ type ControllerConfig struct { Resources corev1.ResourceRequirements `json:"resources,omitempty"` // Number of controller replicas to run. - // Currently only 1 replica is supported because the controller uses in-memory + // Set to 0 to suspend the controller (Deployment stays but no pods run, all other resources are preserved). + // Currently only 1 running replica is supported because the controller uses in-memory // state for gRPC stream coordination (Dial/Listen). Values greater than 1 will // be clamped to 1 with a warning. See https://github.com/jumpstarter-dev/jumpstarter/issues/1013 // for the tracking issue on HA controller support. // +kubebuilder:default=1 - // +kubebuilder:validation:Minimum=1 - Replicas int32 `json:"replicas,omitempty"` + // +kubebuilder:validation:Minimum=0 + Replicas *int32 `json:"replicas,omitempty"` // Custom annotations to add to controller pod templates. PodAnnotations map[string]string `json:"podAnnotations,omitempty"` diff --git a/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go b/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go index b542068db..0f2a10b67 100644 --- a/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/controller/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -134,6 +134,11 @@ func (in *ConfigMapKeySelector) DeepCopy() *ConfigMapKeySelector { func (in *ControllerConfig) DeepCopyInto(out *ControllerConfig) { *out = *in in.Resources.DeepCopyInto(&out.Resources) + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } if in.PodAnnotations != nil { in, out := &in.PodAnnotations, &out.PodAnnotations *out = make(map[string]string, len(*in)) @@ -779,6 +784,11 @@ func (in *RouteConfig) DeepCopy() *RouteConfig { func (in *RoutersConfig) DeepCopyInto(out *RoutersConfig) { *out = *in in.Resources.DeepCopyInto(&out.Resources) + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } if in.PodAnnotations != nil { in, out := &in.PodAnnotations, &out.PodAnnotations *out = make(map[string]string, len(*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 e0cb75219..1cca06dac 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 @@ -1085,11 +1085,13 @@ spec: default: 1 description: |- Number of controller replicas to run. - Currently only 1 replica is supported because the controller uses in-memory + Set to 0 to suspend the controller (Deployment stays but no pods run, all other resources are preserved). + Currently only 1 running replica is supported because the controller uses in-memory state for gRPC stream coordination (Dial/Listen). Values greater than 1 will - be clamped to 1 with a warning. See issue 1013 for HA controller support. + be clamped to 1 with a warning. See https://github.com/jumpstarter-dev/jumpstarter/issues/1013 + for the tracking issue on HA controller support. format: int32 - minimum: 1 + minimum: 0 type: integer resources: description: |- @@ -1412,9 +1414,11 @@ spec: type: string replicas: default: 1 - description: Replicas for this provisioner controller Deployment. + description: |- + Replicas for this provisioner controller Deployment. + Set to 0 to suspend the provisioner (Deployment stays but no pods run). format: int32 - minimum: 1 + minimum: 0 type: integer resources: description: Resources overrides the global exporterSets.resources @@ -1849,9 +1853,11 @@ spec: default: 3 description: |- Number of router replicas to run. - Must be a positive integer. Minimum recommended value is 3 for high availability. + Set to 0 to suspend all routers (existing Deployments are scaled to 0 pods, Services and + certificates are preserved so the configuration can be restored without reconfiguration). + Minimum recommended value is 3 for high availability. format: int32 - minimum: 1 + minimum: 0 type: integer resources: description: |- @@ -2148,8 +2154,10 @@ spec: Multiple replicas provide HA; each exporter connects to exactly one replica via a persistent MetricsStream, so Prometheus sum-by queries across replicas yield exact totals without double-counting (see JEP-0013 DD-8). + Set to 0 to suspend the telemetry deployment (Deployment stays but no pods run, + Service and other resources are preserved). format: int32 - minimum: 1 + minimum: 0 type: integer resources: description: Resource requirements for the telemetry pod. diff --git a/controller/deploy/operator/internal/controller/jumpstarter/certificates.go b/controller/deploy/operator/internal/controller/jumpstarter/certificates.go index 47c0c6d78..6a285de28 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/certificates.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/certificates.go @@ -108,7 +108,11 @@ func (r *JumpstarterReconciler) reconcileCertificates(ctx context.Context, js *o } // Create router certificates (one per replica) - for i := int32(0); i < js.Spec.Routers.Replicas; i++ { + certReplicas := int32(0) + if js.Spec.Routers.Replicas != nil { + certReplicas = *js.Spec.Routers.Replicas + } + for i := int32(0); i < certReplicas; i++ { if err := r.reconcileRouterCertificate(ctx, js, issuerRef, i); err != nil { return fmt.Errorf("failed to reconcile router %d certificate: %w", i, err) } diff --git a/controller/deploy/operator/internal/controller/jumpstarter/exporterset_test.go b/controller/deploy/operator/internal/controller/jumpstarter/exporterset_test.go index e58351745..3963bcb2d 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/exporterset_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/exporterset_test.go @@ -284,6 +284,37 @@ var _ = Describe("hasEnabledProvisioners", func() { } Expect(hasEnabledProvisioners(provs)).To(BeTrue()) }) + + It("should return false when all provisioners have replicas=0 (suspended)", func() { + provs := []operatorv1alpha1.ProvisionerConfig{ + {Name: "qemu.jumpstarter.dev", Replicas: ptr.To(int32(0))}, + {Name: "corellium.jumpstarter.dev", Replicas: ptr.To(int32(0))}, + } + Expect(hasEnabledProvisioners(provs)).To(BeFalse()) + }) + + It("should return true when at least one provisioner has replicas>0 among suspended ones", func() { + provs := []operatorv1alpha1.ProvisionerConfig{ + {Name: "qemu.jumpstarter.dev", Replicas: ptr.To(int32(0))}, + {Name: "corellium.jumpstarter.dev", Replicas: ptr.To(int32(1))}, + } + Expect(hasEnabledProvisioners(provs)).To(BeTrue()) + }) + + It("should return false when provisioner is enabled but replicas=0", func() { + provs := []operatorv1alpha1.ProvisionerConfig{ + {Name: "qemu.jumpstarter.dev", Enabled: ptr.To(true), Replicas: ptr.To(int32(0))}, + } + Expect(hasEnabledProvisioners(provs)).To(BeFalse()) + }) + + It("should return false when all provisioners are disabled or suspended", func() { + provs := []operatorv1alpha1.ProvisionerConfig{ + {Name: "qemu.jumpstarter.dev", Enabled: ptr.To(false)}, + {Name: "corellium.jumpstarter.dev", Replicas: ptr.To(int32(0))}, + } + Expect(hasEnabledProvisioners(provs)).To(BeFalse()) + }) }) var _ = Describe("createExporterSetServiceAccount", func() { diff --git a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go index b50af27dc..671572a53 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go @@ -164,14 +164,14 @@ func (r *JumpstarterReconciler) Reconcile(ctx context.Context, req ctrl.Request) // gRPC stream coordination (Dial/Listen pairing), so only one replica can // serve traffic correctly. Multiple replicas would cause connection failures // when Dial and Listen land on different pods. - if jumpstarter.Spec.Controller.Replicas > 1 { + if jumpstarter.Spec.Controller.Replicas != nil && *jumpstarter.Spec.Controller.Replicas > 1 { log.Info("WARNING: controller.replicas > 1 is not yet supported — the controller "+ "uses in-memory state for gRPC stream coordination. Clamping to 1.", - "requested", jumpstarter.Spec.Controller.Replicas) + "requested", *jumpstarter.Spec.Controller.Replicas) r.emitEventf(&jumpstarter, corev1.EventTypeWarning, "ReplicasClamped", "controller.replicas=%d is not yet supported (in-memory gRPC state requires a single replica), clamping to 1", - jumpstarter.Spec.Controller.Replicas) - jumpstarter.Spec.Controller.Replicas = 1 + *jumpstarter.Spec.Controller.Replicas) + jumpstarter.Spec.Controller.Replicas = ptr.To(int32(1)) } // Reconcile RBAC resources first @@ -378,11 +378,22 @@ func (r *JumpstarterReconciler) reconcileControllerDeployment(ctx context.Contex func (r *JumpstarterReconciler) reconcileRouterDeployment(ctx context.Context, jumpstarter *operatorv1alpha1.Jumpstarter) error { log := logf.FromContext(ctx) + // When replicas is 0, suspend all existing router Deployments in-place + // (scale to 0 pods) without deleting them or their associated resources. + routerReplicas := int32(0) + if jumpstarter.Spec.Routers.Replicas != nil { + routerReplicas = *jumpstarter.Spec.Routers.Replicas + } + + if routerReplicas == 0 { + return r.suspendAllRouterDeployments(ctx, jumpstarter) + } + // Cache hashes by secret name so a shared CertSecret is fetched once across replicas. tlsHashBySecret := make(map[string]string) // Create one deployment per replica - for i := int32(0); i < jumpstarter.Spec.Routers.Replicas; i++ { + for i := int32(0); i < routerReplicas; i++ { secretName := routerTLSSecretName(jumpstarter, i) routerTLSHash, ok := tlsHashBySecret[secretName] if !ok { @@ -499,8 +510,14 @@ func (r *JumpstarterReconciler) reconcileServices(ctx context.Context, jumpstart } } - // Reconcile router services - one per replica, all endpoints per replica - for i := int32(0); i < jumpstarter.Spec.Routers.Replicas; i++ { + // Reconcile router services - one per replica, all endpoints per replica. + // When replicas == 0 the router is suspended; skip service reconciliation and + // cleanup so existing Services are preserved for quick resume. + svcRouterReplicas := int32(0) + if jumpstarter.Spec.Routers.Replicas != nil { + svcRouterReplicas = *jumpstarter.Spec.Routers.Replicas + } + for i := int32(0); i < svcRouterReplicas; i++ { if len(jumpstarter.Spec.Routers.GRPC.Endpoints) > 0 { // Each replica gets ALL configured endpoints with replica substitution for endpointIdx, baseEndpoint := range jumpstarter.Spec.Routers.GRPC.Endpoints { @@ -549,10 +566,14 @@ func (r *JumpstarterReconciler) reconcileServices(ctx context.Context, jumpstart } } - // Clean up services for scaled-down replicas - if err := r.cleanupExcessRouterServices(ctx, jumpstarter); err != nil { - log.Error(err, "Failed to cleanup excess router services") - return err + // Clean up services for scaled-down replicas. + // Skip when replicas == 0 (suspended): keep existing Services so the router + // configuration can be restored without reconfiguration. + if svcRouterReplicas > 0 { + if err := r.cleanupExcessRouterServices(ctx, jumpstarter); err != nil { + log.Error(err, "Failed to cleanup excess router services") + return err + } } // Reconcile login endpoints (if configured) @@ -918,7 +939,7 @@ func (r *JumpstarterReconciler) createControllerDeployment(jumpstarter *operator Labels: labels, }, Spec: appsv1.DeploymentSpec{ - Replicas: &jumpstarter.Spec.Controller.Replicas, + Replicas: jumpstarter.Spec.Controller.Replicas, ProgressDeadlineSeconds: ptr.To(int32(600)), RevisionHistoryLimit: ptr.To(int32(10)), Strategy: appsv1.DeploymentStrategy{ @@ -1323,16 +1344,20 @@ func (r *JumpstarterReconciler) buildConfig(ctx context.Context, jumpstarter *op // Telemetry configuration. // Certificate is intentionally omitted until the telemetry binary supports TLS serving. + // When replicas==0 the telemetry Deployment is suspended (no ready endpoints), so omit + // the telemetry block from the config to avoid directing exporters to a dead endpoint. if jumpstarter.Spec.Telemetry != nil && jumpstarter.Spec.Telemetry.Enabled { t := jumpstarter.Spec.Telemetry - telemetryCfg := &config.Telemetry{ - Enabled: true, - Endpoint: telemetryEndpointFor(jumpstarter.Namespace), - } - if t.Logging.Filter.MinSeverity != "" { - telemetryCfg.Logging.Filter.MinSeverity = t.Logging.Filter.MinSeverity + if t.Replicas == nil || *t.Replicas > 0 { + telemetryCfg := &config.Telemetry{ + Enabled: true, + Endpoint: telemetryEndpointFor(jumpstarter.Namespace), + } + if t.Logging.Filter.MinSeverity != "" { + telemetryCfg.Logging.Filter.MinSeverity = t.Logging.Filter.MinSeverity + } + cfg.Telemetry = telemetryCfg } - cfg.Telemetry = telemetryCfg } // gRPC keepalive configuration @@ -1434,7 +1459,11 @@ func (r *JumpstarterReconciler) buildRouter(jumpstarter *operatorv1alpha1.Jumpst router := make(config.Router) // Create router entry for each replica - for i := int32(0); i < jumpstarter.Spec.Routers.Replicas; i++ { + routerReplicaCount := int32(0) + if jumpstarter.Spec.Routers.Replicas != nil { + routerReplicaCount = *jumpstarter.Spec.Routers.Replicas + } + for i := int32(0); i < routerReplicaCount; i++ { // First replica is named "default" for backwards compatibility routerName := "default" if i > 0 { @@ -1525,6 +1554,39 @@ func (r *JumpstarterReconciler) buildEndpointForReplica(jumpstarter *operatorv1a return endpoint } +// suspendAllRouterDeployments scales every existing router Deployment that belongs to +// this Jumpstarter instance down to 0 replicas without deleting the Deployment or any +// other associated resources (Services, Certificates, etc.). +// This is called when spec.routers.replicas == 0. +func (r *JumpstarterReconciler) suspendAllRouterDeployments(ctx context.Context, jumpstarter *operatorv1alpha1.Jumpstarter) error { + log := logf.FromContext(ctx) + + deploymentList := &appsv1.DeploymentList{} + if err := r.List(ctx, deploymentList, + client.InNamespace(jumpstarter.Namespace), + client.MatchingLabels{"router": jumpstarter.Name}, + ); err != nil { + return fmt.Errorf("failed to list router deployments for suspension: %w", err) + } + + zero := int32(0) + for i := range deploymentList.Items { + dep := &deploymentList.Items[i] + if dep.Spec.Replicas != nil && *dep.Spec.Replicas == 0 { + continue + } + dep.Spec.Replicas = &zero + if err := r.Update(ctx, dep); err != nil { + return fmt.Errorf("failed to suspend router deployment %s: %w", dep.Name, err) + } + log.Info("Router deployment suspended (scaled to 0)", "name", dep.Name) + r.emitEventf(jumpstarter, corev1.EventTypeNormal, "RouterDeploymentSuspended", + "Router deployment suspended: name=%s namespace=%s", dep.Name, dep.Namespace) + } + + return nil +} + // cleanupExcessRouterDeployments deletes router deployments that exceed the current replica count func (r *JumpstarterReconciler) cleanupExcessRouterDeployments(ctx context.Context, jumpstarter *operatorv1alpha1.Jumpstarter) error { log := logf.FromContext(ctx) @@ -1548,7 +1610,11 @@ func (r *JumpstarterReconciler) cleanupExcessRouterDeployments(ctx context.Conte // Check if this deployment's name indicates it's beyond the current replica count // We need to check all indices from current replicas onwards - for idx := jumpstarter.Spec.Routers.Replicas; idx < 100; idx++ { // reasonable upper bound + cleanupRouterReplicas := int32(0) + if jumpstarter.Spec.Routers.Replicas != nil { + cleanupRouterReplicas = *jumpstarter.Spec.Routers.Replicas + } + for idx := cleanupRouterReplicas; idx < 100; idx++ { // reasonable upper bound excessName := fmt.Sprintf("%s-router-%d", jumpstarter.Name, idx) if deployment.Name == excessName { log.Info("Deleting excess router deployment", "deployment", deployment.Name, "replicaIndex", idx) @@ -1579,7 +1645,11 @@ func (r *JumpstarterReconciler) cleanupExcessRouterServices(ctx context.Context, suffixes := []string{"", "-lb", "-np"} // 1. Delete services for excess replicas (replica index >= current replica count) - for idx := jumpstarter.Spec.Routers.Replicas; idx < 100; idx++ { // reasonable upper bound + svcCleanupReplicas := int32(0) + if jumpstarter.Spec.Routers.Replicas != nil { + svcCleanupReplicas = *jumpstarter.Spec.Routers.Replicas + } + for idx := svcCleanupReplicas; idx < 100; idx++ { // reasonable upper bound foundAny := false // Try to delete services for all endpoints and service types for this replica @@ -1623,7 +1693,7 @@ func (r *JumpstarterReconciler) cleanupExcessRouterServices(ctx context.Context, numEndpoints = 1 // default endpoint } - for replicaIdx := int32(0); replicaIdx < jumpstarter.Spec.Routers.Replicas; replicaIdx++ { + for replicaIdx := int32(0); replicaIdx < svcCleanupReplicas; replicaIdx++ { for endpointIdx := numEndpoints; endpointIdx < 10; endpointIdx++ { // reasonable upper bound foundAny := false @@ -1738,7 +1808,11 @@ func (r *JumpstarterReconciler) SetupWithManager(mgr ctrl.Manager) error { // Router TLS cert secrets if jumpstarter.Spec.CertManager.Enabled { - for i := int32(0); i < jumpstarter.Spec.Routers.Replicas; i++ { + tlsRouterReplicas := int32(0) + if jumpstarter.Spec.Routers.Replicas != nil { + tlsRouterReplicas = *jumpstarter.Spec.Routers.Replicas + } + for i := int32(0); i < tlsRouterReplicas; i++ { keys = append(keys, jumpstarter.Namespace+"/"+GetRouterCertSecretName(jumpstarter, i)) } } else if s := jumpstarter.Spec.Routers.GRPC.TLS.CertSecret; s != "" { diff --git a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go index 475c4b3bf..d434902a5 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go @@ -18,6 +18,7 @@ package jumpstarter import ( "context" + "fmt" "strings" . "github.com/onsi/ginkgo/v2" @@ -31,6 +32,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" apiserverv1beta1 "k8s.io/apiserver/pkg/apis/apiserver/v1beta1" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/reconcile" operatorv1alpha1 "github.com/jumpstarter-dev/jumpstarter/controller/deploy/operator/api/v1alpha1" @@ -153,14 +155,14 @@ var _ = Describe("Jumpstarter Controller — JWT CA resolution", func() { }, Controller: operatorv1alpha1.ControllerConfig{ Image: "quay.io/jumpstarter/jumpstarter:latest", - Replicas: 1, + Replicas: ptr.To(int32(1)), GRPC: operatorv1alpha1.GRPCConfig{ Endpoints: []operatorv1alpha1.Endpoint{{Address: "controller"}}, }, }, Routers: operatorv1alpha1.RoutersConfig{ Image: "quay.io/jumpstarter/jumpstarter:latest", - Replicas: 1, + Replicas: ptr.To(int32(1)), GRPC: operatorv1alpha1.GRPCConfig{ Endpoints: []operatorv1alpha1.Endpoint{{Address: "router"}}, }, @@ -400,14 +402,14 @@ var _ = Describe("ExporterSet Controller Lifecycle", func() { }, Controller: operatorv1alpha1.ControllerConfig{ Image: "quay.io/jumpstarter/jumpstarter:latest", - Replicas: 1, + Replicas: ptr.To(int32(1)), GRPC: operatorv1alpha1.GRPCConfig{ Endpoints: []operatorv1alpha1.Endpoint{{Address: "controller"}}, }, }, Routers: operatorv1alpha1.RoutersConfig{ Image: "quay.io/jumpstarter/jumpstarter:latest", - Replicas: 1, + Replicas: ptr.To(int32(1)), GRPC: operatorv1alpha1.GRPCConfig{ Endpoints: []operatorv1alpha1.Endpoint{{Address: "router"}}, }, @@ -784,6 +786,426 @@ var _ = Describe("ExporterSet Controller Lifecycle", func() { }) }) +var _ = Describe("Scale-to-zero / Suspend", func() { + const crName = "test-suspend" + var crNamespace string + ctx := context.Background() + + baseSpec := func() operatorv1alpha1.JumpstarterSpec { + return operatorv1alpha1.JumpstarterSpec{ + BaseDomain: "example.com", + CertManager: operatorv1alpha1.CertManagerConfig{ + Enabled: false, + }, + Controller: operatorv1alpha1.ControllerConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + Replicas: ptr.To(int32(1)), + GRPC: operatorv1alpha1.GRPCConfig{ + Endpoints: []operatorv1alpha1.Endpoint{{Address: "controller"}}, + }, + }, + Routers: operatorv1alpha1.RoutersConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + Replicas: ptr.To(int32(1)), + GRPC: operatorv1alpha1.GRPCConfig{ + Endpoints: []operatorv1alpha1.Endpoint{{Address: "router"}}, + }, + }, + } + } + + doReconcile := func() { + r := &JumpstarterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + EndpointReconciler: endpoints.NewReconciler(k8sClient, k8sClient.Scheme(), cfg), + } + _, err := r.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: crName, Namespace: crNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + } + + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "suspend-test-"}} + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + crNamespace = ns.Name + }) + + AfterEach(func() { + _ = k8sClient.Delete(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: crNamespace}, + }) + }) + + Context("Controller", func() { + It("keeps the Deployment at 0 replicas when controller.replicas=0 (not deleted)", func() { + By("creating a CR with controller replicas=0") + spec := baseSpec() + spec.Controller.Replicas = ptr.To(int32(0)) + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + By("verifying the controller Deployment exists with 0 replicas") + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-controller", + Namespace: crNamespace, + }, dep)).To(Succeed(), "controller Deployment should exist, not be deleted") + Expect(*dep.Spec.Replicas).To(Equal(int32(0))) + }) + + It("reports ControllerDeploymentReady=True with 'suspended' message when replicas=0", func() { + spec := baseSpec() + spec.Controller.Replicas = ptr.To(int32(0)) + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + cond := meta.FindStatusCondition(js.Status.Conditions, operatorv1alpha1.ConditionTypeControllerDeploymentReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Message).To(ContainSubstring("suspended")) + }) + + It("scales the controller Deployment back up when replicas is restored", func() { + By("creating with replicas=0") + spec := baseSpec() + spec.Controller.Replicas = ptr.To(int32(0)) + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + doReconcile() + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-controller", Namespace: crNamespace, + }, dep)).To(Succeed()) + Expect(*dep.Spec.Replicas).To(Equal(int32(0))) + + By("restoring replicas=1") + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + js.Spec.Controller.Replicas = ptr.To(int32(1)) + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + doReconcile() + + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-controller", Namespace: crNamespace, + }, dep)).To(Succeed()) + Expect(*dep.Spec.Replicas).To(Equal(int32(1))) + }) + }) + + Context("Routers", func() { + It("scales existing router Deployments to 0 (not deletes) when routers.replicas=0", func() { + By("creating with routers.replicas=1 and reconciling to create the Deployment") + spec := baseSpec() + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + doReconcile() + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-router-0", Namespace: crNamespace, + }, dep)).To(Succeed()) + Expect(*dep.Spec.Replicas).To(Equal(int32(1))) + + By("scaling routers to 0") + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + js.Spec.Routers.Replicas = ptr.To(int32(0)) + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + doReconcile() + + By("verifying router Deployment still exists but with 0 replicas") + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-router-0", Namespace: crNamespace, + }, dep)).To(Succeed(), "router Deployment should not be deleted") + Expect(*dep.Spec.Replicas).To(Equal(int32(0))) + }) + + It("preserves router Services when routers.replicas=0", func() { + By("creating with routers.replicas=1") + spec := baseSpec() + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + doReconcile() + + By("scaling routers to 0") + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + js.Spec.Routers.Replicas = ptr.To(int32(0)) + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + doReconcile() + + By("verifying router Service is still present") + svc := &corev1.Service{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-router-0", Namespace: crNamespace, + }, svc)).To(Succeed(), "router Service should be preserved when suspended") + }) + + It("reports RouterDeploymentsReady=True with 'suspended' message when replicas=0", func() { + spec := baseSpec() + spec.Routers.Replicas = ptr.To(int32(0)) + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + cond := meta.FindStatusCondition(js.Status.Conditions, operatorv1alpha1.ConditionTypeRouterDeploymentsReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Message).To(ContainSubstring("suspended")) + }) + + It("resumes routers after suspension by recreating Deployments when replicas > 0", func() { + By("creating with replicas=1 and suspending") + spec := baseSpec() + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + doReconcile() + + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + js.Spec.Routers.Replicas = ptr.To(int32(0)) + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + doReconcile() + + By("restoring replicas=1") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + js.Spec.Routers.Replicas = ptr.To(int32(1)) + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + doReconcile() + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-router-0", Namespace: crNamespace, + }, dep)).To(Succeed()) + Expect(*dep.Spec.Replicas).To(Equal(int32(1))) + }) + + It("resumes to 1 replica after 3->0 suspension: excess Deployments are cleaned up", func() { + By("creating with replicas=3") + spec := baseSpec() + spec.Routers.Replicas = ptr.To(int32(3)) + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + doReconcile() + + By("verifying all 3 Deployments exist") + dep := &appsv1.Deployment{} + for i := 0; i < 3; i++ { + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: fmt.Sprintf("%s-router-%d", crName, i), Namespace: crNamespace, + }, dep)).To(Succeed()) + } + + By("suspending all routers (replicas=0) — Deployments scaled to 0 but not deleted") + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + js.Spec.Routers.Replicas = ptr.To(int32(0)) + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + doReconcile() + + for i := 0; i < 3; i++ { + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: fmt.Sprintf("%s-router-%d", crName, i), Namespace: crNamespace, + }, dep)).To(Succeed(), "Deployment router-%d should exist (suspended, not deleted)", i) + Expect(*dep.Spec.Replicas).To(Equal(int32(0))) + } + + By("resuming at replicas=1 — excess suspended Deployments (router-1, router-2) are cleaned up") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + js.Spec.Routers.Replicas = ptr.To(int32(1)) + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + doReconcile() + + By("verifying router-0 is active (replicas=1)") + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-router-0", Namespace: crNamespace, + }, dep)).To(Succeed()) + Expect(*dep.Spec.Replicas).To(Equal(int32(1))) + + By("verifying router-1 and router-2 are deleted (cleanup of excess suspended Deployments)") + for i := 1; i < 3; i++ { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: fmt.Sprintf("%s-router-%d", crName, i), Namespace: crNamespace, + }, dep) + Expect(errors.IsNotFound(err)).To(BeTrue(), + "excess suspended Deployment router-%d should be deleted on resume", i) + } + }) + }) + + Context("ExporterSet provisioner", func() { + It("keeps the provisioner Deployment at 0 replicas when replicas=0 (not deleted)", func() { + By("creating a provisioner with replicas=0") + spec := baseSpec() + zero := int32(0) + spec.ExporterSets = &operatorv1alpha1.ExporterSetsConfig{ + Image: "quay.io/jumpstarter-dev/jumpstarter-exporterset-controller:latest", + Provisioners: []operatorv1alpha1.ProvisionerConfig{ + {Name: "qemu.jumpstarter.dev", Replicas: &zero}, + }, + } + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-exporterset-qemu-jumpstarter-dev", + Namespace: crNamespace, + }, dep)).To(Succeed(), "provisioner Deployment should exist at 0 replicas, not be deleted") + Expect(*dep.Spec.Replicas).To(Equal(int32(0))) + }) + + It("does not block readiness check for suspended provisioners (replicas=0)", func() { + By("creating two provisioners: one active, one suspended") + spec := baseSpec() + zero := int32(0) + spec.ExporterSets = &operatorv1alpha1.ExporterSetsConfig{ + Image: "quay.io/jumpstarter-dev/jumpstarter-exporterset-controller:latest", + Provisioners: []operatorv1alpha1.ProvisionerConfig{ + {Name: "qemu.jumpstarter.dev"}, // active (replicas defaults to 1) + {Name: "corellium.jumpstarter.dev", Replicas: &zero}, // suspended + }, + } + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + By("marking the active provisioner as Available (the suspended one is ignored)") + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-exporterset-qemu-jumpstarter-dev", + Namespace: crNamespace, + }, dep)).To(Succeed()) + dep.Status.Conditions = []appsv1.DeploymentCondition{ + {Type: appsv1.DeploymentAvailable, Status: corev1.ConditionTrue, Reason: "MinimumReplicasAvailable"}, + } + Expect(k8sClient.Status().Update(ctx, dep)).To(Succeed()) + + doReconcile() + + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + cond := meta.FindStatusCondition(js.Status.Conditions, operatorv1alpha1.ConditionTypeExporterSetControllersReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue), + "suspended provisioner should not block overall ExporterSetControllersReady") + }) + + It("clears a stale ExporterSetControllersReady=False when all provisioners are suspended", func() { + By("creating an active provisioner that is not yet Available (condition will be False)") + spec := baseSpec() + spec.ExporterSets = &operatorv1alpha1.ExporterSetsConfig{ + Image: "quay.io/jumpstarter-dev/jumpstarter-exporterset-controller:latest", + Provisioners: []operatorv1alpha1.ProvisionerConfig{ + {Name: "qemu.jumpstarter.dev"}, + }, + } + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + By("verifying ExporterSetControllersReady=False (deployment not Available)") + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + cond := meta.FindStatusCondition(js.Status.Conditions, operatorv1alpha1.ConditionTypeExporterSetControllersReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + + By("suspending all provisioners (replicas=0)") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + zero := int32(0) + js.Spec.ExporterSets.Provisioners[0].Replicas = &zero + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + + doReconcile() + + By("verifying ExporterSetControllersReady is now True/Suspended (stale False cleared)") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + cond = meta.FindStatusCondition(js.Status.Conditions, operatorv1alpha1.ConditionTypeExporterSetControllersReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue), + "stale False condition should be cleared when all provisioners are suspended") + Expect(cond.Reason).To(Equal("Suspended")) + }) + }) + + Context("Suspended reason", func() { + It("uses Suspended reason (not DeploymentAvailable) when controller.replicas=0", func() { + spec := baseSpec() + spec.Controller.Replicas = ptr.To(int32(0)) + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + cond := meta.FindStatusCondition(js.Status.Conditions, operatorv1alpha1.ConditionTypeControllerDeploymentReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Reason).To(Equal("Suspended"), + "reason should be Suspended, not DeploymentAvailable") + }) + + It("uses Suspended reason (not AllDeploymentsAvailable) when routers.replicas=0", func() { + spec := baseSpec() + spec.Routers.Replicas = ptr.To(int32(0)) + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + cond := meta.FindStatusCondition(js.Status.Conditions, operatorv1alpha1.ConditionTypeRouterDeploymentsReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Reason).To(Equal("Suspended"), + "reason should be Suspended, not AllDeploymentsAvailable") + }) + }) +}) + var _ = Describe("sanitizeProvisionerName", func() { It("replaces dots with dashes and lowercases", func() { Expect(sanitizeProvisionerName("qemu.jumpstarter.dev")).To(Equal("qemu-jumpstarter-dev")) diff --git a/controller/deploy/operator/internal/controller/jumpstarter/router_test.go b/controller/deploy/operator/internal/controller/jumpstarter/router_test.go new file mode 100644 index 000000000..4ee76092c --- /dev/null +++ b/controller/deploy/operator/internal/controller/jumpstarter/router_test.go @@ -0,0 +1,457 @@ +/* +Copyright 2026. The Jumpstarter Authors. + +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 jumpstarter + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + operatorv1alpha1 "github.com/jumpstarter-dev/jumpstarter/controller/deploy/operator/api/v1alpha1" + "github.com/jumpstarter-dev/jumpstarter/controller/deploy/operator/internal/controller/jumpstarter/endpoints" +) + +var _ = Describe("Router Lifecycle", func() { + const crName = "test-router" + + var crNamespace string + ctx := context.Background() + + makeJumpstarterSpec := func(replicas *int32) operatorv1alpha1.JumpstarterSpec { + return operatorv1alpha1.JumpstarterSpec{ + BaseDomain: "example.com", + CertManager: operatorv1alpha1.CertManagerConfig{ + Enabled: false, + }, + Controller: operatorv1alpha1.ControllerConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + Replicas: ptr.To(int32(1)), + GRPC: operatorv1alpha1.GRPCConfig{ + Endpoints: []operatorv1alpha1.Endpoint{{Address: "controller"}}, + }, + }, + Routers: operatorv1alpha1.RoutersConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + ImagePullPolicy: corev1.PullIfNotPresent, + Replicas: replicas, + GRPC: operatorv1alpha1.GRPCConfig{ + Endpoints: []operatorv1alpha1.Endpoint{{Address: "router"}}, + }, + }, + } + } + + newReconciler := func() *JumpstarterReconciler { + return &JumpstarterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + EndpointReconciler: endpoints.NewReconciler(k8sClient, k8sClient.Scheme(), cfg), + } + } + + doReconcile := func() { + _, err := newReconciler().Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: crName, Namespace: crNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + } + + routerDeploymentName := func(index int) string { + return fmt.Sprintf("%s-router-%d", crName, index) + } + + routerServiceName := func(index int) string { + return fmt.Sprintf("%s-router-%d", crName, index) + } + + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "router-test-"}} + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + crNamespace = ns.Name + }) + + AfterEach(func() { + _ = k8sClient.Delete(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: crNamespace}, + }) + }) + + It("creates a router Deployment and Service for replicas=1", func() { + By("creating a Jumpstarter CR with 1 router replica") + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: makeJumpstarterSpec(ptr.To(int32(1))), + })).To(Succeed()) + + doReconcile() + + By("verifying the router Deployment exists") + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(0), Namespace: crNamespace, + }, dep)).To(Succeed()) + + By("verifying the router Service exists") + svc := &corev1.Service{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerServiceName(0), Namespace: crNamespace, + }, svc)).To(Succeed()) + Expect(svc.Spec.Type).To(Equal(corev1.ServiceTypeClusterIP)) + Expect(svc.Spec.Ports).To(HaveLen(1)) + Expect(svc.Spec.Ports[0].Port).To(Equal(int32(8083))) + }) + + It("sets correct labels on the router Deployment", func() { + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: makeJumpstarterSpec(ptr.To(int32(1))), + })).To(Succeed()) + + doReconcile() + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(0), Namespace: crNamespace, + }, dep)).To(Succeed()) + + Expect(dep.Labels).To(HaveKeyWithValue("component", "router")) + Expect(dep.Labels).To(HaveKeyWithValue("app", fmt.Sprintf("%s-router-0", crName))) + Expect(dep.Labels).To(HaveKeyWithValue("router", crName)) + Expect(dep.Labels).To(HaveKeyWithValue("router-index", "0")) + }) + + It("uses the image and imagePullPolicy from the spec", func() { + spec := makeJumpstarterSpec(ptr.To(int32(1))) + spec.Routers.Image = "quay.io/jumpstarter/jumpstarter:v1.2.3" + spec.Routers.ImagePullPolicy = corev1.PullAlways + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(0), Namespace: crNamespace, + }, dep)).To(Succeed()) + + container := dep.Spec.Template.Spec.Containers[0] + Expect(container.Image).To(Equal("quay.io/jumpstarter/jumpstarter:v1.2.3")) + Expect(container.ImagePullPolicy).To(Equal(corev1.PullAlways)) + }) + + It("sets required environment variables on the router container", func() { + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: makeJumpstarterSpec(ptr.To(int32(1))), + })).To(Succeed()) + + doReconcile() + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(0), Namespace: crNamespace, + }, dep)).To(Succeed()) + + envNames := make([]string, 0) + for _, e := range dep.Spec.Template.Spec.Containers[0].Env { + envNames = append(envNames, e.Name) + } + Expect(envNames).To(ContainElements("GRPC_ROUTER_ENDPOINT", "ROUTER_KEY", "NAMESPACE")) + }) + + It("applies default resource requests and limits when none are specified", func() { + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: makeJumpstarterSpec(ptr.To(int32(1))), + })).To(Succeed()) + + doReconcile() + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(0), Namespace: crNamespace, + }, dep)).To(Succeed()) + + res := dep.Spec.Template.Spec.Containers[0].Resources + Expect(res.Requests).To(HaveKeyWithValue(corev1.ResourceCPU, resource.MustParse("100m"))) + Expect(res.Requests).To(HaveKeyWithValue(corev1.ResourceMemory, resource.MustParse("256Mi"))) + Expect(res.Limits).To(HaveKeyWithValue(corev1.ResourceCPU, resource.MustParse("1"))) + Expect(res.Limits).To(HaveKeyWithValue(corev1.ResourceMemory, resource.MustParse("512Mi"))) + }) + + It("applies security context: RunAsNonRoot and drop ALL capabilities", func() { + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: makeJumpstarterSpec(ptr.To(int32(1))), + })).To(Succeed()) + + doReconcile() + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(0), Namespace: crNamespace, + }, dep)).To(Succeed()) + + podSC := dep.Spec.Template.Spec.SecurityContext + Expect(podSC).NotTo(BeNil()) + Expect(podSC.RunAsNonRoot).NotTo(BeNil()) + Expect(*podSC.RunAsNonRoot).To(BeTrue()) + + containerSC := dep.Spec.Template.Spec.Containers[0].SecurityContext + Expect(containerSC).NotTo(BeNil()) + Expect(containerSC.AllowPrivilegeEscalation).NotTo(BeNil()) + Expect(*containerSC.AllowPrivilegeEscalation).To(BeFalse()) + Expect(containerSC.Capabilities).NotTo(BeNil()) + Expect(containerSC.Capabilities.Drop).To(ContainElement(corev1.Capability("ALL"))) + }) + + It("creates one Deployment and Service per replica when replicas=3", func() { + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: makeJumpstarterSpec(ptr.To(int32(3))), + })).To(Succeed()) + + doReconcile() + + for i := 0; i < 3; i++ { + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(i), Namespace: crNamespace, + }, dep)).To(Succeed(), "Deployment for router-%d should exist", i) + Expect(dep.Labels).To(HaveKeyWithValue("router-index", fmt.Sprintf("%d", i))) + + svc := &corev1.Service{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerServiceName(i), Namespace: crNamespace, + }, svc)).To(Succeed(), "Service for router-%d should exist", i) + } + }) + + It("scales up: adds new Deployments and Services when replicas increases", func() { + By("creating with 1 router replica") + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: makeJumpstarterSpec(ptr.To(int32(1))), + })).To(Succeed()) + doReconcile() + + By("scaling up to 3 replicas") + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + js.Spec.Routers.Replicas = ptr.To(int32(3)) + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + doReconcile() + + By("verifying all 3 Deployments and Services exist") + for i := 0; i < 3; i++ { + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(i), Namespace: crNamespace, + }, dep)).To(Succeed(), "Deployment router-%d should exist after scale-up", i) + + svc := &corev1.Service{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerServiceName(i), Namespace: crNamespace, + }, svc)).To(Succeed(), "Service router-%d should exist after scale-up", i) + } + }) + + It("scales down: deletes excess Deployments and Services when replicas decreases", func() { + By("creating with 3 router replicas") + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: makeJumpstarterSpec(ptr.To(int32(3))), + })).To(Succeed()) + doReconcile() + + By("scaling down to 1 replica") + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + js.Spec.Routers.Replicas = ptr.To(int32(1)) + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + doReconcile() + + By("verifying router-0 still exists") + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(0), Namespace: crNamespace, + }, dep)).To(Succeed()) + + By("verifying router-1 and router-2 Deployments are deleted") + for i := 1; i < 3; i++ { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(i), Namespace: crNamespace, + }, dep) + Expect(errors.IsNotFound(err)).To(BeTrue(), "Deployment router-%d should be deleted", i) + } + + By("verifying router-1 and router-2 Services are deleted") + svc := &corev1.Service{} + for i := 1; i < 3; i++ { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: routerServiceName(i), Namespace: crNamespace, + }, svc) + Expect(errors.IsNotFound(err)).To(BeTrue(), "Service router-%d should be deleted", i) + } + }) + + It("updates the Deployment when the image changes", func() { + By("creating with initial image") + spec := makeJumpstarterSpec(ptr.To(int32(1))) + spec.Routers.Image = "quay.io/jumpstarter/jumpstarter:v1" + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + doReconcile() + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(0), Namespace: crNamespace, + }, dep)).To(Succeed()) + Expect(dep.Spec.Template.Spec.Containers[0].Image).To(Equal("quay.io/jumpstarter/jumpstarter:v1")) + + By("updating the image in the CR") + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + js.Spec.Routers.Image = "quay.io/jumpstarter/jumpstarter:v2" + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + doReconcile() + + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(0), Namespace: crNamespace, + }, dep)).To(Succeed()) + Expect(dep.Spec.Template.Spec.Containers[0].Image).To(Equal("quay.io/jumpstarter/jumpstarter:v2")) + }) + + It("reports RouterDeploymentsReady=False when Deployment is not Available yet", func() { + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: makeJumpstarterSpec(ptr.To(int32(1))), + })).To(Succeed()) + + doReconcile() + + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + cond := meta.FindStatusCondition(js.Status.Conditions, operatorv1alpha1.ConditionTypeRouterDeploymentsReady) + Expect(cond).NotTo(BeNil(), "RouterDeploymentsReady condition should be set") + Expect(cond.Status).To(Equal(metav1.ConditionFalse), + "should be False while Deployment is not Available") + }) + + It("reports RouterDeploymentsReady=True when all Deployments become Available", func() { + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: makeJumpstarterSpec(ptr.To(int32(1))), + })).To(Succeed()) + + doReconcile() + + By("marking the router Deployment as Available") + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(0), Namespace: crNamespace, + }, dep)).To(Succeed()) + dep.Status.Conditions = []appsv1.DeploymentCondition{ + {Type: appsv1.DeploymentAvailable, Status: corev1.ConditionTrue, Reason: "MinimumReplicasAvailable"}, + } + Expect(k8sClient.Status().Update(ctx, dep)).To(Succeed()) + + doReconcile() + + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + cond := meta.FindStatusCondition(js.Status.Conditions, operatorv1alpha1.ConditionTypeRouterDeploymentsReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }) + + It("reports RouterDeploymentsReady=False when any replica Deployment is not Available", func() { + By("creating with 2 replicas") + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: makeJumpstarterSpec(ptr.To(int32(2))), + })).To(Succeed()) + + doReconcile() + + By("marking only router-0 as Available (router-1 is not)") + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: routerDeploymentName(0), Namespace: crNamespace, + }, dep)).To(Succeed()) + dep.Status.Conditions = []appsv1.DeploymentCondition{ + {Type: appsv1.DeploymentAvailable, Status: corev1.ConditionTrue, Reason: "MinimumReplicasAvailable"}, + } + Expect(k8sClient.Status().Update(ctx, dep)).To(Succeed()) + + doReconcile() + + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + cond := meta.FindStatusCondition(js.Status.Conditions, operatorv1alpha1.ConditionTypeRouterDeploymentsReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse), + "overall condition should be False when any replica is not Available") + }) +}) + +var _ = Describe("defaultRouterResources", func() { + It("returns defaults when spec is empty", func() { + result := defaultRouterResources(corev1.ResourceRequirements{}) + Expect(result.Requests).To(HaveKeyWithValue(corev1.ResourceCPU, resource.MustParse("100m"))) + Expect(result.Requests).To(HaveKeyWithValue(corev1.ResourceMemory, resource.MustParse("256Mi"))) + Expect(result.Limits).To(HaveKeyWithValue(corev1.ResourceCPU, resource.MustParse("1"))) + Expect(result.Limits).To(HaveKeyWithValue(corev1.ResourceMemory, resource.MustParse("512Mi"))) + }) + + It("returns user-specified resources unchanged when requests are set", func() { + custom := corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("200m"), + }, + } + result := defaultRouterResources(custom) + Expect(result.Requests).To(HaveKeyWithValue(corev1.ResourceCPU, resource.MustParse("200m"))) + Expect(result.Limits).To(BeNil()) + }) + + It("returns user-specified resources unchanged when limits are set", func() { + custom := corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + } + result := defaultRouterResources(custom) + Expect(result.Limits).To(HaveKeyWithValue(corev1.ResourceMemory, resource.MustParse("1Gi"))) + Expect(result.Requests).To(BeNil()) + }) +}) diff --git a/controller/deploy/operator/internal/controller/jumpstarter/status.go b/controller/deploy/operator/internal/controller/jumpstarter/status.go index cbe15429f..382d77fbf 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/status.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/status.go @@ -91,10 +91,12 @@ func (r *JumpstarterReconciler) updateStatus(ctx context.Context, js *operatorv1 // Check controller deployment readiness controllerReady, controllerMsg := r.checkControllerDeploymentReady(ctx, js) + controllerReason := conditionReason(controllerReady, "DeploymentAvailable", "DeploymentNotAvailable") + if js.Spec.Controller.Replicas != nil && *js.Spec.Controller.Replicas == 0 { + controllerReason = "Suspended" + } setCondition(js, operatorv1alpha1.ConditionTypeControllerDeploymentReady, - controllerReady, - conditionReason(controllerReady, "DeploymentAvailable", "DeploymentNotAvailable"), - controllerMsg) + controllerReady, controllerReason, controllerMsg) if !controllerReady { allReady = false messages = append(messages, controllerMsg) @@ -102,10 +104,12 @@ func (r *JumpstarterReconciler) updateStatus(ctx context.Context, js *operatorv1 // Check router deployments readiness routersReady, routersMsg := r.checkRouterDeploymentsReady(ctx, js) + routersReason := conditionReason(routersReady, "AllDeploymentsAvailable", "DeploymentsNotAvailable") + if js.Spec.Routers.Replicas != nil && *js.Spec.Routers.Replicas == 0 { + routersReason = "Suspended" + } setCondition(js, operatorv1alpha1.ConditionTypeRouterDeploymentsReady, - routersReady, - conditionReason(routersReady, "AllDeploymentsAvailable", "DeploymentsNotAvailable"), - routersMsg) + routersReady, routersReason, routersMsg) if !routersReady { allReady = false messages = append(messages, routersMsg) @@ -114,10 +118,12 @@ func (r *JumpstarterReconciler) updateStatus(ctx context.Context, js *operatorv1 // Check telemetry deployment readiness (only if enabled), clear stale condition when disabled. if js.Spec.Telemetry != nil && js.Spec.Telemetry.Enabled { telReady, telMsg := r.checkTelemetryDeploymentReady(ctx, js) + telReason := conditionReason(telReady, "DeploymentAvailable", "DeploymentNotAvailable") + if js.Spec.Telemetry.Replicas != nil && *js.Spec.Telemetry.Replicas == 0 { + telReason = "Suspended" + } setCondition(js, operatorv1alpha1.ConditionTypeTelemetryDeploymentReady, - telReady, - conditionReason(telReady, "DeploymentAvailable", "DeploymentNotAvailable"), - telMsg) + telReady, telReason, telMsg) if !telReady { allReady = false messages = append(messages, telMsg) @@ -126,16 +132,26 @@ func (r *JumpstarterReconciler) updateStatus(ctx context.Context, js *operatorv1 meta.RemoveStatusCondition(&js.Status.Conditions, operatorv1alpha1.ConditionTypeTelemetryDeploymentReady) } - // Check ExporterSet controller deployments readiness (only if configured) - if js.Spec.ExporterSets != nil && hasEnabledProvisioners(js.Spec.ExporterSets.Provisioners) { - esReady, esMsg := r.checkExporterSetControllersReady(ctx, js) - setCondition(js, operatorv1alpha1.ConditionTypeExporterSetControllersReady, - esReady, - conditionReason(esReady, "AllControllersAvailable", "ControllersNotAvailable"), - esMsg) - if !esReady { - allReady = false - messages = append(messages, esMsg) + // Check ExporterSet controller deployments readiness (only if configured). + // When ExporterSets is configured but no active provisioners remain (all disabled or + // suspended via replicas: 0), set the condition to True/Suspended so a previous stale + // False condition does not block overall readiness. + if js.Spec.ExporterSets != nil { + if hasEnabledProvisioners(js.Spec.ExporterSets.Provisioners) { + esReady, esMsg := r.checkExporterSetControllersReady(ctx, js) + setCondition(js, operatorv1alpha1.ConditionTypeExporterSetControllersReady, + esReady, + conditionReason(esReady, "AllControllersAvailable", "ControllersNotAvailable"), + esMsg) + if !esReady { + allReady = false + messages = append(messages, esMsg) + } + } else { + setCondition(js, operatorv1alpha1.ConditionTypeExporterSetControllersReady, + true, + "Suspended", + "All ExporterSet provisioners are suspended or disabled") } } @@ -288,7 +304,11 @@ func (r *JumpstarterReconciler) checkRouterCertificatesReady(ctx context.Context allReady := true var notReadyRouters []int32 - for i := int32(0); i < js.Spec.Routers.Replicas; i++ { + certRouterReplicas := int32(0) + if js.Spec.Routers.Replicas != nil { + certRouterReplicas = *js.Spec.Routers.Replicas + } + for i := int32(0); i < certRouterReplicas; i++ { secretName := GetRouterCertSecretName(js, i) secret := &corev1.Secret{} err := r.Get(ctx, types.NamespacedName{Name: secretName, Namespace: js.Namespace}, secret) @@ -322,13 +342,19 @@ func (r *JumpstarterReconciler) checkRouterCertificatesReady(ctx context.Context } if allReady { - return true, fmt.Sprintf("All %d router TLS certificates ready", js.Spec.Routers.Replicas) + return true, fmt.Sprintf("All %d router TLS certificates ready", certRouterReplicas) } return false, fmt.Sprintf("Router TLS certificates pending for replicas: %v", notReadyRouters) } -// checkControllerDeploymentReady checks if the controller deployment is available +// checkControllerDeploymentReady checks if the controller deployment is available. +// When spec.controller.replicas == 0 the deployment is intentionally suspended and +// is reported as ready (true) with a "Suspended" reason. func (r *JumpstarterReconciler) checkControllerDeploymentReady(ctx context.Context, js *operatorv1alpha1.Jumpstarter) (bool, string) { + if js.Spec.Controller.Replicas != nil && *js.Spec.Controller.Replicas == 0 { + return true, "Controller deployment suspended (replicas: 0)" + } + deploymentName := fmt.Sprintf("%s-controller", js.Name) deployment := &appsv1.Deployment{} err := r.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: js.Namespace}, deployment) @@ -352,13 +378,24 @@ func (r *JumpstarterReconciler) checkControllerDeploymentReady(ctx context.Conte return false, fmt.Sprintf("Controller deployment %s has no Available condition", deploymentName) } -// checkRouterDeploymentsReady checks if all router deployments are available +// checkRouterDeploymentsReady checks if all router deployments are available. +// When spec.routers.replicas == 0 all routers are intentionally suspended and +// are reported as ready (true) with a "Suspended" reason. func (r *JumpstarterReconciler) checkRouterDeploymentsReady(ctx context.Context, js *operatorv1alpha1.Jumpstarter) (bool, string) { + routerReplicas := int32(0) + if js.Spec.Routers.Replicas != nil { + routerReplicas = *js.Spec.Routers.Replicas + } + + if routerReplicas == 0 { + return true, "Router deployments suspended (replicas: 0)" + } + log := logf.FromContext(ctx) allReady := true var notReadyRouters []int32 - for i := int32(0); i < js.Spec.Routers.Replicas; i++ { + for i := int32(0); i < routerReplicas; i++ { deploymentName := fmt.Sprintf("%s-router-%d", js.Name, i) deployment := &appsv1.Deployment{} err := r.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: js.Namespace}, deployment) @@ -394,7 +431,7 @@ func (r *JumpstarterReconciler) checkRouterDeploymentsReady(ctx context.Context, } if allReady { - return true, fmt.Sprintf("All %d router deployments available", js.Spec.Routers.Replicas) + return true, fmt.Sprintf("All %d router deployments available", routerReplicas) } return false, fmt.Sprintf("Router deployments not available for replicas: %v", notReadyRouters) } @@ -432,7 +469,8 @@ func conditionMessage(status bool, trueMessage, falseMessage string) string { } // checkExporterSetControllersReady checks if all enabled ExporterSet provisioner -// controller deployments are available. +// controller deployments are available. Provisioners with replicas == 0 are treated +// as intentionally suspended and do not block readiness. func (r *JumpstarterReconciler) checkExporterSetControllersReady(ctx context.Context, js *operatorv1alpha1.Jumpstarter) (bool, string) { log := logf.FromContext(ctx) allReady := true @@ -444,6 +482,11 @@ func (r *JumpstarterReconciler) checkExporterSetControllersReady(ctx context.Con continue } + // A provisioner with replicas == 0 is intentionally suspended; skip readiness check. + if prov.Replicas != nil && *prov.Replicas == 0 { + continue + } + sanitized := sanitizeProvisionerName(prov.Name) deploymentName := fmt.Sprintf("%s-exporterset-%s", js.Name, sanitized) deployment := &appsv1.Deployment{} @@ -481,7 +524,13 @@ func (r *JumpstarterReconciler) checkExporterSetControllersReady(ctx context.Con } // checkTelemetryDeploymentReady checks if the telemetry deployment is available. +// When spec.telemetry.replicas == 0 the deployment is intentionally suspended and +// is reported as ready (true) with a "Suspended" reason. func (r *JumpstarterReconciler) checkTelemetryDeploymentReady(ctx context.Context, js *operatorv1alpha1.Jumpstarter) (bool, string) { + if js.Spec.Telemetry != nil && js.Spec.Telemetry.Replicas != nil && *js.Spec.Telemetry.Replicas == 0 { + return true, "Telemetry deployment suspended (replicas: 0)" + } + log := logf.FromContext(ctx) deploymentName := fmt.Sprintf("%s-telemetry", js.Name) deployment := &appsv1.Deployment{} @@ -506,12 +555,17 @@ func (r *JumpstarterReconciler) checkTelemetryDeploymentReady(ctx context.Contex return false, fmt.Sprintf("Telemetry deployment %s has no Available condition", deploymentName) } -// hasEnabledProvisioners returns true if at least one provisioner is enabled. +// hasEnabledProvisioners returns true if at least one provisioner is enabled and +// has replicas > 0 (i.e. not suspended), meaning readiness should be checked. func hasEnabledProvisioners(provisioners []operatorv1alpha1.ProvisionerConfig) bool { for _, p := range provisioners { - if p.Enabled == nil || *p.Enabled { - return true + if p.Enabled != nil && !*p.Enabled { + continue } + if p.Replicas != nil && *p.Replicas == 0 { + continue + } + return true } return false } diff --git a/controller/deploy/operator/internal/controller/jumpstarter/telemetry_test.go b/controller/deploy/operator/internal/controller/jumpstarter/telemetry_test.go index 66e86d113..d254d1a74 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/telemetry_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/telemetry_test.go @@ -49,20 +49,20 @@ var _ = Describe("Telemetry Lifecycle", func() { CertManager: operatorv1alpha1.CertManagerConfig{ Enabled: false, }, - Controller: operatorv1alpha1.ControllerConfig{ - Image: "quay.io/jumpstarter/jumpstarter:latest", - Replicas: 1, - GRPC: operatorv1alpha1.GRPCConfig{ - Endpoints: []operatorv1alpha1.Endpoint{{Address: "controller"}}, - }, + Controller: operatorv1alpha1.ControllerConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + Replicas: ptr.To(int32(1)), + GRPC: operatorv1alpha1.GRPCConfig{ + Endpoints: []operatorv1alpha1.Endpoint{{Address: "controller"}}, }, - Routers: operatorv1alpha1.RoutersConfig{ - Image: "quay.io/jumpstarter/jumpstarter:latest", - Replicas: 1, - GRPC: operatorv1alpha1.GRPCConfig{ - Endpoints: []operatorv1alpha1.Endpoint{{Address: "router"}}, - }, + }, + Routers: operatorv1alpha1.RoutersConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + Replicas: ptr.To(int32(1)), + GRPC: operatorv1alpha1.GRPCConfig{ + Endpoints: []operatorv1alpha1.Endpoint{{Address: "router"}}, }, + }, } } @@ -355,6 +355,27 @@ var _ = Describe("Telemetry Lifecycle", func() { Expect(configData).NotTo(ContainSubstring("telemetry")) }) + It("does not embed telemetry endpoint in ConfigMap when enabled=true but replicas=0 (suspended)", func() { + By("creating a Jumpstarter CR with telemetry enabled but replicas=0") + spec := makeJumpstarterSpec() + spec.Telemetry = &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + Image: "quay.io/jumpstarter-dev/jumpstarter-telemetry:latest", + Replicas: ptr.To(int32(0)), + } + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + By("verifying telemetry endpoint is absent from the controller ConfigMap") + configData := getConfigData() + Expect(configData).NotTo(ContainSubstring("telemetry"), + "suspended telemetry (replicas=0) should not embed an endpoint that has no ready pods") + }) + It("sets TelemetryDeploymentReady status condition", func() { By("creating a Jumpstarter CR with telemetry enabled") spec := makeJumpstarterSpec() @@ -503,6 +524,85 @@ var _ = Describe("Telemetry Lifecycle", func() { Expect(deployment.Spec.Template.Spec.Containers[0].Image).To( Equal("quay.io/jumpstarter-dev/jumpstarter-telemetry:v2")) }) + + It("suspends the telemetry deployment (replicas=0, enabled=true): Deployment stays at 0, Service kept, condition Suspended", func() { + By("creating a Jumpstarter CR with telemetry enabled and replicas=0") + spec := makeJumpstarterSpec() + spec.Telemetry = &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + Image: "quay.io/jumpstarter-dev/jumpstarter-telemetry:latest", + Replicas: ptr.To(int32(0)), + } + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + By("reconciling") + doReconcile() + + By("verifying the Deployment exists with 0 replicas (not deleted)") + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-telemetry", + Namespace: crNamespace, + }, deployment)).To(Succeed(), "Deployment should exist, not be deleted") + Expect(*deployment.Spec.Replicas).To(Equal(int32(0))) + + By("verifying the Service is still present") + svc := &corev1.Service{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: telemetryServiceName, + Namespace: crNamespace, + }, svc)).To(Succeed(), "Service should be preserved when suspended") + + By("verifying the TelemetryDeploymentReady condition reports Suspended (True)") + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + cond := meta.FindStatusCondition(js.Status.Conditions, operatorv1alpha1.ConditionTypeTelemetryDeploymentReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue), "suspended state should be reported as True, not False") + Expect(cond.Message).To(ContainSubstring("suspended")) + }) + + It("resumes telemetry after suspension: Deployment scales back up when replicas > 0", func() { + By("creating a Jumpstarter CR with telemetry enabled and replicas=0") + spec := makeJumpstarterSpec() + spec.Telemetry = &operatorv1alpha1.TelemetryConfig{ + Enabled: true, + Image: "quay.io/jumpstarter-dev/jumpstarter-telemetry:latest", + Replicas: ptr.To(int32(0)), + } + Expect(k8sClient.Create(ctx, &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{Name: crName, Namespace: crNamespace}, + Spec: spec, + })).To(Succeed()) + + doReconcile() + + By("verifying suspended state") + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-telemetry", + Namespace: crNamespace, + }, deployment)).To(Succeed()) + Expect(*deployment.Spec.Replicas).To(Equal(int32(0))) + + By("resuming by setting replicas=2") + js := &operatorv1alpha1.Jumpstarter{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: crName, Namespace: crNamespace}, js)).To(Succeed()) + js.Spec.Telemetry.Replicas = ptr.To(int32(2)) + Expect(k8sClient.Update(ctx, js)).To(Succeed()) + + doReconcile() + + By("verifying Deployment is scaled back up to 2") + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: crName + "-telemetry", + Namespace: crNamespace, + }, deployment)).To(Succeed()) + Expect(*deployment.Spec.Replicas).To(Equal(int32(2))) + }) }) var _ = Describe("telemetryEndpointFor", func() { From 21650bb4ab350d3e55963f6651f944483f93d470 Mon Sep 17 00:00:00 2001 From: Bella Khizgiyaev Date: Wed, 26 Aug 2026 10:36:58 +0300 Subject: [PATCH 2/2] fix: update remaining test files to use ptr.To for *int32 Replicas fields Signed-off-by: Bella Khizgiyaev --- .../controller_metrics_bind_test.go | 3 +- .../jumpstarter_controller_test.go | 16 ++++---- .../jumpstarter/router_metrics_bind_test.go | 3 +- .../deploy/operator/test/e2e/e2e_test.go | 37 ++++++++++--------- 4 files changed, 31 insertions(+), 28 deletions(-) diff --git a/controller/deploy/operator/internal/controller/jumpstarter/controller_metrics_bind_test.go b/controller/deploy/operator/internal/controller/jumpstarter/controller_metrics_bind_test.go index 320978e3e..f46173270 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/controller_metrics_bind_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/controller_metrics_bind_test.go @@ -22,6 +22,7 @@ import ( . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" ) var _ = Describe("createControllerDeployment metrics bind", func() { @@ -39,7 +40,7 @@ var _ = Describe("createControllerDeployment metrics bind", func() { Controller: operatorv1alpha1.ControllerConfig{ Image: "example.com/controller:test", ImagePullPolicy: corev1.PullIfNotPresent, - Replicas: 1, + Replicas: ptr.To(int32(1)), }, }, } diff --git a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go index d434902a5..13872dbf3 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller_test.go @@ -65,10 +65,10 @@ var _ = Describe("Jumpstarter Controller", func() { CertManager: operatorv1alpha1.CertManagerConfig{ Enabled: false, // Disable for unit tests - cert-manager CRDs not available in envtest }, - Controller: operatorv1alpha1.ControllerConfig{ - Image: "quay.io/jumpstarter/jumpstarter:latest", - ImagePullPolicy: "IfNotPresent", - Replicas: 1, + Controller: operatorv1alpha1.ControllerConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + ImagePullPolicy: "IfNotPresent", + Replicas: ptr.To(int32(1)), Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("100m"), @@ -83,10 +83,10 @@ var _ = Describe("Jumpstarter Controller", func() { }, }, }, - Routers: operatorv1alpha1.RoutersConfig{ - Image: "quay.io/jumpstarter/jumpstarter:latest", - ImagePullPolicy: "IfNotPresent", - Replicas: 1, + Routers: operatorv1alpha1.RoutersConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + ImagePullPolicy: "IfNotPresent", + Replicas: ptr.To(int32(1)), Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("100m"), diff --git a/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go b/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go index fe65dc990..a997bc914 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/router_metrics_bind_test.go @@ -22,6 +22,7 @@ import ( . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" ) var _ = Describe("createRouterDeployment metrics bind", func() { @@ -39,7 +40,7 @@ var _ = Describe("createRouterDeployment metrics bind", func() { Routers: operatorv1alpha1.RoutersConfig{ Image: "example.com/router:test", ImagePullPolicy: corev1.PullIfNotPresent, - Replicas: 1, + Replicas: ptr.To(int32(1)), }, }, } diff --git a/controller/deploy/operator/test/e2e/e2e_test.go b/controller/deploy/operator/test/e2e/e2e_test.go index 0eb6a96dd..bc318185b 100644 --- a/controller/deploy/operator/test/e2e/e2e_test.go +++ b/controller/deploy/operator/test/e2e/e2e_test.go @@ -39,6 +39,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/yaml" apiserverv1beta1 "k8s.io/apiserver/pkg/apis/apiserver/v1beta1" @@ -523,7 +524,7 @@ provisioning: }, jumpstarter) Expect(err).NotTo(HaveOccurred()) - jumpstarter.Spec.Controller.Replicas = 3 + jumpstarter.Spec.Controller.Replicas = ptr.To(int32(3)) Expect(k8sClient.Update(ctx, jumpstarter)).To(Succeed()) DeferCleanup(func() { restore := &operatorv1alpha1.Jumpstarter{} @@ -534,7 +535,7 @@ provisioning: if getErr != nil { return } - restore.Spec.Controller.Replicas = 1 + restore.Spec.Controller.Replicas = ptr.To(int32(1)) _ = k8sClient.Update(ctx, restore) }) @@ -727,7 +728,7 @@ provisioning: }, jumpstarter) Expect(err).NotTo(HaveOccurred()) - jumpstarter.Spec.Routers.Replicas = 3 + jumpstarter.Spec.Routers.Replicas = ptr.To(int32(3)) err = k8sClient.Update(ctx, jumpstarter) Expect(err).NotTo(HaveOccurred()) @@ -735,7 +736,7 @@ provisioning: allRoutersDeploymentsCreated := func(g Gomega) bool { deployment := &appsv1.Deployment{} - for i := 0; i < int(jumpstarter.Spec.Routers.Replicas); i++ { + for i := 0; i < int(*jumpstarter.Spec.Routers.Replicas); i++ { err := k8sClient.Get(ctx, types.NamespacedName{ Name: fmt.Sprintf("jumpstarter-router-%d", i), Namespace: dynamicTestNamespace, @@ -752,7 +753,7 @@ provisioning: By("verifying the new router services were created") allRoutersServicesCreated := func(g Gomega) bool { service := &corev1.Service{} - for i := 0; i < int(jumpstarter.Spec.Routers.Replicas); i++ { + for i := 0; i < int(*jumpstarter.Spec.Routers.Replicas); i++ { err := k8sClient.Get(ctx, types.NamespacedName{ Name: fmt.Sprintf("jumpstarter-router-%d-np", i), Namespace: dynamicTestNamespace, @@ -784,7 +785,7 @@ provisioning: }, jumpstarter) Expect(err).NotTo(HaveOccurred()) - jumpstarter.Spec.Routers.Replicas = 1 + jumpstarter.Spec.Routers.Replicas = ptr.To(int32(1)) err = k8sClient.Update(ctx, jumpstarter) Expect(err).NotTo(HaveOccurred()) @@ -2049,20 +2050,20 @@ dSignatureRotatedSignatureRotatedSignatureRotatedSignatureRotatedSig== }, Spec: operatorv1alpha1.JumpstarterSpec{ BaseDomain: "apps-crc.testing", - Controller: operatorv1alpha1.ControllerConfig{ - Image: image, - ImagePullPolicy: corev1.PullIfNotPresent, - Replicas: 1, - GRPC: operatorv1alpha1.GRPCConfig{ - Endpoints: []operatorv1alpha1.Endpoint{ - {Address: fmt.Sprintf("grpc.%s:8082", jwtCATestNamespace)}, - }, + Controller: operatorv1alpha1.ControllerConfig{ + Image: image, + ImagePullPolicy: corev1.PullIfNotPresent, + Replicas: ptr.To(int32(1)), + GRPC: operatorv1alpha1.GRPCConfig{ + Endpoints: []operatorv1alpha1.Endpoint{ + {Address: fmt.Sprintf("grpc.%s:8082", jwtCATestNamespace)}, }, }, - Routers: operatorv1alpha1.RoutersConfig{ - Image: image, - ImagePullPolicy: corev1.PullIfNotPresent, - Replicas: 1, + }, + Routers: operatorv1alpha1.RoutersConfig{ + Image: image, + ImagePullPolicy: corev1.PullIfNotPresent, + Replicas: ptr.To(int32(1)), GRPC: operatorv1alpha1.GRPCConfig{ Endpoints: []operatorv1alpha1.Endpoint{ {Address: fmt.Sprintf("router.%s:8083", jwtCATestNamespace)},