Files
wehub-resource-sync e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

1823 lines
63 KiB
Go

// Copyright 2025 Alibaba Group Holding Ltd.
//
// 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 controller
import (
"context"
"encoding/json"
stderrors "errors"
"time"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/apimachinery/pkg/util/rand"
"k8s.io/client-go/util/retry"
"k8s.io/utils/ptr"
kclient "sigs.k8s.io/controller-runtime/pkg/client"
"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/controller/recycle"
"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/fieldindex"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1"
)
var _ = Describe("Pool scale", func() {
var (
timeout = 10 * time.Second
interval = 1 * time.Second
)
Context("When reconciling a resource", func() {
const resourceName = "pool-scale-test"
ctx := context.Background()
typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default",
}
BeforeEach(func() {
By("creating the custom resource for the Kind Pool")
typeNamespacedName.Name = resourceName + "-" + rand.String(8)
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 2,
BufferMin: 1,
BufferMax: 1,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
cnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
})
AfterEach(func() {
resource := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
if err != nil {
if !errors.IsNotFound(err) {
Expect(err).NotTo(HaveOccurred())
} else {
By("The specific resource instance Pool already deleted")
return
}
}
By("Cleanup the specific resource instance Pool")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
})
It("should successfully update pool status", func() {
pool := &sandboxv1alpha1.Pool{}
Eventually(func(g Gomega) {
if err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {
return
}
cnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
})
It("should successfully scale out pool buffer size", func() {
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
pool.Spec.CapacitySpec.BufferMin = 2
pool.Spec.CapacitySpec.BufferMax = 2
pool.Spec.ScaleStrategy = &sandboxv1alpha1.ScaleStrategy{
MaxUnavailable: ptr.To(intstr.FromString("100%")),
}
Expect(k8sClient.Update(ctx, pool)).To(Succeed())
Eventually(func(g Gomega) {
if err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {
return
}
cnt := int32(2)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
})
It("should successfully scale out buffer limit by pool max", func() {
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
pool.Spec.CapacitySpec.PoolMax = 2
pool.Spec.CapacitySpec.BufferMin = 3
pool.Spec.CapacitySpec.BufferMax = 3
pool.Spec.ScaleStrategy = &sandboxv1alpha1.ScaleStrategy{
MaxUnavailable: ptr.To(intstr.FromString("100%")),
}
Expect(k8sClient.Update(ctx, pool)).To(Succeed())
Eventually(func(g Gomega) {
if err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {
return
}
cnt := int32(2)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
})
It("should successfully scale in pool buffer size", func() {
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
pool.Spec.CapacitySpec.BufferMin = 0
pool.Spec.CapacitySpec.BufferMax = 0
Expect(k8sClient.Update(ctx, pool)).To(Succeed())
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
if err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {
return
}
cnt := int32(0)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
})
It("should successfully scale in buffer limit by pool min", func() {
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
pool.Spec.CapacitySpec.PoolMax = 1
pool.Spec.CapacitySpec.PoolMin = 1
pool.Spec.CapacitySpec.BufferMin = 0
pool.Spec.CapacitySpec.BufferMax = 0
Expect(k8sClient.Update(ctx, pool)).To(Succeed())
Eventually(func(g Gomega) {
if err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {
return
}
cnt := int32(1)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
})
})
})
var _ = Describe("Pool update", func() {
var (
timeout = 10 * time.Second
interval = 1 * time.Second
)
Context("When reconciling a resource", func() {
const resourceName = "pool-update-test"
ctx := context.Background()
typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default",
}
BeforeEach(func() {
By("creating the custom resource for the Kind Pool")
typeNamespacedName.Name = resourceName + "-" + rand.String(8)
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 2,
BufferMin: 1,
BufferMax: 1,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
cnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
Expect(err).NotTo(HaveOccurred())
pods := &v1.PodList{}
Expect(k8sClient.List(ctx, pods, &kclient.ListOptions{
Namespace: typeNamespacedName.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),
})).To(Succeed())
// Mock pod running and ready
for _, pod := range pods.Items {
pod.Status.Phase = v1.PodRunning
pod.Status.Conditions = []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionTrue}}
Expect(k8sClient.Status().Update(ctx, &pod)).To(Succeed())
}
})
AfterEach(func() {
resource := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
if err != nil {
if !errors.IsNotFound(err) {
Expect(err).NotTo(HaveOccurred())
} else {
By("The specific resource instance Pool already deleted")
return
}
}
By("Cleanup the specific resource instance Pool")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
})
It("should successfully update pool revision", func() {
var oldRevision string
Expect(retry.RetryOnConflict(retry.DefaultRetry, func() error {
pool := &sandboxv1alpha1.Pool{}
if err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {
return err
}
if oldRevision == "" {
oldRevision = pool.Status.Revision
}
pool.Spec.Template.Labels = map[string]string{
"test.pool.update": "v1",
}
return k8sClient.Update(ctx, pool)
})).Should(Succeed())
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
cnt := int32(1)
g.Expect(pool.Status.Revision).NotTo(Equal(oldRevision))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
})
It("should successfully update pool with allocated pod", func() {
pool := &sandboxv1alpha1.Pool{}
sbxNamespaceName := types.NamespacedName{
Name: "sandbox-test-" + rand.String(8),
Namespace: typeNamespacedName.Namespace,
}
sandbox := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: sbxNamespaceName.Name,
Namespace: sbxNamespaceName.Namespace,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
PoolRef: typeNamespacedName.Name,
},
}
Expect(k8sClient.Create(ctx, sandbox)).To(Succeed())
// wait allocation
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(ctx, sbxNamespaceName, sandbox)).To(Succeed())
alloc, err := getSandboxAllocation(sandbox)
Expect(err).NotTo(HaveOccurred())
g.Expect(alloc.Pods).NotTo(BeEmpty())
}, timeout, interval).Should(Succeed())
Expect(k8sClient.Get(ctx, sbxNamespaceName, sandbox)).To(Succeed())
sbxAlloc, err := getSandboxAllocation(sandbox)
Expect(err).NotTo(HaveOccurred())
Expect(len(sbxAlloc.Pods)).To(Equal(1))
// check pool allocation
err = k8sClient.Get(ctx, typeNamespacedName, pool)
Expect(err).NotTo(HaveOccurred())
allocation, err := getPoolAllocation(pool)
Expect(err).NotTo(HaveOccurred())
Expect(len(allocation.PodAllocation)).To(Equal(1))
Expect(allocation.PodAllocation[sbxAlloc.Pods[0]]).To(Equal(sandbox.Name))
// update pool
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
oldRevision := pool.Status.Revision
pool.Spec.Template.Labels = map[string]string{
"test.pool.update": "v1",
}
pool.Spec.ScaleStrategy = &sandboxv1alpha1.ScaleStrategy{
MaxUnavailable: ptr.To(intstr.FromString("100%")),
}
Expect(k8sClient.Update(ctx, pool)).To(Succeed())
Eventually(func(g Gomega) {
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
cnt := int32(2)
g.Expect(pool.Status.Revision).NotTo(Equal(oldRevision))
g.Expect(pool.Status.Total).To(Equal(cnt))
pods := &v1.PodList{}
Expect(k8sClient.List(ctx, pods, &kclient.ListOptions{
Namespace: typeNamespacedName.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),
})).To(Succeed())
for _, pod := range pods.Items {
if pod.Name == sbxAlloc.Pods[0] {
g.Expect(pod.DeletionTimestamp).To(BeNil())
g.Expect(pod.Labels[LabelPoolRevision]).To(Equal(oldRevision))
continue
}
if pod.DeletionTimestamp != nil {
continue
}
g.Expect(pod.Labels[LabelPoolRevision]).NotTo(Equal(oldRevision))
}
}, timeout, interval).Should(Succeed())
Expect(k8sClient.Delete(ctx, sandbox)).To(Succeed())
})
})
})
var _ = Describe("Pool allocate", func() {
var (
timeout = 10 * time.Second
interval = 1 * time.Second
)
Context("When reconciling a resource", func() {
const resourceName = "pool-allocate-test"
ctx := context.Background()
typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default",
}
BeforeEach(func() {
By("creating the custom resource for the Kind Pool")
typeNamespacedName.Name = resourceName + "-" + rand.String(8)
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 2,
BufferMin: 1,
BufferMax: 1,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
cnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
Expect(err).NotTo(HaveOccurred())
pods := &v1.PodList{}
Expect(k8sClient.List(ctx, pods, &kclient.ListOptions{
Namespace: typeNamespacedName.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),
})).To(Succeed())
// Mock pod running and ready
for _, pod := range pods.Items {
pod.Status.Phase = v1.PodRunning
pod.Status.Conditions = []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionTrue}}
Expect(k8sClient.Status().Update(ctx, &pod)).To(Succeed())
}
})
AfterEach(func() {
resource := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
if err != nil {
if !errors.IsNotFound(err) {
Expect(err).NotTo(HaveOccurred())
} else {
By("The specific resource instance Pool already deleted")
return
}
}
By("Cleanup the specific resource instance Pool")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
})
It("should successfully allocate pool pod to batch sandbox and release", func() {
pool := &sandboxv1alpha1.Pool{}
bsbxNamespaceName := types.NamespacedName{
Name: "batch-sandbox-test-" + rand.String(8),
Namespace: typeNamespacedName.Namespace,
}
batchSandbox := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: bsbxNamespaceName.Name,
Namespace: bsbxNamespaceName.Namespace,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
PoolRef: typeNamespacedName.Name,
},
}
Expect(k8sClient.Create(ctx, batchSandbox)).To(Succeed())
// wait allocation
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
alloc, err := getSandboxAllocation(batchSandbox)
Expect(err).NotTo(HaveOccurred())
g.Expect(alloc.Pods).NotTo(BeEmpty())
}, timeout, interval).Should(Succeed())
Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
sbxAlloc, err := getSandboxAllocation(batchSandbox)
Expect(err).NotTo(HaveOccurred())
Expect(len(sbxAlloc.Pods)).To(Equal(1))
// check pool allocation
err = k8sClient.Get(ctx, typeNamespacedName, pool)
Expect(err).NotTo(HaveOccurred())
allocation, err := getPoolAllocation(pool)
Expect(err).NotTo(HaveOccurred())
Expect(len(allocation.PodAllocation)).To(Equal(1))
Expect(allocation.PodAllocation[sbxAlloc.Pods[0]]).To(Equal(batchSandbox.Name))
// release
release := AllocationRelease{
Pods: sbxAlloc.Pods,
}
js, err := json.Marshal(release)
Expect(err).NotTo(HaveOccurred())
batchSandbox.Annotations[AnnoAllocReleaseKey] = string(js)
err = k8sClient.Update(ctx, batchSandbox)
Expect(err).NotTo(HaveOccurred())
// wait release
Eventually(func(g Gomega) {
err = k8sClient.Get(ctx, typeNamespacedName, pool)
Expect(err).NotTo(HaveOccurred())
allocation, err = getPoolAllocation(pool)
Expect(err).NotTo(HaveOccurred())
g.Expect(len(allocation.PodAllocation)).To(Equal(0))
}, timeout, interval).Should(Succeed())
Expect(k8sClient.Delete(ctx, batchSandbox)).To(Succeed())
})
It("should GC allocation when a pooled BatchSandbox detaches from its pool", func() {
pool := &sandboxv1alpha1.Pool{}
bsbxNamespaceName := types.NamespacedName{
Name: "batch-sandbox-solidify-test-" + rand.String(8),
Namespace: typeNamespacedName.Namespace,
}
batchSandbox := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: bsbxNamespaceName.Name,
Namespace: bsbxNamespaceName.Namespace,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
PoolRef: typeNamespacedName.Name,
},
}
Expect(k8sClient.Create(ctx, batchSandbox)).To(Succeed())
var allocatedPod string
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
alloc, err := getSandboxAllocation(batchSandbox)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(alloc.Pods).To(HaveLen(1))
allocatedPod = alloc.Pods[0]
}, timeout, interval).Should(Succeed())
Expect(allocatedPod).NotTo(BeEmpty())
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
Expect(retry.RetryOnConflict(retry.DefaultRetry, func() error {
latest := &sandboxv1alpha1.BatchSandbox{}
if err := k8sClient.Get(ctx, bsbxNamespaceName, latest); err != nil {
return err
}
latest.Spec.Template = pool.Spec.Template.DeepCopy()
latest.Spec.PoolRef = ""
return k8sClient.Update(ctx, latest)
})).To(Succeed())
maxUnavailable := intstr.FromString("100%")
Expect(retry.RetryOnConflict(retry.DefaultRetry, func() error {
latestPool := &sandboxv1alpha1.Pool{}
if err := k8sClient.Get(ctx, typeNamespacedName, latestPool); err != nil {
return err
}
latestPool.Spec.ScaleStrategy = &sandboxv1alpha1.ScaleStrategy{
MaxUnavailable: &maxUnavailable,
}
return k8sClient.Update(ctx, latestPool)
})).To(Succeed())
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
allocation, err := getPoolAllocation(pool)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(allocation.PodAllocation).NotTo(HaveKey(allocatedPod))
pod := &v1.Pod{}
err = k8sClient.Get(ctx, types.NamespacedName{
Name: allocatedPod,
Namespace: typeNamespacedName.Namespace,
}, pod)
if err == nil {
g.Expect(pod.DeletionTimestamp).NotTo(BeNil())
} else {
g.Expect(errors.IsNotFound(err)).To(BeTrue())
}
}, 5*time.Second, interval).Should(Succeed())
Expect(k8sClient.Delete(ctx, batchSandbox)).To(Succeed())
})
})
})
func getSandboxAllocation(obj kclient.Object) (*SandboxAllocation, error) {
allocation := &SandboxAllocation{}
anno := obj.GetAnnotations()
if anno == nil {
return allocation, nil
}
str, ok := anno[AnnoAllocStatusKey]
if !ok {
return allocation, nil
}
err := json.Unmarshal([]byte(str), allocation)
if err != nil {
return nil, err
}
return allocation, nil
}
func getPoolAllocation(pool *sandboxv1alpha1.Pool) (*PoolAllocation, error) {
store := NewInMemoryAllocationStore()
if err := store.Recover(ctx, k8sClient); err != nil {
return nil, err
}
return store.GetAllocation(ctx, pool)
}
var _ = Describe("Pool deletion and recreation", func() {
var (
timeout = 10 * time.Second
interval = 1 * time.Second
)
Context("When deleting and recreating a Pool with same name", func() {
const resourceName = "pool-recreate-test"
ctx := context.Background()
typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default",
}
BeforeEach(func() {
By("creating the custom resource for the Kind Pool")
typeNamespacedName.Name = resourceName + "-" + rand.String(8)
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 2,
BufferMin: 1,
BufferMax: 1,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
cnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
})
AfterEach(func() {
resource := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
if err != nil {
if !errors.IsNotFound(err) {
Expect(err).NotTo(HaveOccurred())
}
} else {
By("Cleanup the specific resource instance Pool")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
}
})
It("should allow recreating a Pool with the same name after deletion", func() {
By("deleting the existing Pool")
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
Expect(k8sClient.Delete(ctx, pool)).To(Succeed())
By("waiting for the Pool to be fully deleted")
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(errors.IsNotFound(err)).To(BeTrue(), "Pool should be deleted")
}, timeout, interval).Should(Succeed())
By("recreating a Pool with the same name")
newPool := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 2,
BufferMin: 1,
BufferMax: 1,
},
},
}
Expect(k8sClient.Create(ctx, newPool)).To(Succeed())
By("verifying the new Pool is successfully reconciled and creates expected pods")
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
cnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt), "new Pool should have correct total pod count")
}, timeout, interval).Should(Succeed())
})
It("should clear allocation when recreating a Pool with the same name", func() {
By("setting pool pods to running state")
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
pods := &v1.PodList{}
Expect(k8sClient.List(ctx, pods, &kclient.ListOptions{
Namespace: typeNamespacedName.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),
})).To(Succeed())
// Mock pod running and ready
for _, pod := range pods.Items {
pod.Status.Phase = v1.PodRunning
pod.Status.Conditions = []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionTrue}}
Expect(k8sClient.Status().Update(ctx, &pod)).To(Succeed())
}
By("creating a BatchSandbox to allocate pods from the pool")
bsbxNamespaceName := types.NamespacedName{
Name: "batch-sandbox-recreate-test-" + rand.String(8),
Namespace: typeNamespacedName.Namespace,
}
batchSandbox := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: bsbxNamespaceName.Name,
Namespace: bsbxNamespaceName.Namespace,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
PoolRef: typeNamespacedName.Name,
},
}
Expect(k8sClient.Create(ctx, batchSandbox)).To(Succeed())
By("waiting for allocation to complete")
var allocatedPod string
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
alloc, err := getSandboxAllocation(batchSandbox)
Expect(err).NotTo(HaveOccurred())
g.Expect(alloc.Pods).NotTo(BeEmpty())
allocatedPod = alloc.Pods[0]
}, timeout, interval).Should(Succeed())
By("verifying pool allocation exists")
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
allocation, err := getPoolAllocation(pool)
Expect(err).NotTo(HaveOccurred())
Expect(len(allocation.PodAllocation)).To(Equal(1))
Expect(allocation.PodAllocation[allocatedPod]).To(Equal(batchSandbox.Name))
By("deleting the BatchSandbox")
Expect(k8sClient.Delete(ctx, batchSandbox)).To(Succeed())
Eventually(func(g Gomega) {
err := k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)
g.Expect(errors.IsNotFound(err)).To(BeTrue())
}, timeout, interval).Should(Succeed())
By("deleting the Pool")
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
Expect(k8sClient.Delete(ctx, pool)).To(Succeed())
By("waiting for the Pool to be fully deleted")
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(errors.IsNotFound(err)).To(BeTrue(), "Pool should be deleted")
}, timeout, interval).Should(Succeed())
By("recreating a Pool with the same name")
newPool := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 2,
BufferMin: 1,
BufferMax: 1,
},
},
}
Expect(k8sClient.Create(ctx, newPool)).To(Succeed())
By("verifying the new Pool has empty allocation")
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
allocation, err := getPoolAllocation(pool)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(len(allocation.PodAllocation)).To(Equal(0), "new Pool should have empty allocation, not reuse old allocation")
}, timeout, interval).Should(Succeed())
})
})
})
var _ = Describe("Pool scaleStrategy", func() {
var (
timeout = 10 * time.Second
interval = 1 * time.Second
)
Context("When reconciling a resource with scaleStrategy", func() {
const resourceName = "pool-scale-strategy-test"
ctx := context.Background()
typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default",
}
BeforeEach(func() {
By("creating the custom resource for the Kind Pool")
typeNamespacedName.Name = resourceName + "-" + rand.String(8)
})
AfterEach(func() {
resource := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
if err != nil {
if !errors.IsNotFound(err) {
Expect(err).NotTo(HaveOccurred())
} else {
By("The specific resource instance Pool already deleted")
return
}
}
By("Cleanup the specific resource instance Pool")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
})
It("should scale with default 25% maxUnavailable when not specified", func() {
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 10,
BufferMin: 10,
BufferMax: 10,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
// With default 25% maxUnavailable and target 10 pods,
// first batch should create at most 3 pods (ceil(10 * 0.25) = 3)
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
// Initial creation should be limited to 3 (or less if pods become ready)
g.Expect(pool.Status.Total).To(BeNumerically("<=", 3))
}, timeout, interval).Should(Succeed())
})
It("should scale with percentage maxUnavailable", func() {
maxUnavailable := intstr.FromString("20%")
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 10,
BufferMin: 10,
BufferMax: 10,
},
ScaleStrategy: &sandboxv1alpha1.ScaleStrategy{
MaxUnavailable: &maxUnavailable,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
// With 20% maxUnavailable and target 10 pods,
// first batch should create at most 2 pods
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(pool.Status.Total).To(BeNumerically("<=", 2))
}, timeout, interval).Should(Succeed())
})
It("should scale with integer maxUnavailable", func() {
maxUnavailable := intstr.FromInt(1)
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 5,
BufferMin: 5,
BufferMax: 5,
},
ScaleStrategy: &sandboxv1alpha1.ScaleStrategy{
MaxUnavailable: &maxUnavailable,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
// With maxUnavailable=1, only 1 pod should be created at a time
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(pool.Status.Total).To(BeNumerically("<=", 1))
}, timeout, interval).Should(Succeed())
})
})
})
var _ = Describe("Pool recycle", func() {
var (
timeout = 10 * time.Second
interval = 1 * time.Second
)
Context("When reconciling a resource with RecycleStrategy", func() {
const resourceName = "pool-recycle-test"
ctx := context.Background()
typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default",
}
BeforeEach(func() {
By("randomizing pool name for isolation")
typeNamespacedName.Name = resourceName + "-" + rand.String(8)
})
AfterEach(func() {
resource := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
if err != nil {
if !errors.IsNotFound(err) {
Expect(err).NotTo(HaveOccurred())
} else {
By("The specific resource instance Pool already deleted")
return
}
}
By("Cleanup the specific resource instance Pool")
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
})
It("should create pool with Delete recycle strategy and release pods via deletion", func() {
By("creating a Pool with explicit Delete RecycleStrategy")
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 2,
BufferMin: 1,
BufferMax: 1,
},
RecycleStrategy: &sandboxv1alpha1.RecycleStrategy{
Type: sandboxv1alpha1.RecycleTypeDelete,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
By("waiting for Pool to be ready")
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
cnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
By("setting pool pods to running state")
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
pods := &v1.PodList{}
Expect(k8sClient.List(ctx, pods, &kclient.ListOptions{
Namespace: typeNamespacedName.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),
})).To(Succeed())
for _, pod := range pods.Items {
pod.Status.Phase = v1.PodRunning
pod.Status.Conditions = []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionTrue}}
Expect(k8sClient.Status().Update(ctx, &pod)).To(Succeed())
}
By("creating a BatchSandbox to allocate pods from the pool")
bsbxNamespaceName := types.NamespacedName{
Name: "batch-sandbox-recycle-test-" + rand.String(8),
Namespace: typeNamespacedName.Namespace,
}
batchSandbox := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: bsbxNamespaceName.Name,
Namespace: bsbxNamespaceName.Namespace,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
PoolRef: typeNamespacedName.Name,
},
}
Expect(k8sClient.Create(ctx, batchSandbox)).To(Succeed())
By("waiting for allocation")
var allocatedPodName string
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
alloc, err := getSandboxAllocation(batchSandbox)
Expect(err).NotTo(HaveOccurred())
g.Expect(alloc.Pods).NotTo(BeEmpty())
allocatedPodName = alloc.Pods[0]
}, timeout, interval).Should(Succeed())
By("releasing pods by updating the BatchSandbox alloc-release annotation")
release := AllocationRelease{
Pods: []string{allocatedPodName},
}
js, err := json.Marshal(release)
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
if batchSandbox.Annotations == nil {
batchSandbox.Annotations = make(map[string]string)
}
batchSandbox.Annotations[AnnoAllocReleaseKey] = string(js)
Expect(k8sClient.Update(ctx, batchSandbox)).To(Succeed())
By("verifying the pool allocation is cleared after Delete recycle")
Eventually(func(g Gomega) {
err := k8sClient.Get(ctx, typeNamespacedName, pool)
Expect(err).NotTo(HaveOccurred())
allocation, err := getPoolAllocation(pool)
Expect(err).NotTo(HaveOccurred())
g.Expect(len(allocation.PodAllocation)).To(Equal(0), "Pool allocation should be cleared after Delete recycle")
}, timeout, interval).Should(Succeed())
By("verifying the released pod is deleted with Delete recycle strategy")
Eventually(func(g Gomega) {
pod := &v1.Pod{}
err := k8sClient.Get(ctx, types.NamespacedName{Name: allocatedPodName, Namespace: typeNamespacedName.Namespace}, pod)
if err != nil && errors.IsNotFound(err) {
return // pod already gone
}
g.Expect(err).NotTo(HaveOccurred())
g.Expect(pod.DeletionTimestamp).NotTo(BeNil(), "pod should be terminating with Delete recycle strategy")
}, timeout, interval).Should(Succeed())
Expect(k8sClient.Delete(ctx, batchSandbox)).To(Succeed())
})
It("should create pool with default (no explicit) RecycleStrategy which behaves as Delete", func() {
By("creating a Pool without explicit RecycleStrategy")
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 2,
BufferMin: 1,
BufferMax: 1,
},
// No RecycleStrategy set - should default to Delete
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
By("waiting for Pool to be ready")
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
cnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
By("setting pool pods to running state")
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
pods := &v1.PodList{}
Expect(k8sClient.List(ctx, pods, &kclient.ListOptions{
Namespace: typeNamespacedName.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),
})).To(Succeed())
for _, pod := range pods.Items {
pod.Status.Phase = v1.PodRunning
pod.Status.Conditions = []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionTrue}}
Expect(k8sClient.Status().Update(ctx, &pod)).To(Succeed())
}
By("creating a BatchSandbox to allocate pods from the pool")
bsbxNamespaceName := types.NamespacedName{
Name: "batch-sandbox-default-recycle-test-" + rand.String(8),
Namespace: typeNamespacedName.Namespace,
}
batchSandbox := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: bsbxNamespaceName.Name,
Namespace: bsbxNamespaceName.Namespace,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
PoolRef: typeNamespacedName.Name,
},
}
Expect(k8sClient.Create(ctx, batchSandbox)).To(Succeed())
By("waiting for allocation")
var allocatedPodName string
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
alloc, err := getSandboxAllocation(batchSandbox)
Expect(err).NotTo(HaveOccurred())
g.Expect(alloc.Pods).NotTo(BeEmpty())
allocatedPodName = alloc.Pods[0]
}, timeout, interval).Should(Succeed())
By("releasing pods by updating the BatchSandbox alloc-release annotation")
release := AllocationRelease{
Pods: []string{allocatedPodName},
}
js, err := json.Marshal(release)
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
if batchSandbox.Annotations == nil {
batchSandbox.Annotations = make(map[string]string)
}
batchSandbox.Annotations[AnnoAllocReleaseKey] = string(js)
Expect(k8sClient.Update(ctx, batchSandbox)).To(Succeed())
By("verifying the released pod is deleted (default RecycleStrategy = Delete)")
Eventually(func(g Gomega) {
pod := &v1.Pod{}
err := k8sClient.Get(ctx, types.NamespacedName{Name: allocatedPodName, Namespace: typeNamespacedName.Namespace}, pod)
if err != nil && errors.IsNotFound(err) {
return // pod already gone
}
g.Expect(err).NotTo(HaveOccurred())
g.Expect(pod.DeletionTimestamp).NotTo(BeNil(), "pod should be terminating with default Delete recycle strategy")
}, timeout, interval).Should(Succeed())
Expect(k8sClient.Delete(ctx, batchSandbox)).To(Succeed())
})
It("should delete multiple pods when BatchSandbox releases with Delete recycle strategy", func() {
By("creating a Pool with Delete RecycleStrategy and larger capacity")
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 4,
BufferMin: 2,
BufferMax: 2,
},
ScaleStrategy: &sandboxv1alpha1.ScaleStrategy{
MaxUnavailable: ptr.To(intstr.FromString("100%")),
},
RecycleStrategy: &sandboxv1alpha1.RecycleStrategy{
Type: sandboxv1alpha1.RecycleTypeDelete,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
By("waiting for Pool to be ready")
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
cnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
By("setting pool pods to running state")
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
pods := &v1.PodList{}
Expect(k8sClient.List(ctx, pods, &kclient.ListOptions{
Namespace: typeNamespacedName.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),
})).To(Succeed())
for _, pod := range pods.Items {
pod.Status.Phase = v1.PodRunning
pod.Status.Conditions = []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionTrue}}
Expect(k8sClient.Status().Update(ctx, &pod)).To(Succeed())
}
By("creating a BatchSandbox with 2 replicas")
bsbxNamespaceName := types.NamespacedName{
Name: "batch-sandbox-multi-delete-" + rand.String(8),
Namespace: typeNamespacedName.Namespace,
}
batchSandbox := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: bsbxNamespaceName.Name,
Namespace: bsbxNamespaceName.Namespace,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(2)),
PoolRef: typeNamespacedName.Name,
},
}
Expect(k8sClient.Create(ctx, batchSandbox)).To(Succeed())
By("waiting for allocation of 2 pods")
var allocatedPodNames []string
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
alloc, err := getSandboxAllocation(batchSandbox)
Expect(err).NotTo(HaveOccurred())
g.Expect(alloc.Pods).To(HaveLen(2))
allocatedPodNames = alloc.Pods
}, timeout, interval).Should(Succeed())
By("releasing all pods")
release := AllocationRelease{
Pods: allocatedPodNames,
}
js, err := json.Marshal(release)
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
if batchSandbox.Annotations == nil {
batchSandbox.Annotations = make(map[string]string)
}
batchSandbox.Annotations[AnnoAllocReleaseKey] = string(js)
Expect(k8sClient.Update(ctx, batchSandbox)).To(Succeed())
By("verifying all released pods are deleted")
Eventually(func(g Gomega) {
for _, podName := range allocatedPodNames {
pod := &v1.Pod{}
err := k8sClient.Get(ctx, types.NamespacedName{Name: podName, Namespace: typeNamespacedName.Namespace}, pod)
if err != nil && errors.IsNotFound(err) {
continue
}
g.Expect(err).NotTo(HaveOccurred())
g.Expect(pod.DeletionTimestamp).NotTo(BeNil(), "pod %s should be terminating", podName)
}
}, timeout, interval).Should(Succeed())
Expect(k8sClient.Delete(ctx, batchSandbox)).To(Succeed())
})
It("should create pool with Noop recycle strategy and keep pods available after release", func() {
By("creating a Pool with Noop RecycleStrategy")
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 2,
PoolMax: 4,
BufferMin: 2,
BufferMax: 2,
},
ScaleStrategy: &sandboxv1alpha1.ScaleStrategy{
MaxUnavailable: ptr.To(intstr.FromString("100%")),
},
RecycleStrategy: &sandboxv1alpha1.RecycleStrategy{
Type: sandboxv1alpha1.RecycleTypeNoop,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
By("waiting for Pool to be ready")
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
cnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
By("setting pool pods to running state")
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
pods := &v1.PodList{}
Expect(k8sClient.List(ctx, pods, &kclient.ListOptions{
Namespace: typeNamespacedName.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),
})).To(Succeed())
for _, pod := range pods.Items {
pod.Status.Phase = v1.PodRunning
pod.Status.Conditions = []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionTrue}}
Expect(k8sClient.Status().Update(ctx, &pod)).To(Succeed())
}
By("creating a BatchSandbox to allocate pods from the pool")
bsbxNamespaceName := types.NamespacedName{
Name: "batch-sandbox-noop-recycle-test-" + rand.String(8),
Namespace: typeNamespacedName.Namespace,
}
batchSandbox := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: bsbxNamespaceName.Name,
Namespace: bsbxNamespaceName.Namespace,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
PoolRef: typeNamespacedName.Name,
},
}
Expect(k8sClient.Create(ctx, batchSandbox)).To(Succeed())
By("waiting for allocation")
var allocatedPodName string
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
alloc, err := getSandboxAllocation(batchSandbox)
Expect(err).NotTo(HaveOccurred())
g.Expect(alloc.Pods).NotTo(BeEmpty())
allocatedPodName = alloc.Pods[0]
}, timeout, interval).Should(Succeed())
By("releasing pods by updating the BatchSandbox alloc-release annotation")
release := AllocationRelease{
Pods: []string{allocatedPodName},
}
js, err := json.Marshal(release)
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
if batchSandbox.Annotations == nil {
batchSandbox.Annotations = make(map[string]string)
}
batchSandbox.Annotations[AnnoAllocReleaseKey] = string(js)
Expect(k8sClient.Update(ctx, batchSandbox)).To(Succeed())
By("verifying the pool allocation is cleared after Noop recycle")
Eventually(func(g Gomega) {
err := k8sClient.Get(ctx, typeNamespacedName, pool)
Expect(err).NotTo(HaveOccurred())
allocation, err := getPoolAllocation(pool)
Expect(err).NotTo(HaveOccurred())
g.Expect(len(allocation.PodAllocation)).To(Equal(0), "Pool allocation should be cleared after Noop recycle")
}, timeout, interval).Should(Succeed())
By("verifying the released pod still exists immediately after Noop recycle (not deleted by recycler)")
// Unlike Delete recycle which marks pods for deletion, Noop recycle leaves pods intact.
// The pool's scale-down logic may subsequently delete pods if buffer exceeds BufferMax,
// but that is a separate concern from the recycle strategy.
Eventually(func(g Gomega) {
pod := &v1.Pod{}
err := k8sClient.Get(ctx, types.NamespacedName{Name: allocatedPodName, Namespace: typeNamespacedName.Namespace}, pod)
if err != nil && errors.IsNotFound(err) {
// The pod might have been deleted by pool scale-down, which is acceptable.
// The key difference from Delete recycle is that Noop recycler does NOT
// return NeedDelete=true, so the pod is not targeted for deletion by recycle itself.
return
}
g.Expect(err).NotTo(HaveOccurred())
// If the pod still exists, it should not have DeletionTimestamp from recycle.
// If it was deleted, it was deleted by pool scale-down, not by the recycler.
g.Expect(pod.DeletionTimestamp).To(BeNil(), "pod should not be marked for deletion by Noop recycle")
}, timeout, interval).Should(Succeed())
By("verifying pool available count increases after release")
Eventually(func(g Gomega) {
err := k8sClient.Get(ctx, typeNamespacedName, pool)
g.Expect(err).NotTo(HaveOccurred())
// Pool.Available should have increased since the released pod is available for reallocation
g.Expect(pool.Status.Allocated).To(BeNumerically("<", 1), "Pool allocated should be 0 after release")
}, timeout, interval).Should(Succeed())
Expect(k8sClient.Delete(ctx, batchSandbox)).To(Succeed())
})
It("should create pool with Restart recycle strategy and keep pod alive after release", func() {
By("creating a Pool with Restart RecycleStrategy")
// In envtest there is no real kubelet, so the exec command never actually runs.
// The restart handler will keep retrying until max retries is exceeded and fall
// back to NeedDelete=true. We therefore verify the same observable end-state as
// Delete recycle (allocation cleared, pod eventually terminating), while confirming
// that the Restart strategy path is exercised through the factory.
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 2,
PoolMax: 4,
BufferMin: 2,
BufferMax: 2,
},
ScaleStrategy: &sandboxv1alpha1.ScaleStrategy{
MaxUnavailable: ptr.To(intstr.FromString("100%")),
},
RecycleStrategy: &sandboxv1alpha1.RecycleStrategy{
Type: sandboxv1alpha1.RecycleTypeRestart,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
By("waiting for Pool to be ready")
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
g.Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
cnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(cnt))
}, timeout, interval).Should(Succeed())
By("setting pool pods to running state")
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
pods := &v1.PodList{}
Expect(k8sClient.List(ctx, pods, &kclient.ListOptions{
Namespace: typeNamespacedName.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),
})).To(Succeed())
for _, pod := range pods.Items {
pod.Status.Phase = v1.PodRunning
pod.Status.Conditions = []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionTrue}}
Expect(k8sClient.Status().Update(ctx, &pod)).To(Succeed())
}
By("creating a BatchSandbox to allocate pods from the pool")
bsbxNamespaceName := types.NamespacedName{
Name: "batch-sandbox-restart-recycle-test-" + rand.String(8),
Namespace: typeNamespacedName.Namespace,
}
batchSandbox := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: bsbxNamespaceName.Name,
Namespace: bsbxNamespaceName.Namespace,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
PoolRef: typeNamespacedName.Name,
},
}
Expect(k8sClient.Create(ctx, batchSandbox)).To(Succeed())
By("waiting for allocation")
var allocatedPodName string
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
alloc, err := getSandboxAllocation(batchSandbox)
Expect(err).NotTo(HaveOccurred())
g.Expect(alloc.Pods).NotTo(BeEmpty())
allocatedPodName = alloc.Pods[0]
}, timeout, interval).Should(Succeed())
By("releasing pods by updating the BatchSandbox alloc-release annotation")
release := AllocationRelease{
Pods: []string{allocatedPodName},
}
js, err := json.Marshal(release)
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
if batchSandbox.Annotations == nil {
batchSandbox.Annotations = make(map[string]string)
}
batchSandbox.Annotations[AnnoAllocReleaseKey] = string(js)
Expect(k8sClient.Update(ctx, batchSandbox)).To(Succeed())
By("verifying the pool allocation is cleared after Restart recycle (retries exceeded → NeedDelete)")
Eventually(func(g Gomega) {
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
allocation, err := getPoolAllocation(pool)
Expect(err).NotTo(HaveOccurred())
g.Expect(allocation.PodAllocation).To(BeEmpty(), "Pool allocation should be cleared after Restart recycle")
}, 30*time.Second, interval).Should(Succeed())
By("verifying pool Allocated count returns to 0")
Eventually(func(g Gomega) {
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
g.Expect(pool.Status.Allocated).To(BeNumerically("==", 0), "Pool allocated should be 0 after restart recycle")
}, 30*time.Second, interval).Should(Succeed())
Expect(k8sClient.Delete(ctx, batchSandbox)).To(Succeed())
})
It("should replenish buffer after Delete recycle frees pods", func() {
By("creating a Pool with Delete RecycleStrategy and BufferMin=2")
resource := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: typeNamespacedName.Name,
Namespace: typeNamespacedName.Namespace,
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "main",
Image: "example.com",
},
},
},
},
CapacitySpec: sandboxv1alpha1.CapacitySpec{
PoolMin: 0,
PoolMax: 4,
BufferMin: 2,
BufferMax: 2,
},
ScaleStrategy: &sandboxv1alpha1.ScaleStrategy{
MaxUnavailable: ptr.To(intstr.FromString("100%")),
},
RecycleStrategy: &sandboxv1alpha1.RecycleStrategy{
Type: sandboxv1alpha1.RecycleTypeDelete,
},
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
By("waiting for Pool to be ready with 2 buffer pods")
Eventually(func(g Gomega) {
pool := &sandboxv1alpha1.Pool{}
g.Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
g.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))
g.Expect(pool.Status.Total).To(Equal(int32(2)))
}, timeout, interval).Should(Succeed())
By("setting pool pods to running state")
pool := &sandboxv1alpha1.Pool{}
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
pods := &v1.PodList{}
Expect(k8sClient.List(ctx, pods, &kclient.ListOptions{
Namespace: typeNamespacedName.Namespace,
FieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),
})).To(Succeed())
for _, pod := range pods.Items {
pod.Status.Phase = v1.PodRunning
pod.Status.Conditions = []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionTrue}}
Expect(k8sClient.Status().Update(ctx, &pod)).To(Succeed())
}
By("creating a BatchSandbox with 1 replica")
bsbxNamespaceName := types.NamespacedName{
Name: "batch-sandbox-replenish-" + rand.String(8),
Namespace: typeNamespacedName.Namespace,
}
batchSandbox := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: bsbxNamespaceName.Name,
Namespace: bsbxNamespaceName.Namespace,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
PoolRef: typeNamespacedName.Name,
},
}
Expect(k8sClient.Create(ctx, batchSandbox)).To(Succeed())
By("waiting for 1 pod to be allocated")
var allocatedPodName string
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
alloc, err := getSandboxAllocation(batchSandbox)
Expect(err).NotTo(HaveOccurred())
g.Expect(alloc.Pods).NotTo(BeEmpty())
allocatedPodName = alloc.Pods[0]
}, timeout, interval).Should(Succeed())
By("releasing the allocated pod")
release := AllocationRelease{Pods: []string{allocatedPodName}}
js, err := json.Marshal(release)
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())
if batchSandbox.Annotations == nil {
batchSandbox.Annotations = make(map[string]string)
}
batchSandbox.Annotations[AnnoAllocReleaseKey] = string(js)
Expect(k8sClient.Update(ctx, batchSandbox)).To(Succeed())
By("verifying pool allocation is cleared")
Eventually(func(g Gomega) {
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
allocation, err := getPoolAllocation(pool)
Expect(err).NotTo(HaveOccurred())
g.Expect(allocation.PodAllocation).To(BeEmpty(), "Pool allocation should be cleared after Delete recycle")
}, timeout, interval).Should(Succeed())
By("verifying pool schedules a new pod to replenish the deleted buffer pod")
// The deleted pod reduces Total; the reconciler should scale up to restore BufferMin=2.
// envtest does not run kubelet so new pods stay Pending, but the scale-up expectation
// being satisfied is enough for Total to reflect the new pod.
Eventually(func(g Gomega) {
Expect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())
// After the deleted pod is gone and a replacement is created, Total >= 2.
g.Expect(pool.Status.Total).To(BeNumerically(">=", int32(2)),
"pool should replenish buffer after pod deletion")
}, 30*time.Second, interval).Should(Succeed())
Expect(k8sClient.Delete(ctx, batchSandbox)).To(Succeed())
})
})
})
var _ = Describe("collectRecycleResults", func() {
type testCase struct {
desc string
results []recycleResult
wantSucceedMap map[string][]string
wantToDelete []string
wantErrContains string
}
cases := []testCase{
{
desc: "empty results returns empty maps",
results: nil,
wantSucceedMap: map[string][]string{},
wantToDelete: nil,
},
{
desc: "Succeeded state adds pod to succeedMap",
results: []recycleResult{
{sandboxName: "sbx-1", podName: "pod-1", status: &recycle.Status{State: recycle.StateSucceeded}},
},
wantSucceedMap: map[string][]string{"sbx-1": {"pod-1"}},
wantToDelete: nil,
},
{
desc: "NeedDelete adds pod to toDeletePods and succeedMap",
results: []recycleResult{
{sandboxName: "sbx-1", podName: "pod-1", status: &recycle.Status{State: recycle.StateSucceeded, NeedDelete: true}},
},
wantSucceedMap: map[string][]string{"sbx-1": {"pod-1"}},
wantToDelete: []string{"pod-1"},
},
{
desc: "Recycling state (in-progress) does not add to succeedMap or toDeletePods",
results: []recycleResult{
{sandboxName: "sbx-1", podName: "pod-1", status: &recycle.Status{State: recycle.StateRecycling}},
},
wantSucceedMap: map[string][]string{},
wantToDelete: nil,
},
{
desc: "error result is collected and pod is skipped",
results: []recycleResult{
{sandboxName: "sbx-1", podName: "pod-1", err: stderrors.New("exec failed")},
},
wantSucceedMap: map[string][]string{},
wantToDelete: nil,
wantErrContains: "exec failed",
},
{
desc: "multiple sandboxes multiple pods",
results: []recycleResult{
{sandboxName: "sbx-a", podName: "pod-1", status: &recycle.Status{State: recycle.StateSucceeded}},
{sandboxName: "sbx-a", podName: "pod-2", status: &recycle.Status{State: recycle.StateSucceeded, NeedDelete: true}},
{sandboxName: "sbx-b", podName: "pod-3", status: &recycle.Status{State: recycle.StateSucceeded}},
{sandboxName: "sbx-b", podName: "pod-4", err: stderrors.New("timeout")},
},
wantSucceedMap: map[string][]string{
"sbx-a": {"pod-1", "pod-2"},
"sbx-b": {"pod-3"},
},
wantToDelete: []string{"pod-2"},
wantErrContains: "timeout",
},
{
desc: "multiple errors are joined",
results: []recycleResult{
{sandboxName: "sbx-1", podName: "pod-1", err: stderrors.New("err-a")},
{sandboxName: "sbx-1", podName: "pod-2", err: stderrors.New("err-b")},
},
wantSucceedMap: map[string][]string{},
wantToDelete: nil,
wantErrContains: "err-a",
},
}
for _, tc := range cases {
tc := tc
It(tc.desc, func() {
succeedMap, toDeletePods, err := collectRecycleResults(context.Background(), tc.results)
if tc.wantErrContains != "" {
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(tc.wantErrContains))
} else {
Expect(err).NotTo(HaveOccurred())
}
Expect(succeedMap).To(Equal(tc.wantSucceedMap))
Expect(toDeletePods).To(Equal(tc.wantToDelete))
})
}
})