package handler import ( "context" "fmt" "testing" "github.com/goodrain/rainbond/api/util/bcode" dbmodel "github.com/goodrain/rainbond/db/model" corev1 "k8s.io/api/core/v1" storagev1 "k8s.io/api/storage/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/fake" ) func expansionStorageClass(name string, allowed bool) *storagev1.StorageClass { return &storagev1.StorageClass{ ObjectMeta: metav1.ObjectMeta{Name: name}, AllowVolumeExpansion: &allowed, } } func expansionStorageClassWithProvisioner(name string, allowed bool, provisioner string) *storagev1.StorageClass { storageClass := expansionStorageClass(name, allowed) storageClass.Provisioner = provisioner return storageClass } func expansionPVC(namespace, name, serviceID, volumeName, storageClass, requested, actual string, conditions ...corev1.PersistentVolumeClaimConditionType, ) *corev1.PersistentVolumeClaim { pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Namespace: namespace, Name: name, Labels: map[string]string{"service_id": serviceID}, Annotations: map[string]string{"volume_name": volumeName}, }, Spec: corev1.PersistentVolumeClaimSpec{ StorageClassName: &storageClass, Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{ corev1.ResourceStorage: resource.MustParse(requested), }}, }, Status: corev1.PersistentVolumeClaimStatus{Capacity: corev1.ResourceList{ corev1.ResourceStorage: resource.MustParse(actual), }}, } for _, conditionType := range conditions { pvc.Status.Conditions = append(pvc.Status.Conditions, corev1.PersistentVolumeClaimCondition{ Type: conditionType, Status: corev1.ConditionTrue, Message: fmt.Sprintf("condition %s", conditionType), }) } return pvc } // capability_id: rainbond.component.volume-expansion-status func TestInspectVolumeExpansion(t *testing.T) { service := &dbmodel.TenantServices{ServiceID: "service-1", Namespace: "tenant-ns"} volume := &dbmodel.TenantServiceVolume{ Model: dbmodel.Model{ID: 7}, ServiceID: service.ServiceID, VolumeName: "data", VolumeType: "fast", VolumeCapacity: 20, } tests := []struct { name string service *dbmodel.TenantServices volume *dbmodel.TenantServiceVolume objects []runtime.Object wantStatus string wantAllowed bool wantActual int64 wantRequested int64 wantPVCCount int resolveTenantNamespaceHook func(string) (string, error) }{ { name: "stateful claims are aggregated", service: service, volume: volume, objects: []runtime.Object{ expansionStorageClass("fast", true), expansionPVC("tenant-ns", "manual7-app-0", "service-1", "data", "fast", "20Gi", "20Gi"), expansionPVC("tenant-ns", "manual7-app-1", "service-1", "data", "fast", "20Gi", "20Gi"), }, wantStatus: volumeExpansionReady, wantAllowed: true, wantActual: 20, wantRequested: 20, wantPVCCount: 2, }, { name: "requested capacity ahead of actual is resizing", service: service, volume: volume, objects: []runtime.Object{ expansionStorageClass("fast", true), expansionPVC("tenant-ns", "manual7", "service-1", "data", "fast", "30Gi", "20Gi"), }, wantStatus: volumeExpansionResizing, wantAllowed: true, wantActual: 20, wantRequested: 30, wantPVCCount: 1, }, { name: "stored target ahead of PVC request is pending", service: service, volume: &dbmodel.TenantServiceVolume{ Model: dbmodel.Model{ID: 7}, ServiceID: service.ServiceID, VolumeName: "data", VolumeType: "fast", VolumeCapacity: 30, }, objects: []runtime.Object{ expansionStorageClass("fast", true), expansionPVC("tenant-ns", "manual7", "service-1", "data", "fast", "10Gi", "10Gi"), }, wantStatus: "pending", wantAllowed: true, wantActual: 10, wantRequested: 10, wantPVCCount: 1, }, { name: "filesystem condition takes precedence", service: service, volume: volume, objects: []runtime.Object{ expansionStorageClass("fast", true), expansionPVC( "tenant-ns", "manual7", "service-1", "data", "fast", "30Gi", "20Gi", corev1.PersistentVolumeClaimFileSystemResizePending, ), }, wantStatus: volumeExpansionFileSystemResizePending, wantAllowed: true, wantActual: 20, wantRequested: 30, wantPVCCount: 1, }, { name: "storage class can forbid expansion", service: service, volume: volume, objects: []runtime.Object{ expansionStorageClass("fast", false), expansionPVC("tenant-ns", "manual7", "service-1", "data", "fast", "20Gi", "20Gi"), }, wantStatus: volumeExpansionUnsupported, wantAllowed: false, wantActual: 20, wantRequested: 20, wantPVCCount: 1, }, { name: "nfs subdir provisioner cannot expand volumes", service: service, volume: volume, objects: []runtime.Object{ expansionStorageClassWithProvisioner( "fast", true, "cluster.local/nfs-subdir-external-provisioner", ), expansionPVC("tenant-ns", "manual7", "service-1", "data", "fast", "20Gi", "20Gi"), }, wantStatus: volumeExpansionUnsupported, wantAllowed: false, wantActual: 20, wantRequested: 20, wantPVCCount: 1, }, { name: "not yet deployed PVC can change desired capacity", service: service, volume: volume, wantStatus: volumeExpansionUnbound, wantAllowed: true, wantActual: 0, wantRequested: 0, wantPVCCount: 0, }, { name: "image namespace does not shadow tenant namespace", service: &dbmodel.TenantServices{ TenantID: "tenant-uuid", ServiceID: "service-1", Namespace: "goodrain", }, volume: volume, objects: []runtime.Object{ expansionStorageClass("fast", true), expansionPVC("default", "manual7", "service-1", "data", "fast", "20Gi", "20Gi"), }, wantStatus: volumeExpansionReady, wantAllowed: true, wantActual: 20, wantRequested: 20, wantPVCCount: 1, resolveTenantNamespaceHook: func(tenantID string) (string, error) { return "default", nil }, }, { name: "virtual machine guest disks are not supported", service: &dbmodel.TenantServices{ ServiceID: "service-1", Namespace: "tenant-ns", ExtendMethod: "vm", }, volume: volume, wantStatus: volumeExpansionUnsupported, wantAllowed: false, wantPVCCount: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { action := &ServiceAction{ kubeClient: fake.NewSimpleClientset(tt.objects...), resolveTenantNamespaceHook: tt.resolveTenantNamespaceHook, } got, _, err := action.inspectVolumeExpansion(context.Background(), tt.service, tt.volume) if err != nil { t.Fatalf("inspect volume expansion: %v", err) } if got.Status != tt.wantStatus || got.AllowExpansion != tt.wantAllowed || got.ActualCapacity != tt.wantActual || got.RequestedCapacity != tt.wantRequested || got.PVCCount != tt.wantPVCCount { t.Fatalf("unexpected runtime: %#v", got) } }) } } // capability_id: rainbond.component.volume-expansion-updates-claims func TestExpandVolumeClaims(t *testing.T) { service := &dbmodel.TenantServices{ServiceID: "service-1", Namespace: "tenant-ns"} volume := &dbmodel.TenantServiceVolume{ Model: dbmodel.Model{ID: 7}, ServiceID: service.ServiceID, VolumeName: "data", VolumeType: "fast", VolumeCapacity: 10, } client := fake.NewSimpleClientset( expansionStorageClass("fast", true), expansionPVC("tenant-ns", "manual7-app-0", "service-1", "data", "fast", "10Gi", "10Gi"), expansionPVC("tenant-ns", "manual7-app-1", "service-1", "data", "fast", "10Gi", "10Gi"), ) action := &ServiceAction{kubeClient: client} if err := action.expandVolumeClaims(context.Background(), service, volume, 20); err != nil { t.Fatalf("expand claims: %v", err) } for _, name := range []string{"manual7-app-0", "manual7-app-1"} { pvc, err := client.CoreV1().PersistentVolumeClaims("tenant-ns").Get(context.Background(), name, metav1.GetOptions{}) if err != nil { t.Fatalf("get PVC %s: %v", name, err) } if got := pvc.Spec.Resources.Requests[corev1.ResourceStorage]; got.Cmp(resource.MustParse("20Gi")) != 0 { t.Fatalf("PVC %s request = %s, want 20Gi", name, got.String()) } } } func TestExpandVolumeClaimsRejectsShrink(t *testing.T) { service := &dbmodel.TenantServices{ServiceID: "service-1", Namespace: "tenant-ns"} volume := &dbmodel.TenantServiceVolume{ Model: dbmodel.Model{ID: 7}, ServiceID: service.ServiceID, VolumeName: "data", VolumeType: "fast", VolumeCapacity: 20, } client := fake.NewSimpleClientset( expansionStorageClass("fast", true), expansionPVC("tenant-ns", "manual7", "service-1", "data", "fast", "20Gi", "20Gi"), ) action := &ServiceAction{kubeClient: client} err := action.expandVolumeClaims(context.Background(), service, volume, 10) if err == nil { t.Fatal("expected shrink request to fail") } if got := bcode.Err2Coder(err).GetStatus(); got != 400 { t.Fatalf("error status = %d, want 400: %v", got, err) } pvc, err := client.CoreV1().PersistentVolumeClaims("tenant-ns").Get(context.Background(), "manual7", metav1.GetOptions{}) if err != nil { t.Fatal(err) } if got := pvc.Spec.Resources.Requests[corev1.ResourceStorage]; got.Cmp(resource.MustParse("20Gi")) != 0 { t.Fatalf("PVC request changed after rejected shrink: %s", got.String()) } } func TestExpandVolumeClaimsRejectsDisabledStorageClass(t *testing.T) { service := &dbmodel.TenantServices{ServiceID: "service-1", Namespace: "tenant-ns"} volume := &dbmodel.TenantServiceVolume{ Model: dbmodel.Model{ID: 7}, ServiceID: service.ServiceID, VolumeName: "data", VolumeType: "fast", VolumeCapacity: 10, } action := &ServiceAction{kubeClient: fake.NewSimpleClientset( expansionStorageClass("fast", false), expansionPVC("tenant-ns", "manual7", "service-1", "data", "fast", "10Gi", "10Gi"), )} err := action.expandVolumeClaims(context.Background(), service, volume, 20) if err == nil { t.Fatal("expected disabled StorageClass to reject expansion") } if got := bcode.Err2Coder(err).GetStatus(); got != 400 { t.Fatalf("error status = %d, want 400: %v", got, err) } }