| /* |
| Copyright 2021 The Kubernetes Authors. |
| |
| Licensed under the Apache License, Version 2.0 (the "License"); |
| you may not use this file except in compliance with the License. |
| You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, software |
| distributed under the License is distributed on an "AS IS" BASIS, |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| See the License for the specific language governing permissions and |
| limitations under the License. |
| */ |
| |
| package testsuites |
| |
| import ( |
| "context" |
| "fmt" |
| "strings" |
| "time" |
| |
| "github.com/onsi/ginkgo/v2" |
| "github.com/onsi/gomega" |
| "github.com/onsi/gomega/types" |
| |
| storagev1 "k8s.io/api/storage/v1" |
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| "k8s.io/client-go/kubernetes" |
| "k8s.io/kubernetes/test/e2e/framework" |
| e2eskipper "k8s.io/kubernetes/test/e2e/framework/skipper" |
| e2evolume "k8s.io/kubernetes/test/e2e/framework/volume" |
| storageframework "k8s.io/kubernetes/test/e2e/storage/framework" |
| admissionapi "k8s.io/pod-security-admission/api" |
| ) |
| |
| type capacityTestSuite struct { |
| tsInfo storageframework.TestSuiteInfo |
| } |
| |
| // InitCustomCapacityTestSuite returns capacityTestSuite that implements TestSuite interface |
| // using custom test patterns |
| func InitCustomCapacityTestSuite(patterns []storageframework.TestPattern) storageframework.TestSuite { |
| return &capacityTestSuite{ |
| tsInfo: storageframework.TestSuiteInfo{ |
| Name: "capacity", |
| TestPatterns: patterns, |
| SupportedSizeRange: e2evolume.SizeRange{ |
| Min: "1Mi", |
| }, |
| }, |
| } |
| } |
| |
| // InitCapacityTestSuite returns capacityTestSuite that implements TestSuite interface\ |
| // using test suite default patterns |
| func InitCapacityTestSuite() storageframework.TestSuite { |
| patterns := []storageframework.TestPattern{ |
| storageframework.DefaultFsDynamicPV, |
| } |
| return InitCustomCapacityTestSuite(patterns) |
| } |
| |
| func (p *capacityTestSuite) GetTestSuiteInfo() storageframework.TestSuiteInfo { |
| return p.tsInfo |
| } |
| |
| func (p *capacityTestSuite) SkipUnsupportedTests(driver storageframework.TestDriver, pattern storageframework.TestPattern) { |
| // Check preconditions. |
| if pattern.VolType != storageframework.DynamicPV { |
| e2eskipper.Skipf("Suite %q does not support %v", p.tsInfo.Name, pattern.VolType) |
| } |
| dInfo := driver.GetDriverInfo() |
| if !dInfo.Capabilities[storageframework.CapCapacity] { |
| e2eskipper.Skipf("Driver %s doesn't publish storage capacity -- skipping", dInfo.Name) |
| } |
| } |
| |
| func (p *capacityTestSuite) DefineTests(driver storageframework.TestDriver, pattern storageframework.TestPattern) { |
| var ( |
| dInfo = driver.GetDriverInfo() |
| dDriver storageframework.DynamicPVTestDriver |
| sc *storagev1.StorageClass |
| ) |
| |
| // Beware that it also registers an AfterEach which renders f unusable. Any code using |
| // f must run inside an It or Context callback. |
| f := framework.NewFrameworkWithCustomTimeouts("capacity", storageframework.GetDriverTimeouts(driver)) |
| f.NamespacePodSecurityLevel = admissionapi.LevelPrivileged |
| |
| init := func(ctx context.Context) { |
| dDriver, _ = driver.(storageframework.DynamicPVTestDriver) |
| // Now do the more expensive test initialization. |
| config := driver.PrepareTest(ctx, f) |
| sc = dDriver.GetDynamicProvisionStorageClass(ctx, config, pattern.FsType) |
| if sc == nil { |
| e2eskipper.Skipf("Driver %q does not define Dynamic Provision StorageClass - skipping", dInfo.Name) |
| } |
| } |
| |
| ginkgo.It("provides storage capacity information", func(ctx context.Context) { |
| init(ctx) |
| |
| timeout := time.Minute |
| pollInterval := time.Second |
| matchSC := HaveCapacitiesForClass(sc.Name) |
| listAll := gomega.Eventually(ctx, func() (*storagev1.CSIStorageCapacityList, error) { |
| return f.ClientSet.StorageV1().CSIStorageCapacities("").List(ctx, metav1.ListOptions{}) |
| }, timeout, pollInterval) |
| |
| // If we have further information about what storage |
| // capacity information to expect from the driver, |
| // then we can make the check more specific. The baseline |
| // is that it provides some arbitrary capacity for the |
| // storage class. |
| matcher := matchSC |
| if len(dInfo.TopologyKeys) == 1 { |
| // We can construct topology segments by |
| // collecting all values for this one key and |
| // then expect one CSIStorageCapacity object |
| // per value for the storage class. |
| // |
| // Local storage on a node will be covered by |
| // this checking. A more complex approach for |
| // drivers with multiple keys might be |
| // possible, too, but is not currently |
| // implemented. |
| matcher = HaveCapacitiesForClassAndNodes(ctx, f.ClientSet, sc.Provisioner, sc.Name, dInfo.TopologyKeys[0]) |
| } |
| |
| // Create storage class and wait for capacity information. |
| sc := SetupStorageClass(ctx, f.ClientSet, sc) |
| listAll.Should(MatchCapacities(matcher), "after creating storage class") |
| |
| // Delete storage class again and wait for removal of storage capacity information. |
| err := f.ClientSet.StorageV1().StorageClasses().Delete(ctx, sc.Name, metav1.DeleteOptions{}) |
| framework.ExpectNoError(err, "delete storage class") |
| listAll.ShouldNot(MatchCapacities(matchSC), "after deleting storage class") |
| }) |
| } |
| |
| func formatCapacities(capacities []storagev1.CSIStorageCapacity) []string { |
| lines := []string{} |
| for _, capacity := range capacities { |
| lines = append(lines, fmt.Sprintf(" %+v", capacity)) |
| } |
| return lines |
| } |
| |
| // MatchCapacities runs some kind of check against *storagev1.CSIStorageCapacityList. |
| // In case of failure, all actual objects are appended to the failure message. |
| func MatchCapacities(match types.GomegaMatcher) types.GomegaMatcher { |
| return matchCSIStorageCapacities{match: match} |
| } |
| |
| type matchCSIStorageCapacities struct { |
| match types.GomegaMatcher |
| } |
| |
| var _ types.GomegaMatcher = matchCSIStorageCapacities{} |
| |
| func (m matchCSIStorageCapacities) Match(actual interface{}) (success bool, err error) { |
| return m.match.Match(actual) |
| } |
| |
| func (m matchCSIStorageCapacities) FailureMessage(actual interface{}) (message string) { |
| return m.match.FailureMessage(actual) + m.dump(actual) |
| } |
| |
| func (m matchCSIStorageCapacities) NegatedFailureMessage(actual interface{}) (message string) { |
| return m.match.NegatedFailureMessage(actual) + m.dump(actual) |
| } |
| |
| func (m matchCSIStorageCapacities) dump(actual interface{}) string { |
| capacities, ok := actual.(*storagev1.CSIStorageCapacityList) |
| if !ok || capacities == nil { |
| return "" |
| } |
| lines := []string{"\n\nall CSIStorageCapacity objects:"} |
| for _, capacity := range capacities.Items { |
| lines = append(lines, fmt.Sprintf("%+v", capacity)) |
| } |
| return strings.Join(lines, "\n") |
| } |
| |
| // CapacityMatcher can be used to compose different matchers where one |
| // adds additional checks for CSIStorageCapacity objects already checked |
| // by another. |
| type CapacityMatcher interface { |
| types.GomegaMatcher |
| // MatchedCapacities returns all CSICapacityObjects which were |
| // found during the preceding Match call. |
| MatchedCapacities() []storagev1.CSIStorageCapacity |
| } |
| |
| // HaveCapacitiesForClass filters all storage capacity objects in a *storagev1.CSIStorageCapacityList |
| // by storage class. Success is when when there is at least one. |
| func HaveCapacitiesForClass(scName string) CapacityMatcher { |
| return &haveCSIStorageCapacities{scName: scName} |
| } |
| |
| type haveCSIStorageCapacities struct { |
| scName string |
| matchingCapacities []storagev1.CSIStorageCapacity |
| } |
| |
| var _ CapacityMatcher = &haveCSIStorageCapacities{} |
| |
| func (h *haveCSIStorageCapacities) Match(actual interface{}) (success bool, err error) { |
| capacities, ok := actual.(*storagev1.CSIStorageCapacityList) |
| if !ok { |
| return false, fmt.Errorf("expected *storagev1.CSIStorageCapacityList, got: %T", actual) |
| } |
| h.matchingCapacities = nil |
| for _, capacity := range capacities.Items { |
| if capacity.StorageClassName == h.scName { |
| h.matchingCapacities = append(h.matchingCapacities, capacity) |
| } |
| } |
| return len(h.matchingCapacities) > 0, nil |
| } |
| |
| func (h *haveCSIStorageCapacities) MatchedCapacities() []storagev1.CSIStorageCapacity { |
| return h.matchingCapacities |
| } |
| |
| func (h *haveCSIStorageCapacities) FailureMessage(actual interface{}) (message string) { |
| return fmt.Sprintf("no CSIStorageCapacity objects for storage class %q", h.scName) |
| } |
| |
| func (h *haveCSIStorageCapacities) NegatedFailureMessage(actual interface{}) (message string) { |
| return fmt.Sprintf("CSIStorageCapacity objects for storage class %q:\n%s", |
| h.scName, |
| strings.Join(formatCapacities(h.matchingCapacities), "\n"), |
| ) |
| } |
| |
| // HaveCapacitiesForClassAndNodes matches objects by storage class name. It finds |
| // all nodes on which the driver runs and expects one object per node. |
| func HaveCapacitiesForClassAndNodes(ctx context.Context, client kubernetes.Interface, driverName, scName, topologyKey string) CapacityMatcher { |
| return &haveLocalStorageCapacities{ |
| ctx: ctx, |
| client: client, |
| driverName: driverName, |
| match: HaveCapacitiesForClass(scName), |
| topologyKey: topologyKey, |
| } |
| } |
| |
| type haveLocalStorageCapacities struct { |
| ctx context.Context |
| client kubernetes.Interface |
| driverName string |
| match CapacityMatcher |
| topologyKey string |
| |
| matchSuccess bool |
| expectedCapacities []storagev1.CSIStorageCapacity |
| unexpectedCapacities []storagev1.CSIStorageCapacity |
| missingTopologyValues []string |
| } |
| |
| var _ CapacityMatcher = &haveLocalStorageCapacities{} |
| |
| func (h *haveLocalStorageCapacities) Match(actual interface{}) (success bool, err error) { |
| ctx := h.ctx |
| h.expectedCapacities = nil |
| h.unexpectedCapacities = nil |
| h.missingTopologyValues = nil |
| |
| // First check with underlying matcher. |
| success, err = h.match.Match(actual) |
| h.matchSuccess = success |
| if !success || err != nil { |
| return |
| } |
| |
| // Find all nodes on which the driver runs. |
| csiNodes, err := h.client.StorageV1().CSINodes().List(ctx, metav1.ListOptions{}) |
| if err != nil { |
| return false, err |
| } |
| topologyValues := map[string]bool{} |
| for _, csiNode := range csiNodes.Items { |
| for _, driver := range csiNode.Spec.Drivers { |
| if driver.Name != h.driverName { |
| continue |
| } |
| node, err := h.client.CoreV1().Nodes().Get(ctx, csiNode.Name, metav1.GetOptions{}) |
| if err != nil { |
| return false, err |
| } |
| value, ok := node.Labels[h.topologyKey] |
| if !ok || value == "" { |
| return false, fmt.Errorf("driver %q should run on node %q, but its topology label %q was not set", |
| h.driverName, |
| node.Name, |
| h.topologyKey) |
| } |
| topologyValues[value] = true |
| break |
| } |
| } |
| if len(topologyValues) == 0 { |
| return false, fmt.Errorf("driver %q not running on any node", h.driverName) |
| } |
| |
| // Now check that for each topology value there is exactly one CSIStorageCapacity object. |
| remainingTopologyValues := map[string]bool{} |
| for value := range topologyValues { |
| remainingTopologyValues[value] = true |
| } |
| capacities := h.match.MatchedCapacities() |
| for _, capacity := range capacities { |
| if capacity.NodeTopology == nil || |
| len(capacity.NodeTopology.MatchExpressions) > 0 || |
| len(capacity.NodeTopology.MatchLabels) != 1 || |
| !remainingTopologyValues[capacity.NodeTopology.MatchLabels[h.topologyKey]] { |
| h.unexpectedCapacities = append(h.unexpectedCapacities, capacity) |
| continue |
| } |
| remainingTopologyValues[capacity.NodeTopology.MatchLabels[h.topologyKey]] = false |
| h.expectedCapacities = append(h.expectedCapacities, capacity) |
| } |
| |
| // Success is when there were no unexpected capacities and enough expected ones. |
| for value, remaining := range remainingTopologyValues { |
| if remaining { |
| h.missingTopologyValues = append(h.missingTopologyValues, value) |
| } |
| } |
| return len(h.unexpectedCapacities) == 0 && len(h.missingTopologyValues) == 0, nil |
| } |
| |
| func (h *haveLocalStorageCapacities) MatchedCapacities() []storagev1.CSIStorageCapacity { |
| return h.match.MatchedCapacities() |
| } |
| |
| func (h *haveLocalStorageCapacities) FailureMessage(actual interface{}) (message string) { |
| if !h.matchSuccess { |
| return h.match.FailureMessage(actual) |
| } |
| var lines []string |
| if len(h.unexpectedCapacities) != 0 { |
| lines = append(lines, "unexpected CSIStorageCapacity objects:") |
| lines = append(lines, formatCapacities(h.unexpectedCapacities)...) |
| } |
| if len(h.missingTopologyValues) != 0 { |
| lines = append(lines, fmt.Sprintf("no CSIStorageCapacity objects with topology key %q and values %v", |
| h.topologyKey, h.missingTopologyValues, |
| )) |
| } |
| return strings.Join(lines, "\n") |
| } |
| |
| func (h *haveLocalStorageCapacities) NegatedFailureMessage(actual interface{}) (message string) { |
| if h.matchSuccess { |
| return h.match.NegatedFailureMessage(actual) |
| } |
| // It's not entirely clear whether negating this check is useful. Just dump all info that we have. |
| var lines []string |
| if len(h.expectedCapacities) != 0 { |
| lines = append(lines, "expected CSIStorageCapacity objects:") |
| lines = append(lines, formatCapacities(h.expectedCapacities)...) |
| } |
| if len(h.unexpectedCapacities) != 0 { |
| lines = append(lines, "unexpected CSIStorageCapacity objects:") |
| lines = append(lines, formatCapacities(h.unexpectedCapacities)...) |
| } |
| if len(h.missingTopologyValues) != 0 { |
| lines = append(lines, fmt.Sprintf("no CSIStorageCapacity objects with topology key %q and values %v", |
| h.topologyKey, h.missingTopologyValues, |
| )) |
| } |
| return strings.Join(lines, "\n") |
| } |