* Copyright (c) 2025 Huawei Technologies Co., Ltd.
* openFuyao is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
* http://license.coscl.org.cn/MulanPSL2
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
*/
package controller
import (
"context"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
serverlessdbv1 "serverlessdb/api/v1"
"serverlessdb/internal/testutil"
)
func newTestScheme() *runtime.Scheme {
return testutil.NewTestScheme()
}
func newFakeClient(objs ...client.Object) client.Client {
return testutil.NewFakeClient(objs...)
}
func poolWithImage(image string) *serverlessdbv1.DBResourcePool {
return &serverlessdbv1.DBResourcePool{
ObjectMeta: metav1.ObjectMeta{Name: "pool1", Namespace: "ns"},
Spec: serverlessdbv1.DBResourcePoolSpec{
PodTemplate: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: serverlessdbv1.ContainerNameCompute, Image: image}},
},
},
},
}
}
func buildPodForTest(t *testing.T, pool *serverlessdbv1.DBResourcePool, instance *serverlessdbv1.DBInstance) *corev1.Pod {
t.Helper()
pod, err := buildPod(pool, instance, newTestScheme())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return pod
}
func computeRes(name string, res serverlessdbv1.InstanceResources) serverlessdbv1.ContainerResources {
return serverlessdbv1.ContainerResources{Name: name, Resources: res}
}
func syncStatusReconciler(pod *corev1.Pod, instance *serverlessdbv1.DBInstance) (*DBResourcePoolReconciler, client.Client) {
cl := newFakeClient(pod, instance)
r := &DBResourcePoolReconciler{Client: cl, Scheme: newTestScheme()}
return r, cl
}
func runSyncStatuses(r *DBResourcePoolReconciler, instance *serverlessdbv1.DBInstance) {
pool := &serverlessdbv1.DBResourcePool{ObjectMeta: metav1.ObjectMeta{Name: "pool1", Namespace: "ns"}}
instanceList := &serverlessdbv1.DBInstanceList{Items: []serverlessdbv1.DBInstance{*instance}}
r.syncInstanceStatuses(context.Background(), pool, instanceList, 5432)
}
func resizeTestSetup(specJSON string) (*DBResourcePoolReconciler, *serverlessdbv1.DBInstance, *corev1.Pod) {
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns"},
Spec: corev1.PodSpec{Containers: []corev1.Container{
{Name: serverlessdbv1.ContainerNameCompute, Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1000m"),
corev1.ResourceMemory: resource.MustParse("1Gi"),
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("2000m"),
corev1.ResourceMemory: resource.MustParse("2Gi"),
},
}},
}},
}
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{
Name: "ins1",
Namespace: "ns",
Annotations: map[string]string{serverlessdbv1.AnnotationKeyResizeSpec: specJSON},
},
Status: serverlessdbv1.DBInstanceStatus{Phase: serverlessdbv1.DBInstancePhaseScaling, PodName: "ins1"},
}
cl := newFakeClient(pod, instance)
r := &DBResourcePoolReconciler{Client: cl, Scheme: newTestScheme()}
return r, instance, pod
}
func TestBuildPod(t *testing.T) {
scheme := newTestScheme()
pool := &serverlessdbv1.DBResourcePool{
ObjectMeta: metav1.ObjectMeta{Name: "pool1", Namespace: "ns"},
Spec: serverlessdbv1.DBResourcePoolSpec{
PgVersion: 17,
PodTemplate: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "test"}},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "compute", Image: "postgres:17"},
},
},
},
},
}
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns"},
}
pod, err := buildPod(pool, instance, scheme)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pod.Name != "ins1" {
t.Fatalf("Pod Name = %q, want ins1", pod.Name)
}
if pod.Namespace != "ns" {
t.Fatalf("Pod Namespace = %q, want ns", pod.Namespace)
}
if pod.Labels[serverlessdbv1.LabelKeyDBResourcePool] != "pool1" {
t.Fatalf("Label dbresourcepool = %q, want pool1", pod.Labels[serverlessdbv1.LabelKeyDBResourcePool])
}
if pod.Labels["app"] != "test" {
t.Fatalf("Label app = %q, want test (should inherit from template)", pod.Labels["app"])
}
if len(pod.OwnerReferences) != 1 {
t.Fatalf("expected 1 owner reference, got %d", len(pod.OwnerReferences))
}
if pod.OwnerReferences[0].Name != "ins1" {
t.Fatalf("OwnerRef Name = %q, want ins1", pod.OwnerReferences[0].Name)
}
}
func poolWithComputeResources(t *testing.T, requests, limits corev1.ResourceList) *serverlessdbv1.DBResourcePool {
t.Helper()
return &serverlessdbv1.DBResourcePool{
ObjectMeta: metav1.ObjectMeta{Name: "pool1", Namespace: "ns"},
Spec: serverlessdbv1.DBResourcePoolSpec{
PodTemplate: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: serverlessdbv1.ContainerNameCompute,
Resources: corev1.ResourceRequirements{
Requests: requests,
Limits: limits,
},
},
},
},
},
},
}
}
func computeContainer(pod *corev1.Pod) *corev1.Container {
for i := range pod.Spec.Containers {
if pod.Spec.Containers[i].Name == serverlessdbv1.ContainerNameCompute {
return &pod.Spec.Containers[i]
}
}
return nil
}
func TestBuildPod_PreservesAppRefLabel(t *testing.T) {
pool := poolWithImage("postgres:17")
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{
Name: "ins1",
Namespace: "ns",
Labels: map[string]string{serverlessdbv1.LabelKeyAppRef: "app1"},
},
}
pod := buildPodForTest(t, pool, instance)
if pod.Labels[serverlessdbv1.LabelKeyAppRef] != "app1" {
t.Fatalf("Label app-ref = %q, want app1", pod.Labels[serverlessdbv1.LabelKeyAppRef])
}
if pod.Labels[serverlessdbv1.LabelKeyDBResourcePool] != "pool1" {
t.Fatalf("Label dbresourcepool = %q, want pool1", pod.Labels[serverlessdbv1.LabelKeyDBResourcePool])
}
}
func TestBuildPod_NoAppRefWhenUnallocated(t *testing.T) {
pool := poolWithImage("postgres:17")
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns"},
}
pod := buildPodForTest(t, pool, instance)
if _, ok := pod.Labels[serverlessdbv1.LabelKeyAppRef]; ok {
t.Fatalf("Label app-ref should be absent for unallocated instance, got %q", pod.Labels[serverlessdbv1.LabelKeyAppRef])
}
}
func TestBuildPod_RestoresCurrentResources(t *testing.T) {
pool := poolWithComputeResources(t,
corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1000m"), corev1.ResourceMemory: resource.MustParse("1Gi")},
corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2000m"), corev1.ResourceMemory: resource.MustParse("2Gi")},
)
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns"},
Status: serverlessdbv1.DBInstanceStatus{
CurrentResources: []serverlessdbv1.ContainerResources{
computeRes(serverlessdbv1.ContainerNameCompute, serverlessdbv1.InstanceResources{Cpu: "2000m", Memory: "2Gi", CpuLimit: "4", MemoryLimit: "4Gi"}),
},
},
}
pod := buildPodForTest(t, pool, instance)
c := computeContainer(pod)
if c == nil {
t.Fatalf("compute container not found")
}
if got := c.Resources.Requests[corev1.ResourceCPU]; got.String() != "2" {
t.Fatalf("Requests CPU = %q, want 2", got.String())
}
if got := c.Resources.Requests[corev1.ResourceMemory]; got.String() != "2Gi" {
t.Fatalf("Requests Memory = %q, want 2Gi", got.String())
}
if got := c.Resources.Limits[corev1.ResourceCPU]; got.String() != "4" {
t.Fatalf("Limits CPU = %q, want 4", got.String())
}
if got := c.Resources.Limits[corev1.ResourceMemory]; got.String() != "4Gi" {
t.Fatalf("Limits Memory = %q, want 4Gi", got.String())
}
}
func TestBuildPod_NilCurrentResources_KeepsTemplate(t *testing.T) {
pool := poolWithComputeResources(t,
corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1000m"), corev1.ResourceMemory: resource.MustParse("1Gi")},
corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2000m"), corev1.ResourceMemory: resource.MustParse("2Gi")},
)
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns"},
Status: serverlessdbv1.DBInstanceStatus{CurrentResources: nil},
}
pod := buildPodForTest(t, pool, instance)
c := computeContainer(pod)
if c == nil {
t.Fatalf("compute container not found")
}
if got := c.Resources.Requests[corev1.ResourceCPU]; got.String() != "1" {
t.Fatalf("Requests CPU = %q, want 1 (template)", got.String())
}
if got := c.Resources.Requests[corev1.ResourceMemory]; got.String() != "1Gi" {
t.Fatalf("Requests Memory = %q, want 1Gi (template)", got.String())
}
if got := c.Resources.Limits[corev1.ResourceCPU]; got.String() != "2" {
t.Fatalf("Limits CPU = %q, want 2 (template)", got.String())
}
if got := c.Resources.Limits[corev1.ResourceMemory]; got.String() != "2Gi" {
t.Fatalf("Limits Memory = %q, want 2Gi (template)", got.String())
}
}
func TestBuildPod_RestoresCurrentResources_MultiContainer(t *testing.T) {
pool := poolWithTwoContainers()
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns"},
Status: serverlessdbv1.DBInstanceStatus{
CurrentResources: []serverlessdbv1.ContainerResources{
computeRes(serverlessdbv1.ContainerNameCompute, serverlessdbv1.InstanceResources{Cpu: "2000m", Memory: "2Gi", CpuLimit: "4", MemoryLimit: "4Gi"}),
computeRes("sidecar", serverlessdbv1.InstanceResources{Cpu: "500m"}),
},
},
}
pod := buildPodForTest(t, pool, instance)
c := computeContainer(pod)
if c == nil {
t.Fatalf("compute container not found")
}
if got := c.Resources.Requests[corev1.ResourceCPU]; got.String() != "2" {
t.Fatalf("compute Requests CPU = %q, want 2", got.String())
}
sidecar := findContainer(pod, "sidecar")
if sidecar == nil {
t.Fatalf("sidecar container not found")
}
if got := sidecar.Resources.Requests[corev1.ResourceCPU]; got.String() != "500m" {
t.Fatalf("sidecar Requests CPU = %q, want 500m", got.String())
}
}
func poolWithTwoContainers() *serverlessdbv1.DBResourcePool {
return &serverlessdbv1.DBResourcePool{
ObjectMeta: metav1.ObjectMeta{Name: "pool1", Namespace: "ns"},
Spec: serverlessdbv1.DBResourcePoolSpec{
PodTemplate: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{
{Name: serverlessdbv1.ContainerNameCompute, Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1000m"),
corev1.ResourceMemory: resource.MustParse("1Gi"),
},
}},
{Name: "sidecar", Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("100m")},
}},
}}},
},
}
}
func findContainer(pod *corev1.Pod, name string) *corev1.Container {
for i := range pod.Spec.Containers {
if pod.Spec.Containers[i].Name == name {
return &pod.Spec.Containers[i]
}
}
return nil
}
func TestRecreateMissingPod_PreservesAppRefAndResources(t *testing.T) {
pool := poolWithComputeResources(t,
corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1000m"),
corev1.ResourceMemory: resource.MustParse("1Gi"),
},
nil,
)
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{
Name: "ins1",
Namespace: "ns",
Labels: map[string]string{
serverlessdbv1.LabelKeyDBResourcePool: "pool1",
serverlessdbv1.LabelKeyAppRef: "app1",
},
},
Status: serverlessdbv1.DBInstanceStatus{
Phase: serverlessdbv1.DBInstancePhaseRunning,
CurrentResources: []serverlessdbv1.ContainerResources{
computeRes(serverlessdbv1.ContainerNameCompute, serverlessdbv1.InstanceResources{Cpu: "2000m", Memory: "2Gi", CpuLimit: "4", MemoryLimit: "4Gi"}),
},
},
}
cl := newFakeClient(pool, instance)
r := &DBResourcePoolReconciler{Client: cl, Scheme: newTestScheme()}
r.recreateMissingPod(context.Background(), pool, instance, 5432)
got := &corev1.Pod{}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins1"}, got); err != nil {
t.Fatalf("pod should be created: %v", err)
}
if got.Labels[serverlessdbv1.LabelKeyAppRef] != "app1" {
t.Fatalf("Label app-ref = %q, want app1", got.Labels[serverlessdbv1.LabelKeyAppRef])
}
if got.Labels[serverlessdbv1.LabelKeyDBResourcePool] != "pool1" {
t.Fatalf("Label dbresourcepool = %q, want pool1", got.Labels[serverlessdbv1.LabelKeyDBResourcePool])
}
c := computeContainer(got)
if c == nil {
t.Fatalf("compute container not found")
}
if gotQ := c.Resources.Requests[corev1.ResourceCPU]; gotQ.String() != "2" {
t.Fatalf("Requests CPU = %q, want 2", gotQ.String())
}
if gotQ := c.Resources.Requests[corev1.ResourceMemory]; gotQ.String() != "2Gi" {
t.Fatalf("Requests Memory = %q, want 2Gi", gotQ.String())
}
if gotQ := c.Resources.Limits[corev1.ResourceCPU]; gotQ.String() != "4" {
t.Fatalf("Limits CPU = %q, want 4", gotQ.String())
}
if gotQ := c.Resources.Limits[corev1.ResourceMemory]; gotQ.String() != "4Gi" {
t.Fatalf("Limits Memory = %q, want 4Gi", gotQ.String())
}
}
func TestReconcileStaleServices_TTLDisabled(t *testing.T) {
r := &DBResourcePoolReconciler{Client: newFakeClient(), ServiceTTL: 0}
hasPending, err := r.reconcileStaleServices(context.Background(), "ns")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hasPending {
t.Fatalf("expected hasPending=false when TTL=0")
}
}
func TestReconcileStaleServices_PodExists_ClearsAnnotation(t *testing.T) {
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "app1",
Namespace: "ns",
Labels: map[string]string{serverlessdbv1.LabelKeyManaged: "true"},
Annotations: map[string]string{
serverlessdbv1.AnnotationKeyPodAbsentSince: "2024-01-01T00:00:00Z",
},
},
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pod1",
Namespace: "ns",
Labels: map[string]string{serverlessdbv1.LabelKeyAppRef: "app1"},
},
}
cl := newFakeClient(svc, pod)
r := &DBResourcePoolReconciler{Client: cl, ServiceTTL: 30 * time.Minute}
hasPending, err := r.reconcileStaleServices(context.Background(), "ns")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hasPending {
t.Fatalf("expected hasPending=false when pod exists")
}
got := &corev1.Service{}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "app1"}, got); err != nil {
t.Fatalf("failed to get service: %v", err)
}
if _, ok := got.Annotations[serverlessdbv1.AnnotationKeyPodAbsentSince]; ok {
t.Fatalf("annotation should be cleared")
}
}
func TestReconcileStaleServices_NoPod_SetsAnnotation(t *testing.T) {
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "app1",
Namespace: "ns",
Labels: map[string]string{serverlessdbv1.LabelKeyManaged: "true"},
},
}
cl := newFakeClient(svc)
r := &DBResourcePoolReconciler{Client: cl, ServiceTTL: 30 * time.Minute}
hasPending, err := r.reconcileStaleServices(context.Background(), "ns")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasPending {
t.Fatalf("expected hasPending=true when pod absent and annotation just set")
}
got := &corev1.Service{}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "app1"}, got); err != nil {
t.Fatalf("failed to get service: %v", err)
}
if _, ok := got.Annotations[serverlessdbv1.AnnotationKeyPodAbsentSince]; !ok {
t.Fatalf("annotation should be set")
}
}
func TestReconcileStaleServices_NoPod_ExpiredTTL_DeletesService(t *testing.T) {
pastTime := metav1.Time{Time: time.Now().Add(-31 * time.Minute)}.Format(time.RFC3339)
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "app1",
Namespace: "ns",
Labels: map[string]string{serverlessdbv1.LabelKeyManaged: "true"},
Annotations: map[string]string{
serverlessdbv1.AnnotationKeyPodAbsentSince: pastTime,
},
},
}
cl := newFakeClient(svc)
r := &DBResourcePoolReconciler{Client: cl, ServiceTTL: 30 * time.Minute}
hasPending, err := r.reconcileStaleServices(context.Background(), "ns")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hasPending {
t.Fatalf("expected hasPending=false when service deleted")
}
got := &corev1.Service{}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "app1"}, got); err == nil {
t.Fatalf("service should be deleted")
}
}
func TestReconcileStaleServices_NoPod_WithinTTL_Waits(t *testing.T) {
recentTime := metav1.Time{Time: time.Now().Add(-5 * time.Minute)}.Format(time.RFC3339)
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "app1",
Namespace: "ns",
Labels: map[string]string{serverlessdbv1.LabelKeyManaged: "true"},
Annotations: map[string]string{
serverlessdbv1.AnnotationKeyPodAbsentSince: recentTime,
},
},
}
cl := newFakeClient(svc)
r := &DBResourcePoolReconciler{Client: cl, ServiceTTL: 30 * time.Minute}
hasPending, err := r.reconcileStaleServices(context.Background(), "ns")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasPending {
t.Fatalf("expected hasPending=true when within TTL")
}
got := &corev1.Service{}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "app1"}, got); err != nil {
t.Fatalf("service should not be deleted: %v", err)
}
}
func TestScaleDown_DeletesUnallocated(t *testing.T) {
ins1 := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns", Labels: map[string]string{
serverlessdbv1.LabelKeyDBResourcePool: "pool1", serverlessdbv1.LabelKeyAppRef: "app1",
}},
}
ins2 := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins2", Namespace: "ns", Labels: map[string]string{
serverlessdbv1.LabelKeyDBResourcePool: "pool1",
}},
}
ins3 := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins3", Namespace: "ns", Labels: map[string]string{
serverlessdbv1.LabelKeyDBResourcePool: "pool1",
}},
}
cl := newFakeClient(ins1, ins2, ins3)
r := &DBResourcePoolReconciler{Client: cl}
list := &serverlessdbv1.DBInstanceList{Items: []serverlessdbv1.DBInstance{*ins1, *ins2, *ins3}}
r.scaleDown(context.Background(), list, 2)
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins1"}, &serverlessdbv1.DBInstance{}); err != nil {
t.Fatalf("ins1 (allocated) should remain: %v", err)
}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins2"}, &serverlessdbv1.DBInstance{}); err == nil {
t.Fatalf("ins2 (unallocated) should be deleted")
}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins3"}, &serverlessdbv1.DBInstance{}); err == nil {
t.Fatalf("ins3 (unallocated) should be deleted")
}
}
func TestScaleDown_AllAllocated_NoDeletion(t *testing.T) {
ins1 := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns", Labels: map[string]string{
serverlessdbv1.LabelKeyDBResourcePool: "pool1", serverlessdbv1.LabelKeyAppRef: "app1",
}},
}
ins2 := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins2", Namespace: "ns", Labels: map[string]string{
serverlessdbv1.LabelKeyDBResourcePool: "pool1", serverlessdbv1.LabelKeyAppRef: "app2",
}},
}
cl := newFakeClient(ins1, ins2)
r := &DBResourcePoolReconciler{Client: cl}
list := &serverlessdbv1.DBInstanceList{Items: []serverlessdbv1.DBInstance{*ins1, *ins2}}
r.scaleDown(context.Background(), list, 1)
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins1"}, &serverlessdbv1.DBInstance{}); err != nil {
t.Fatalf("ins1 should remain: %v", err)
}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins2"}, &serverlessdbv1.DBInstance{}); err != nil {
t.Fatalf("ins2 should remain: %v", err)
}
}
func TestUpdatePoolStatus(t *testing.T) {
pool := &serverlessdbv1.DBResourcePool{
ObjectMeta: metav1.ObjectMeta{Name: "pool1", Namespace: "ns"},
}
ins1 := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns", Labels: map[string]string{serverlessdbv1.LabelKeyDBResourcePool: "pool1"}},
Status: serverlessdbv1.DBInstanceStatus{Phase: serverlessdbv1.DBInstancePhaseWarmReady},
}
ins2 := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins2", Namespace: "ns", Labels: map[string]string{serverlessdbv1.LabelKeyDBResourcePool: "pool1", serverlessdbv1.LabelKeyAppRef: "app1"}},
Status: serverlessdbv1.DBInstanceStatus{Phase: serverlessdbv1.DBInstancePhaseWarmReady},
}
ins3 := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins3", Namespace: "ns", Labels: map[string]string{serverlessdbv1.LabelKeyDBResourcePool: "pool1", serverlessdbv1.LabelKeyAppRef: "app2"}},
Status: serverlessdbv1.DBInstanceStatus{Phase: serverlessdbv1.DBInstancePhaseRunning},
}
cl := newFakeClient(pool, ins1, ins2, ins3)
r := &DBResourcePoolReconciler{Client: cl}
err := r.updatePoolStatus(context.Background(), pool)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := &serverlessdbv1.DBResourcePool{}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "pool1"}, got); err != nil {
t.Fatalf("failed to get pool: %v", err)
}
if got.Status.TotalInstances != 3 {
t.Fatalf("TotalInstances = %d, want 3", got.Status.TotalInstances)
}
if got.Status.WarmInstances != 2 {
t.Fatalf("WarmInstances = %d, want 2", got.Status.WarmInstances)
}
if got.Status.AvailableInstances != 1 {
t.Fatalf("AvailableInstances = %d, want 1", got.Status.AvailableInstances)
}
if got.Status.AllocatedInstances != 2 {
t.Fatalf("AllocatedInstances = %d, want 2", got.Status.AllocatedInstances)
}
}
func TestReconcileDeletion_HasAllocated_Requeues(t *testing.T) {
pool := &serverlessdbv1.DBResourcePool{
ObjectMeta: metav1.ObjectMeta{
Name: "pool1",
Namespace: "ns",
Finalizers: []string{serverlessdbv1.PoolFinalizer},
},
}
ins1 := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns", Labels: map[string]string{
serverlessdbv1.LabelKeyDBResourcePool: "pool1", serverlessdbv1.LabelKeyAppRef: "app1",
}},
}
cl := newFakeClient(pool, ins1)
r := &DBResourcePoolReconciler{Client: cl}
result, err := r.reconcileDeletion(context.Background(), pool)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.RequeueAfter != requeueInterval {
t.Fatalf("RequeueAfter = %v, want %v", result.RequeueAfter, requeueInterval)
}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins1"}, &serverlessdbv1.DBInstance{}); err != nil {
t.Fatalf("instance should not be deleted when allocated: %v", err)
}
}
func TestReconcileDeletion_NoAllocated_DeletesAndRemovesFinalizer(t *testing.T) {
pool := &serverlessdbv1.DBResourcePool{
ObjectMeta: metav1.ObjectMeta{
Name: "pool1",
Namespace: "ns",
Finalizers: []string{serverlessdbv1.PoolFinalizer},
},
}
ins1 := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns", Labels: map[string]string{
serverlessdbv1.LabelKeyDBResourcePool: "pool1",
}},
}
cl := newFakeClient(pool, ins1)
r := &DBResourcePoolReconciler{Client: cl}
result, err := r.reconcileDeletion(context.Background(), pool)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.RequeueAfter != 0 {
t.Fatalf("RequeueAfter = %v, want 0", result.RequeueAfter)
}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins1"}, &serverlessdbv1.DBInstance{}); err == nil {
t.Fatalf("instance should be deleted")
}
got := &serverlessdbv1.DBResourcePool{}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "pool1"}, got); err != nil {
t.Fatalf("failed to get pool: %v", err)
}
if len(got.Finalizers) != 0 {
t.Fatalf("finalizer should be removed, got %v", got.Finalizers)
}
}
func TestReconcileDeletion_NoFinalizer_NoOp(t *testing.T) {
pool := &serverlessdbv1.DBResourcePool{
ObjectMeta: metav1.ObjectMeta{Name: "pool1", Namespace: "ns"},
}
cl := newFakeClient(pool)
r := &DBResourcePoolReconciler{Client: cl}
result, err := r.reconcileDeletion(context.Background(), pool)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.RequeueAfter != 0 {
t.Fatalf("RequeueAfter = %v, want 0", result.RequeueAfter)
}
}
var _ = ctrl.Log
func TestApplyResize_Success_ClearsTrigger_PersistsLastResizeSpec(t *testing.T) {
specJSON := `[{"name":"compute","resources":{"cpu":"2000m","memory":"2Gi","cpu_limit":"4","memory_limit":"4Gi"}}]`
r, instance, pod := resizeTestSetup(specJSON)
r.applyResize(context.Background(), instance, pod)
if _, ok := instance.Annotations[serverlessdbv1.AnnotationKeyResizeSpec]; ok {
t.Fatalf("resize-spec trigger annotation should be cleared after submit")
}
got, ok := instance.Annotations[serverlessdbv1.AnnotationKeyLastResizeSpec]
if !ok {
t.Fatalf("last-resize-spec annotation should be set")
}
if got != specJSON {
t.Fatalf("last-resize-spec = %q, want %q", got, specJSON)
}
}
func TestApplyResize_ResizeInProgress_NoAnnotation_KeepsScaling(t *testing.T) {
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns"},
Status: corev1.PodStatus{Conditions: []corev1.PodCondition{
{Type: corev1.PodResizeInProgress, Status: corev1.ConditionTrue},
}},
}
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns"},
Status: serverlessdbv1.DBInstanceStatus{Phase: serverlessdbv1.DBInstancePhaseScaling, PodName: "ins1"},
}
r := &DBResourcePoolReconciler{Client: newFakeClient(), Scheme: newTestScheme()}
r.applyResize(context.Background(), instance, pod)
if instance.Status.Phase != serverlessdbv1.DBInstancePhaseScaling {
t.Fatalf("phase = %q, want Scaling", instance.Status.Phase)
}
}
func TestApplyResize_ResizeCompleted_TransitionsToRunning(t *testing.T) {
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns"},
Status: corev1.PodStatus{},
}
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns"},
Status: serverlessdbv1.DBInstanceStatus{Phase: serverlessdbv1.DBInstancePhaseScaling, PodName: "ins1"},
}
r := &DBResourcePoolReconciler{Client: newFakeClient(), Scheme: newTestScheme()}
r.applyResize(context.Background(), instance, pod)
if instance.Status.Phase != serverlessdbv1.DBInstancePhaseRunning {
t.Fatalf("phase = %q, want Running", instance.Status.Phase)
}
}
func TestSyncInstanceStatuses_UpdatesCurrentResourcesFromPod(t *testing.T) {
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns", UID: "uid-1"},
Status: corev1.PodStatus{
PodIP: testPodIP1,
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: serverlessdbv1.ContainerNameCompute,
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("500m"),
corev1.ResourceMemory: resource.MustParse("512Mi"),
},
},
},
{
Name: "sidecar",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("100m")},
},
},
},
},
}
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns", Labels: map[string]string{serverlessdbv1.LabelKeyDBResourcePool: "pool1"}},
Status: serverlessdbv1.DBInstanceStatus{Phase: serverlessdbv1.DBInstancePhaseWakingUp, PodName: "ins1"},
}
r, cl := syncStatusReconciler(pod, instance)
runSyncStatuses(r, instance)
got := &serverlessdbv1.DBInstance{}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins1"}, got); err != nil {
t.Fatalf("failed to get instance: %v", err)
}
if len(got.Status.CurrentResources) != 2 {
t.Fatalf("CurrentResources should have 2 containers, got %d", len(got.Status.CurrentResources))
}
byName := map[string]serverlessdbv1.InstanceResources{}
for _, c := range got.Status.CurrentResources {
byName[c.Name] = c.Resources
}
if byName[serverlessdbv1.ContainerNameCompute].Cpu != "500m" {
t.Fatalf("compute CurrentResources.Cpu = %q, want 500m", byName[serverlessdbv1.ContainerNameCompute].Cpu)
}
if byName[serverlessdbv1.ContainerNameCompute].Memory != "512Mi" {
t.Fatalf("compute CurrentResources.Memory = %q, want 512Mi", byName[serverlessdbv1.ContainerNameCompute].Memory)
}
if byName["sidecar"].Cpu != "100m" {
t.Fatalf("sidecar CurrentResources.Cpu = %q, want 100m", byName["sidecar"].Cpu)
}
}
func TestSyncInstanceStatuses_AllocatedInstanceWritesAllocatedAtAndConnectionString(t *testing.T) {
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns", UID: "uid-1"},
Status: corev1.PodStatus{
PodIP: testPodIP1,
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
}
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{
Name: "ins1",
Namespace: "ns",
Labels: map[string]string{
serverlessdbv1.LabelKeyDBResourcePool: "pool1",
serverlessdbv1.LabelKeyAppRef: "myapp",
},
},
Status: serverlessdbv1.DBInstanceStatus{Phase: serverlessdbv1.DBInstancePhaseWakingUp, PodName: "ins1"},
}
r, cl := syncStatusReconciler(pod, instance)
runSyncStatuses(r, instance)
got := &serverlessdbv1.DBInstance{}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins1"}, got); err != nil {
t.Fatalf("failed to get instance: %v", err)
}
if got.Status.Phase != serverlessdbv1.DBInstancePhaseRunning {
t.Fatalf("Phase = %q, want Running", got.Status.Phase)
}
if got.Status.AllocatedAt == nil {
t.Fatalf("AllocatedAt should be set for allocated instance")
}
wantConnStr := "myapp.ns.svc.cluster.local:5432"
if got.Status.ConnectionString != wantConnStr {
t.Fatalf("ConnectionString = %q, want %q", got.Status.ConnectionString, wantConnStr)
}
}
func TestSyncInstanceStatuses_AllocatedInstancePreservesExistingAllocatedAt(t *testing.T) {
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "ins1", Namespace: "ns", UID: "uid-1"},
Status: corev1.PodStatus{
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
}
existingAllocatedAt := metav1.Now()
instance := &serverlessdbv1.DBInstance{
ObjectMeta: metav1.ObjectMeta{
Name: "ins1",
Namespace: "ns",
Labels: map[string]string{
serverlessdbv1.LabelKeyAppRef: "myapp",
},
},
Status: serverlessdbv1.DBInstanceStatus{
Phase: serverlessdbv1.DBInstancePhaseWakingUp,
PodName: "ins1",
AllocatedAt: &existingAllocatedAt,
},
}
r, cl := syncStatusReconciler(pod, instance)
runSyncStatuses(r, instance)
got := &serverlessdbv1.DBInstance{}
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins1"}, got); err != nil {
t.Fatalf("failed to get instance: %v", err)
}
if got.Status.AllocatedAt == nil {
t.Fatalf("AllocatedAt should be preserved, got nil")
}
if got.Status.AllocatedAt.Unix() != existingAllocatedAt.Unix() {
t.Fatalf("AllocatedAt should be preserved, got %v, want %v", got.Status.AllocatedAt.Unix(), existingAllocatedAt.Unix())
}
}
func TestApplyResize_ActuallyChangesPodResources(t *testing.T) {
specJSON := `[{"name":"compute","resources":{"cpu":"2000m","memory":"2Gi","cpu_limit":"4","memory_limit":"4Gi"}}]`
r, instance, pod := resizeTestSetup(specJSON)
r.applyResize(context.Background(), instance, pod)
got := &corev1.Pod{}
cl := r.Client
if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "ins1"}, got); err != nil {
t.Fatalf("failed to get pod: %v", err)
}
c := computeContainer(got)
if c == nil {
t.Fatalf("compute container not found")
}
if gotCPU := c.Resources.Requests[corev1.ResourceCPU]; gotCPU.String() != "2" {
t.Fatalf("Requests CPU = %q, want 2 (resize not applied)", gotCPU.String())
}
if gotMem := c.Resources.Requests[corev1.ResourceMemory]; gotMem.String() != "2Gi" {
t.Fatalf("Requests Memory = %q, want 2Gi", gotMem.String())
}
if gotCPU := c.Resources.Limits[corev1.ResourceCPU]; gotCPU.String() != "4" {
t.Fatalf("Limits CPU = %q, want 4", gotCPU.String())
}
if gotMem := c.Resources.Limits[corev1.ResourceMemory]; gotMem.String() != "4Gi" {
t.Fatalf("Limits Memory = %q, want 4Gi", gotMem.String())
}
}