@@ -27,6 +27,7 @@ func TestHasOwnership(t *testing.T) {
2727 operator := & ElasticsearchOperator {
2828 operatorID : "my-operator" ,
2929 }
30+
3031 assert .True (t , operator .hasOwnership (eds ))
3132
3233 eds .Annotations [esOperatorAnnotationKey ] = "not-my-operator"
@@ -310,3 +311,107 @@ func TestEDSReplicas(t *testing.T) {
310311 })
311312 }
312313}
314+
315+ // TestScaleToZeroPrevention validates that the defensive logic in scaleEDS prevents
316+ // writing 0 to spec.replicas when it would violate minReplicas.
317+ // This test documents the fix for the regression introduced in PR #511 where:
318+ // - kubectl patch operations could leave spec.replicas as nil
319+ // - edsReplicas would return 0 for autoscaling-enabled EDS
320+ // - When autoscaler returned no-op (e.g., excludeSystemIndices filters all indices),
321+ // spec.replicas would be written as 0, violating minReplicas
322+ func TestScaleToZeroPrevention (t * testing.T ) {
323+ minReplicas := int32 (3 )
324+ maxReplicas := int32 (10 )
325+ statusReplicas := int32 (5 )
326+
327+ for _ , tc := range []struct {
328+ name string
329+ eds * zv1.ElasticsearchDataSet
330+ expectedReplicas int32
331+ description string
332+ }{
333+ {
334+ name : "nil replicas + status replicas set -> use status" ,
335+ eds : & zv1.ElasticsearchDataSet {
336+ ObjectMeta : metav1.ObjectMeta {
337+ Name : "test-eds" ,
338+ Namespace : "default" ,
339+ },
340+ Spec : zv1.ElasticsearchDataSetSpec {
341+ Replicas : nil , // Cleared by kubectl patch
342+ Scaling : & zv1.ElasticsearchDataSetScaling {
343+ Enabled : true ,
344+ MinReplicas : minReplicas ,
345+ MaxReplicas : maxReplicas ,
346+ },
347+ },
348+ Status : zv1.ElasticsearchDataSetStatus {
349+ Replicas : statusReplicas , // Actually running 5 replicas
350+ },
351+ },
352+ expectedReplicas : statusReplicas ,
353+ description : "Should use status.replicas (5) to preserve current state" ,
354+ },
355+ {
356+ name : "nil replicas + status replicas zero -> use minReplicas" ,
357+ eds : & zv1.ElasticsearchDataSet {
358+ ObjectMeta : metav1.ObjectMeta {
359+ Name : "test-eds-new" ,
360+ Namespace : "default" ,
361+ },
362+ Spec : zv1.ElasticsearchDataSetSpec {
363+ Replicas : nil , // Not yet initialized
364+ Scaling : & zv1.ElasticsearchDataSetScaling {
365+ Enabled : true ,
366+ MinReplicas : minReplicas ,
367+ MaxReplicas : maxReplicas ,
368+ },
369+ },
370+ Status : zv1.ElasticsearchDataSetStatus {
371+ Replicas : 0 , // New EDS not yet running
372+ },
373+ },
374+ expectedReplicas : minReplicas ,
375+ description : "Should use minReplicas (3) for new/uninitialized EDS" ,
376+ },
377+ {
378+ name : "nil replicas + no minReplicas -> use status" ,
379+ eds : & zv1.ElasticsearchDataSet {
380+ ObjectMeta : metav1.ObjectMeta {
381+ Name : "test-eds-no-min" ,
382+ Namespace : "default" ,
383+ },
384+ Spec : zv1.ElasticsearchDataSetSpec {
385+ Replicas : nil ,
386+ Scaling : & zv1.ElasticsearchDataSetScaling {
387+ Enabled : true ,
388+ MinReplicas : 0 , // No minimum set
389+ MaxReplicas : maxReplicas ,
390+ },
391+ },
392+ Status : zv1.ElasticsearchDataSetStatus {
393+ Replicas : statusReplicas ,
394+ },
395+ },
396+ expectedReplicas : 0 , // No defensive logic applied when minReplicas is 0
397+ description : "Should return 0 when minReplicas is not set" ,
398+ },
399+ } {
400+ t .Run (tc .name , func (t * testing.T ) {
401+ // Simulate what happens in scaleEDS
402+ currentReplicas := edsReplicas (tc .eds )
403+ scaling := tc .eds .Spec .Scaling
404+
405+ // Apply the defensive logic from scaleEDS
406+ if currentReplicas == 0 && scaling != nil && scaling .MinReplicas > 0 {
407+ if tc .eds .Status .Replicas > 0 {
408+ currentReplicas = tc .eds .Status .Replicas
409+ } else {
410+ currentReplicas = scaling .MinReplicas
411+ }
412+ }
413+
414+ assert .Equal (t , tc .expectedReplicas , currentReplicas , tc .description )
415+ })
416+ }
417+ }
0 commit comments