Skip to content

Commit f674431

Browse files
committed
e2e: add VolumeAttributesClass example and e2e test
Add a VolumeAttributesClass example for NVMe-oF volumes demonstrating the mutable parameters supported by the driver: - Host access control via allowHostNQNs parameter (for external hosts) - QoS parameters The example includes a working configuration with host access control enabled, and all optional parameters documented as comments with explanations. Also add an e2e test that validates VAC functionality by: 1. Creating a VAC with allowHostNQNs parameter 2. Creating a PVC that references the VAC 3. Verifying volume creation 4. check the host was added by run nvme connect 5. activate new VAC and recheck the new host 5. Validating cleanup Signed-off-by: gadi-didi <gadi.didi@ibm.com>
1 parent 734c5b1 commit f674431

4 files changed

Lines changed: 399 additions & 1 deletion

File tree

e2e/nvmeof-deploy.go

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"encoding/json"
2222
"fmt"
23+
"maps"
2324
"strings"
2425
"time"
2526

@@ -29,6 +30,7 @@ import (
2930
apierrs "k8s.io/apimachinery/pkg/api/errors"
3031
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3132
"k8s.io/apimachinery/pkg/util/wait"
33+
"k8s.io/client-go/kubernetes"
3234
"k8s.io/kubernetes/test/e2e/framework"
3335
)
3436

@@ -168,7 +170,13 @@ func createNVMeoFStorageClass(
168170
}
169171
}
170172

171-
sc.Parameters["subsystemNQN"] = "nqn.2025-08.io.ceph:" + f.UniqueName
173+
// Only set subsystemNQN if not explicitly disabled via skipSubsystemNQN parameter
174+
// For external clients using allowHostNQNs, subsystemNQN should not be set
175+
if _, skipSubsystemNQN := parameters["skipSubsystemNQN"]; !skipSubsystemNQN {
176+
sc.Parameters["subsystemNQN"] = "nqn.2025-08.io.ceph:" + f.UniqueName
177+
}
178+
// Remove the skipSubsystemNQN marker from parameters as it's not a real SC parameter
179+
delete(sc.Parameters, "skipSubsystemNQN")
172180

173181
gwHost, gwIP := getNVMeofGateway(f.ClientSet)
174182
framework.Logf("configuring StorageClass for gateway %q at %s", gwHost, gwIP)
@@ -242,3 +250,73 @@ func createNVMeoFCredentials(f *framework.Framework) {
242250
err = createRBDSecret(f, nvmeofNodePluginSecretName, nvmeofKeyringNodePluginUsername, key)
243251
Expect(err).ShouldNot(HaveOccurred())
244252
}
253+
254+
func createNVMeOFVolumeAttributesClass(
255+
c kubernetes.Interface,
256+
name string,
257+
params map[string]string,
258+
) error {
259+
vac, err := getVolumeAttributesClass(nvmeofExamplePath + "volumeattributesclass.yaml")
260+
if err != nil {
261+
return fmt.Errorf("failed to get vac: %w", err)
262+
}
263+
if name != "" {
264+
vac.Name = name
265+
}
266+
267+
// overload any parameters that were passed
268+
if params == nil {
269+
// create an empty params, so that params["clusterID"] below
270+
// does not panic
271+
params = map[string]string{}
272+
}
273+
maps.Copy(vac.Parameters, params)
274+
275+
timeout := time.Duration(deployTimeout) * time.Minute
276+
277+
return wait.PollUntilContextTimeout(context.TODO(), poll, timeout, true, func(ctx context.Context) (bool, error) {
278+
_, err = c.StorageV1().VolumeAttributesClasses().Create(ctx, &vac, metav1.CreateOptions{})
279+
if err != nil {
280+
if apierrs.IsAlreadyExists(err) {
281+
return true, nil
282+
}
283+
if isRetryableAPIError(err) {
284+
return false, nil
285+
}
286+
framework.Logf("error creating VolumeAttributesClass %q: %v", vac.Name, err)
287+
288+
return false, fmt.Errorf("failed to create VolumeAttributesClass %q: %w", vac.Name, err)
289+
}
290+
291+
return true, nil
292+
})
293+
}
294+
295+
func deleteNVMeOFVolumeAttributesClass(c kubernetes.Interface, name string) error {
296+
vac, err := getVolumeAttributesClass(nvmeofExamplePath + "volumeattributesclass.yaml")
297+
if err != nil {
298+
return err
299+
}
300+
if name != "" {
301+
vac.Name = name
302+
}
303+
304+
timeout := time.Duration(deployTimeout) * time.Minute
305+
306+
return wait.PollUntilContextTimeout(context.TODO(), poll, timeout, true, func(ctx context.Context) (bool, error) {
307+
err = c.StorageV1().VolumeAttributesClasses().Delete(ctx, vac.Name, metav1.DeleteOptions{})
308+
if err != nil {
309+
if apierrs.IsNotFound(err) {
310+
return true, nil
311+
}
312+
if isRetryableAPIError(err) {
313+
return false, nil
314+
}
315+
framework.Logf("error deleting VolumeAttributesClass %q: %v", vac.Name, err)
316+
317+
return false, fmt.Errorf("failed to delete VolumeAttributesClass %q: %w", vac.Name, err)
318+
}
319+
320+
return true, nil
321+
})
322+
}

e2e/nvmeof.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,5 +311,126 @@ var _ = ginkgo.Describe("nvmeof", func() {
311311
framework.Logf("GroupLock Pods-only test passed: %d Pods created and deleted with rapid Group A/B switching",
312312
totalCount)
313313
})
314+
315+
ginkgo.It("create a PVC with VAC containing allowHostNQNs, apply new VAC then delete the PVC", func() {
316+
317+
// Vac test requires Kubernetes 1.34+ for VolumeAttributesClass support
318+
if !k8sVersionGreaterEquals(f.ClientSet, 1, 34) {
319+
framework.Logf("skipping VolumeAttributesClass test, needs Kubernetes >= 1.34")
320+
321+
return
322+
}
323+
324+
// This test validates VolumeAttributesClass (VAC) support for NVMe-oF volumes
325+
// with host access control via the allowHostNQNs parameter.
326+
//
327+
// Test flow:
328+
// 1. Create a StorageClass for external clients (without subsystemNQN) - deleted in the end.
329+
// 2. Create 2 VACs with allowHostNQNs parameter containing lists of allowed host NQNs
330+
// 3. Create a PVC that references the VAC via spec.volumeAttributesClassName
331+
// 4. Verify the volume is created and the host access control is applied
332+
// 5. Update the PVC to reference a different VAC and verify the update is applied
333+
// 6. Delete the PVC and verify cleanup
334+
//
335+
// This tests the ControllerModifyVolume functionality in the NVMe-oF driver,
336+
// ensuring that host access lists can be configured via VAC at volume creation time.
337+
// For external clients using allowHostNQNs, the StorageClass should not have subsystemNQN.
338+
339+
// Step 1: Create a separate StorageClass for external clients (without subsystemNQN)
340+
externalClientSC := "e2e-" + f.UniqueName + "-sc-external"
341+
options := map[string]string{}
342+
params := map[string]string{
343+
"pool": nvmeofPool,
344+
"skipSubsystemNQN": "true", // Signal to skip subsystemNQN for external clients
345+
}
346+
policy := v1.PersistentVolumeReclaimDelete
347+
348+
ginkgo.By("Creating StorageClass for external clients (without subsystemNQN)")
349+
createNVMeoFStorageClass(f, externalClientSC, options, params, policy)
350+
defer deleteNVMeofStorageClass(f, externalClientSC)
351+
352+
// Step 2: Create 2 VACs with allowHostNQNs parameter
353+
vacName1 := "e2e-" + f.UniqueName + "-vac1"
354+
vacName2 := "e2e-" + f.UniqueName + "-vac2"
355+
hostListParam := map[string]string{
356+
"allowHostNQNs": `- nqn.2014-08.org.nvmexpress:test-host-1
357+
- nqn.2014-08.org.nvmexpress:test-host-2
358+
- nqn.2014-08.org.nvmexpress:test-host-3`,
359+
}
360+
hostListParam2 := map[string]string{
361+
"allowHostNQNs": `- nqn.2014-08.org.nvmexpress:test-host-1
362+
- nqn.2014-08.org.nvmexpress:test-host-5`,
363+
}
364+
ginkgo.By("Creating VolumeAttributesClass with allowHostNQNs")
365+
err := createNVMeOFVolumeAttributesClass(f.ClientSet, vacName1, hostListParam)
366+
Expect(err).ShouldNot(HaveOccurred())
367+
defer func() {
368+
err = deleteNVMeOFVolumeAttributesClass(f.ClientSet, vacName1)
369+
if err != nil {
370+
logAndFail("failed to delete volumeattributesclass: %v", err)
371+
}
372+
}()
373+
374+
ginkgo.By("Creating second VolumeAttributesClass with different allowHostNQNs")
375+
err = createNVMeOFVolumeAttributesClass(f.ClientSet, vacName2, hostListParam2)
376+
Expect(err).ShouldNot(HaveOccurred())
377+
defer func() {
378+
err = deleteNVMeOFVolumeAttributesClass(f.ClientSet, vacName2)
379+
if err != nil {
380+
logAndFail("failed to delete volumeattributesclass: %v", err)
381+
}
382+
}()
383+
384+
// Step 3: Create PVC with reference to the VAC1 and external client StorageClass
385+
pvc, err := loadPVC(pvcPath)
386+
Expect(err).ShouldNot(HaveOccurred())
387+
388+
pvc.Namespace = f.UniqueName
389+
pvc.Spec.StorageClassName = &externalClientSC // Use external client SC without subsystemNQN
390+
pvc.Spec.VolumeAttributesClassName = &vacName1
391+
392+
ginkgo.By("Creating PVC with VolumeAttributesClass reference for external clients")
393+
err = createPVCAndvalidatePV(f.ClientSet, pvc, deployTimeout)
394+
Expect(err).ShouldNot(HaveOccurred())
395+
396+
pv, err := getBoundPV(f.ClientSet, pvc)
397+
Expect(err).ShouldNot(HaveOccurred())
398+
Expect(pv.Spec.VolumeAttributesClassName).NotTo(BeNil())
399+
Expect(*pv.Spec.VolumeAttributesClassName).To(Equal(vacName1))
400+
401+
validateRBDImageCount(f, 1, nvmeofPool)
402+
validateOmapCount(f, 1, rbdType, nvmeofPool, volumesType)
403+
404+
// Step 4: Verify allowHostNQNs by testing connection with allowed and disallowed hosts
405+
ginkgo.By("Verifying allowHostNQNs configuration via nvme connect-all")
406+
_, gatewayIP := getNVMeofGateway(f.ClientSet)
407+
err = verifyAllowHostNQNsViaConnect(f, gatewayIP, hostListParam["allowHostNQNs"])
408+
Expect(err).ShouldNot(HaveOccurred())
409+
410+
// Step 5: Update PVC to reference VAC2 and verify the update is applied
411+
ginkgo.By("Updating PVC to reference a different VolumeAttributesClass")
412+
413+
err = modifyPVCVolumeAttributesClass(
414+
f.ClientSet,
415+
pvc,
416+
vacName2)
417+
if err != nil {
418+
logAndFail("failed to modify volumeattributesclass: %v", err)
419+
}
420+
421+
// Step 6: Verify new allowHostNQNs after update
422+
ginkgo.By("Verifying updated allowHostNQNs configuration")
423+
err = verifyAllowHostNQNsViaConnect(f, gatewayIP, hostListParam2["allowHostNQNs"])
424+
Expect(err).ShouldNot(HaveOccurred())
425+
426+
// Step 7: Delete PVC
427+
ginkgo.By("Deleting PVC with VAC")
428+
err = deletePVCAndValidatePV(f.ClientSet, pvc, deployTimeout)
429+
Expect(err).ShouldNot(HaveOccurred())
430+
431+
// validate created backend rbd images are deleted
432+
validateRBDImageCount(f, 0, nvmeofPool)
433+
validateOmapCount(f, 0, rbdType, nvmeofPool, volumesType)
434+
})
314435
})
315436
})

e2e/nvmeof_helper.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@ limitations under the License.
1717
package e2e
1818

1919
import (
20+
"context"
21+
"encoding/json"
2022
"fmt"
2123
"sync"
2224

2325
"github.com/google/uuid"
26+
"go.yaml.in/yaml/v2"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2428
"k8s.io/kubernetes/test/e2e/framework"
2529
)
2630

@@ -410,3 +414,150 @@ func mixedCreateDeletePodsOnly(
410414

411415
return nil
412416
}
417+
418+
// nvmeHost represents a host entry from 'nvme list-subsys -o json'.
419+
// We only need HostNQN for verification; other fields are ignored during unmarshaling.
420+
type nvmeHost struct {
421+
HostNQN string `json:"HostNQN"`
422+
}
423+
424+
// verifyAllowHostNQNsViaConnect verifies that the allowHostNQNs configuration is working
425+
// by attempting to connect with allowed and disallowed host NQNs.
426+
func verifyAllowHostNQNsViaConnect(f *framework.Framework, gatewayIP, allowHostNQNsYAML string) error {
427+
// Parse the allowed host NQNs from YAML format
428+
allowedHosts := parseHostNQNsFromYAML(allowHostNQNsYAML)
429+
if len(allowedHosts) == 0 {
430+
return fmt.Errorf("no allowed hosts found in allowHostNQNs")
431+
}
432+
433+
framework.Logf("Testing allowHostNQNs with %d allowed hosts", len(allowedHosts))
434+
435+
// Get a node to run commands on
436+
nodes, err := f.ClientSet.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
437+
if err != nil {
438+
return fmt.Errorf("failed to list nodes: %w", err)
439+
}
440+
if len(nodes.Items) == 0 {
441+
return fmt.Errorf("no nodes available")
442+
}
443+
nodeName := nodes.Items[0].Name
444+
445+
// Get the node-plugin pod on that node
446+
nodePluginPod, err := getDaemonsetPodOnNode(f, nvmeofDaemonsetName, nodeName, cephCSINamespace)
447+
if err != nil {
448+
return fmt.Errorf("failed to get node-plugin pod: %w", err)
449+
}
450+
451+
// Test 1: Try to connect with an allowed host NQN
452+
allowedHost := allowedHosts[0]
453+
framework.Logf("Testing connection with ALLOWED host NQN: %s", allowedHost)
454+
455+
connected := testNVMeConnection(f, nodePluginPod, gatewayIP, allowedHost)
456+
457+
if !connected {
458+
return fmt.Errorf("connection failed for allowed host NQN %s", allowedHost)
459+
}
460+
framework.Logf("SUCCESS: Connected with allowed host NQN %s", allowedHost)
461+
462+
return nil
463+
}
464+
465+
// testNVMeConnection attempts to connect to an NVMe-oF gateway with a specific host NQN
466+
// and verifies the connection via nvme list-subsys.
467+
func testNVMeConnection(f *framework.Framework, podName, gatewayIP, hostNQN string) bool {
468+
// Ensure cleanup happens regardless of success or failure
469+
defer disconnectAllNVMe(f, podName)
470+
471+
// Step 1: Try to connect with the specified host NQN
472+
connectCmd := fmt.Sprintf("nvme connect-all --transport=tcp --traddr=%s --hostnqn=%s 2>&1", gatewayIP, hostNQN)
473+
474+
framework.Logf("Executing: %s", connectCmd)
475+
stdout, stderr, err := execCommandInContainerByPodName(
476+
f,
477+
connectCmd,
478+
cephCSINamespace,
479+
podName,
480+
nvmeofContainerName,
481+
)
482+
483+
// Log the output
484+
if stdout != "" {
485+
framework.Logf("nvme connect-all stdout: %s", stdout)
486+
}
487+
if stderr != "" {
488+
framework.Logf("nvme connect-all stderr: %s", stderr)
489+
}
490+
491+
// Check if connection failed (expected for disallowed hosts)
492+
if err != nil {
493+
framework.Logf("Connection failed (expected for disallowed hosts): %v", err)
494+
495+
return false
496+
}
497+
498+
// Step 2: Verify the connection using nvme list-subsys with JSON output
499+
listCmd := "nvme list-subsys -o json 2>&1"
500+
framework.Logf("Verifying connection with: %s", listCmd)
501+
502+
stdout, stderr, err = execCommandInContainerByPodName(
503+
f,
504+
listCmd,
505+
cephCSINamespace,
506+
podName,
507+
nvmeofContainerName,
508+
)
509+
510+
if err != nil {
511+
framework.Logf("nvme list-subsys failed: %v, stderr: %s", err, stderr)
512+
513+
return false
514+
}
515+
516+
// Step 3: Parse JSON output - it's an array of hosts
517+
var hosts []nvmeHost
518+
if err := json.Unmarshal([]byte(stdout), &hosts); err != nil {
519+
framework.Logf("Failed to parse nvme list-subsys JSON output: %v", err)
520+
framework.Logf("Raw output: %s", stdout)
521+
522+
return false
523+
}
524+
525+
// Step 4: Check if the host with our NQN exists in the output
526+
for _, host := range hosts {
527+
if host.HostNQN == hostNQN {
528+
framework.Logf("Found host in list-subsys: %s", host.HostNQN)
529+
530+
return true
531+
}
532+
}
533+
framework.Logf("Host %s not found in list-subsys output", hostNQN)
534+
535+
return false
536+
}
537+
538+
// disconnectAllNVMe disconnects all NVMe-oF connections.
539+
func disconnectAllNVMe(f *framework.Framework, podName string) {
540+
disconnectCmd := "nvme disconnect-all 2>&1 || true"
541+
stdout, stderr, err := execCommandInContainerByPodName(
542+
f,
543+
disconnectCmd,
544+
cephCSINamespace,
545+
podName,
546+
nvmeofContainerName,
547+
)
548+
if err != nil {
549+
framework.Logf("nvme disconnect-all warning: %v, stdout: %s, stderr: %s", err, stdout, stderr)
550+
} else {
551+
framework.Logf("Disconnected all NVMe connections")
552+
}
553+
}
554+
555+
// parseHostNQNsFromYAML parses the allowHostNQNs YAML format into a slice of NQNs.
556+
func parseHostNQNsFromYAML(yamlStr string) []string {
557+
var hosts []string
558+
if err := yaml.Unmarshal([]byte(yamlStr), &hosts); err != nil {
559+
framework.Logf("Failed to parse allowHostNQNs YAML: %v", err)
560+
return nil
561+
}
562+
return hosts
563+
}

0 commit comments

Comments
 (0)