github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/client/allocrunner/csi_hook_test.go (about)

     1  package allocrunner
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"path/filepath"
     8  	"testing"
     9  	"time"
    10  
    11  	"github.com/hashicorp/nomad/ci"
    12  	"github.com/hashicorp/nomad/client/allocrunner/interfaces"
    13  	"github.com/hashicorp/nomad/client/pluginmanager"
    14  	"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
    15  	cstructs "github.com/hashicorp/nomad/client/structs"
    16  	"github.com/hashicorp/nomad/helper/pointer"
    17  	"github.com/hashicorp/nomad/helper/testlog"
    18  	"github.com/hashicorp/nomad/nomad/mock"
    19  	"github.com/hashicorp/nomad/nomad/structs"
    20  	"github.com/hashicorp/nomad/plugins/drivers"
    21  	"github.com/stretchr/testify/require"
    22  )
    23  
    24  var _ interfaces.RunnerPrerunHook = (*csiHook)(nil)
    25  var _ interfaces.RunnerPostrunHook = (*csiHook)(nil)
    26  
    27  func TestCSIHook(t *testing.T) {
    28  	ci.Parallel(t)
    29  
    30  	alloc := mock.Alloc()
    31  	logger := testlog.HCLogger(t)
    32  
    33  	testcases := []struct {
    34  		name                   string
    35  		volumeRequests         map[string]*structs.VolumeRequest
    36  		startsUnschedulable    bool
    37  		startsWithClaims       bool
    38  		expectedClaimErr       error
    39  		expectedMounts         map[string]*csimanager.MountInfo
    40  		expectedMountCalls     int
    41  		expectedUnmountCalls   int
    42  		expectedClaimCalls     int
    43  		expectedUnpublishCalls int
    44  	}{
    45  
    46  		{
    47  			name: "simple case",
    48  			volumeRequests: map[string]*structs.VolumeRequest{
    49  				"vol0": {
    50  					Name:           "vol0",
    51  					Type:           structs.VolumeTypeCSI,
    52  					Source:         "testvolume0",
    53  					ReadOnly:       true,
    54  					AccessMode:     structs.CSIVolumeAccessModeSingleNodeReader,
    55  					AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
    56  					MountOptions:   &structs.CSIMountOptions{},
    57  					PerAlloc:       false,
    58  				},
    59  			},
    60  			expectedMounts: map[string]*csimanager.MountInfo{
    61  				"vol0": &csimanager.MountInfo{Source: fmt.Sprintf(
    62  					"test-alloc-dir/%s/testvolume0/ro-file-system-single-node-reader-only", alloc.ID)},
    63  			},
    64  			expectedMountCalls:     1,
    65  			expectedUnmountCalls:   1,
    66  			expectedClaimCalls:     1,
    67  			expectedUnpublishCalls: 1,
    68  		},
    69  
    70  		{
    71  			name: "per-alloc case",
    72  			volumeRequests: map[string]*structs.VolumeRequest{
    73  				"vol0": {
    74  					Name:           "vol0",
    75  					Type:           structs.VolumeTypeCSI,
    76  					Source:         "testvolume0",
    77  					ReadOnly:       true,
    78  					AccessMode:     structs.CSIVolumeAccessModeSingleNodeReader,
    79  					AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
    80  					MountOptions:   &structs.CSIMountOptions{},
    81  					PerAlloc:       true,
    82  				},
    83  			},
    84  			expectedMounts: map[string]*csimanager.MountInfo{
    85  				"vol0": &csimanager.MountInfo{Source: fmt.Sprintf(
    86  					"test-alloc-dir/%s/testvolume0/ro-file-system-single-node-reader-only", alloc.ID)},
    87  			},
    88  			expectedMountCalls:     1,
    89  			expectedUnmountCalls:   1,
    90  			expectedClaimCalls:     1,
    91  			expectedUnpublishCalls: 1,
    92  		},
    93  
    94  		{
    95  			name: "fatal error on claim",
    96  			volumeRequests: map[string]*structs.VolumeRequest{
    97  				"vol0": {
    98  					Name:           "vol0",
    99  					Type:           structs.VolumeTypeCSI,
   100  					Source:         "testvolume0",
   101  					ReadOnly:       true,
   102  					AccessMode:     structs.CSIVolumeAccessModeSingleNodeReader,
   103  					AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
   104  					MountOptions:   &structs.CSIMountOptions{},
   105  					PerAlloc:       false,
   106  				},
   107  			},
   108  			startsUnschedulable: true,
   109  			expectedMounts: map[string]*csimanager.MountInfo{
   110  				"vol0": &csimanager.MountInfo{Source: fmt.Sprintf(
   111  					"test-alloc-dir/%s/testvolume0/ro-file-system-single-node-reader-only", alloc.ID)},
   112  			},
   113  			expectedMountCalls:     0,
   114  			expectedUnmountCalls:   0,
   115  			expectedClaimCalls:     1,
   116  			expectedUnpublishCalls: 0,
   117  			expectedClaimErr: errors.New(
   118  				"claim volumes: could not claim volume testvolume0: volume is currently unschedulable"),
   119  		},
   120  
   121  		{
   122  			name: "retryable error on claim",
   123  			volumeRequests: map[string]*structs.VolumeRequest{
   124  				"vol0": {
   125  					Name:           "vol0",
   126  					Type:           structs.VolumeTypeCSI,
   127  					Source:         "testvolume0",
   128  					ReadOnly:       true,
   129  					AccessMode:     structs.CSIVolumeAccessModeSingleNodeReader,
   130  					AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
   131  					MountOptions:   &structs.CSIMountOptions{},
   132  					PerAlloc:       false,
   133  				},
   134  			},
   135  			startsWithClaims: true,
   136  			expectedMounts: map[string]*csimanager.MountInfo{
   137  				"vol0": &csimanager.MountInfo{Source: fmt.Sprintf(
   138  					"test-alloc-dir/%s/testvolume0/ro-file-system-single-node-reader-only", alloc.ID)},
   139  			},
   140  			expectedMountCalls:     1,
   141  			expectedUnmountCalls:   1,
   142  			expectedClaimCalls:     2,
   143  			expectedUnpublishCalls: 1,
   144  		},
   145  
   146  		// TODO: this won't actually work on the client.
   147  		// https://github.com/hashicorp/nomad/issues/11798
   148  		//
   149  		// {
   150  		// 	name: "one source volume mounted read-only twice",
   151  		// 	volumeRequests: map[string]*structs.VolumeRequest{
   152  		// 		"vol0": {
   153  		// 			Name:           "vol0",
   154  		// 			Type:           structs.VolumeTypeCSI,
   155  		// 			Source:         "testvolume0",
   156  		// 			ReadOnly:       true,
   157  		// 			AccessMode:     structs.CSIVolumeAccessModeMultiNodeReader,
   158  		// 			AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
   159  		// 			MountOptions:   &structs.CSIMountOptions{},
   160  		// 			PerAlloc:       false,
   161  		// 		},
   162  		// 		"vol1": {
   163  		// 			Name:           "vol1",
   164  		// 			Type:           structs.VolumeTypeCSI,
   165  		// 			Source:         "testvolume0",
   166  		// 			ReadOnly:       false,
   167  		// 			AccessMode:     structs.CSIVolumeAccessModeMultiNodeReader,
   168  		// 			AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
   169  		// 			MountOptions:   &structs.CSIMountOptions{},
   170  		// 			PerAlloc:       false,
   171  		// 		},
   172  		// 	},
   173  		// 	expectedMounts: map[string]*csimanager.MountInfo{
   174  		// 		"vol0": &csimanager.MountInfo{Source: fmt.Sprintf(
   175  		// 			"test-alloc-dir/%s/testvolume0/ro-file-system-multi-node-reader-only", alloc.ID)},
   176  		// 		"vol1": &csimanager.MountInfo{Source: fmt.Sprintf(
   177  		// 			"test-alloc-dir/%s/testvolume0/ro-file-system-multi-node-reader-only", alloc.ID)},
   178  		// 	},
   179  		// 	expectedMountCalls:     1,
   180  		// 	expectedUnmountCalls:   1,
   181  		// 	expectedClaimCalls:     1,
   182  		// 	expectedUnpublishCalls: 1,
   183  		// },
   184  	}
   185  
   186  	for i := range testcases {
   187  		tc := testcases[i]
   188  		t.Run(tc.name, func(t *testing.T) {
   189  			alloc.Job.TaskGroups[0].Volumes = tc.volumeRequests
   190  
   191  			callCounts := map[string]int{}
   192  			mgr := mockPluginManager{mounter: mockVolumeMounter{callCounts: callCounts}}
   193  			rpcer := mockRPCer{
   194  				alloc:            alloc,
   195  				callCounts:       callCounts,
   196  				hasExistingClaim: pointer.Of(tc.startsWithClaims),
   197  				schedulable:      pointer.Of(!tc.startsUnschedulable),
   198  			}
   199  			ar := mockAllocRunner{
   200  				res: &cstructs.AllocHookResources{},
   201  				caps: &drivers.Capabilities{
   202  					FSIsolation:  drivers.FSIsolationChroot,
   203  					MountConfigs: drivers.MountConfigSupportAll,
   204  				},
   205  			}
   206  			hook := newCSIHook(alloc, logger, mgr, rpcer, ar, ar, "secret")
   207  			hook.minBackoffInterval = 1 * time.Millisecond
   208  			hook.maxBackoffInterval = 10 * time.Millisecond
   209  			hook.maxBackoffDuration = 500 * time.Millisecond
   210  
   211  			require.NotNil(t, hook)
   212  
   213  			if tc.expectedClaimErr != nil {
   214  				require.EqualError(t, hook.Prerun(), tc.expectedClaimErr.Error())
   215  				mounts := ar.GetAllocHookResources().GetCSIMounts()
   216  				require.Nil(t, mounts)
   217  			} else {
   218  				require.NoError(t, hook.Prerun())
   219  				mounts := ar.GetAllocHookResources().GetCSIMounts()
   220  				require.NotNil(t, mounts)
   221  				require.Equal(t, tc.expectedMounts, mounts)
   222  				require.NoError(t, hook.Postrun())
   223  			}
   224  
   225  			require.Equal(t, tc.expectedMountCalls, callCounts["mount"])
   226  			require.Equal(t, tc.expectedUnmountCalls, callCounts["unmount"])
   227  			require.Equal(t, tc.expectedClaimCalls, callCounts["claim"])
   228  			require.Equal(t, tc.expectedUnpublishCalls, callCounts["unpublish"])
   229  
   230  		})
   231  	}
   232  
   233  }
   234  
   235  // TestCSIHook_claimVolumesFromAlloc_Validation tests that the validation of task
   236  // capabilities in claimVolumesFromAlloc ensures at least one task supports CSI.
   237  func TestCSIHook_claimVolumesFromAlloc_Validation(t *testing.T) {
   238  	ci.Parallel(t)
   239  
   240  	alloc := mock.Alloc()
   241  	logger := testlog.HCLogger(t)
   242  	volumeRequests := map[string]*structs.VolumeRequest{
   243  		"vol0": {
   244  			Name:           "vol0",
   245  			Type:           structs.VolumeTypeCSI,
   246  			Source:         "testvolume0",
   247  			ReadOnly:       true,
   248  			AccessMode:     structs.CSIVolumeAccessModeSingleNodeReader,
   249  			AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
   250  			MountOptions:   &structs.CSIMountOptions{},
   251  			PerAlloc:       false,
   252  		},
   253  	}
   254  
   255  	type testCase struct {
   256  		name             string
   257  		caps             *drivers.Capabilities
   258  		capFunc          func() (*drivers.Capabilities, error)
   259  		expectedClaimErr error
   260  	}
   261  
   262  	testcases := []testCase{
   263  		{
   264  			name: "invalid - driver does not support CSI",
   265  			caps: &drivers.Capabilities{
   266  				MountConfigs: drivers.MountConfigSupportNone,
   267  			},
   268  			capFunc:          nil,
   269  			expectedClaimErr: errors.New("claim volumes: no task supports CSI"),
   270  		},
   271  
   272  		{
   273  			name: "invalid - driver error",
   274  			caps: &drivers.Capabilities{},
   275  			capFunc: func() (*drivers.Capabilities, error) {
   276  				return nil, errors.New("error thrown by driver")
   277  			},
   278  			expectedClaimErr: errors.New("claim volumes: could not validate task driver capabilities: error thrown by driver"),
   279  		},
   280  
   281  		{
   282  			name: "valid - driver supports CSI",
   283  			caps: &drivers.Capabilities{
   284  				MountConfigs: drivers.MountConfigSupportAll,
   285  			},
   286  			capFunc:          nil,
   287  			expectedClaimErr: nil,
   288  		},
   289  	}
   290  
   291  	for _, tc := range testcases {
   292  		t.Run(tc.name, func(t *testing.T) {
   293  			alloc.Job.TaskGroups[0].Volumes = volumeRequests
   294  
   295  			callCounts := map[string]int{}
   296  			mgr := mockPluginManager{mounter: mockVolumeMounter{callCounts: callCounts}}
   297  
   298  			rpcer := mockRPCer{
   299  				alloc:            alloc,
   300  				callCounts:       callCounts,
   301  				hasExistingClaim: pointer.Of(false),
   302  				schedulable:      pointer.Of(true),
   303  			}
   304  
   305  			ar := mockAllocRunner{
   306  				res:     &cstructs.AllocHookResources{},
   307  				caps:    tc.caps,
   308  				capFunc: tc.capFunc,
   309  			}
   310  
   311  			hook := newCSIHook(alloc, logger, mgr, rpcer, ar, ar, "secret")
   312  			require.NotNil(t, hook)
   313  
   314  			if tc.expectedClaimErr != nil {
   315  				require.EqualError(t, hook.Prerun(), tc.expectedClaimErr.Error())
   316  				mounts := ar.GetAllocHookResources().GetCSIMounts()
   317  				require.Nil(t, mounts)
   318  			} else {
   319  				require.NoError(t, hook.Prerun())
   320  				mounts := ar.GetAllocHookResources().GetCSIMounts()
   321  				require.NotNil(t, mounts)
   322  				require.NoError(t, hook.Postrun())
   323  			}
   324  		})
   325  	}
   326  }
   327  
   328  // HELPERS AND MOCKS
   329  
   330  type mockRPCer struct {
   331  	alloc            *structs.Allocation
   332  	callCounts       map[string]int
   333  	hasExistingClaim *bool
   334  	schedulable      *bool
   335  }
   336  
   337  // RPC mocks the server RPCs, acting as though any request succeeds
   338  func (r mockRPCer) RPC(method string, args interface{}, reply interface{}) error {
   339  	switch method {
   340  	case "CSIVolume.Claim":
   341  		r.callCounts["claim"]++
   342  		req := args.(*structs.CSIVolumeClaimRequest)
   343  		vol := r.testVolume(req.VolumeID)
   344  		err := vol.Claim(req.ToClaim(), r.alloc)
   345  		if err != nil {
   346  			return err
   347  		}
   348  
   349  		resp := reply.(*structs.CSIVolumeClaimResponse)
   350  		resp.PublishContext = map[string]string{}
   351  		resp.Volume = vol
   352  		resp.QueryMeta = structs.QueryMeta{}
   353  	case "CSIVolume.Unpublish":
   354  		r.callCounts["unpublish"]++
   355  		resp := reply.(*structs.CSIVolumeUnpublishResponse)
   356  		resp.QueryMeta = structs.QueryMeta{}
   357  	default:
   358  		return fmt.Errorf("unexpected method")
   359  	}
   360  	return nil
   361  }
   362  
   363  // testVolume is a helper that optionally starts as unschedulable /
   364  // claimed until after the first claim RPC is made, so that we can
   365  // test retryable vs non-retryable failures
   366  func (r mockRPCer) testVolume(id string) *structs.CSIVolume {
   367  	vol := structs.NewCSIVolume(id, 0)
   368  	vol.Schedulable = *r.schedulable
   369  	vol.RequestedCapabilities = []*structs.CSIVolumeCapability{
   370  		{
   371  			AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
   372  			AccessMode:     structs.CSIVolumeAccessModeSingleNodeReader,
   373  		},
   374  		{
   375  			AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
   376  			AccessMode:     structs.CSIVolumeAccessModeSingleNodeWriter,
   377  		},
   378  	}
   379  
   380  	if *r.hasExistingClaim {
   381  		vol.AccessMode = structs.CSIVolumeAccessModeSingleNodeReader
   382  		vol.AttachmentMode = structs.CSIVolumeAttachmentModeFilesystem
   383  		vol.ReadClaims["another-alloc-id"] = &structs.CSIVolumeClaim{
   384  			AllocationID:   "another-alloc-id",
   385  			NodeID:         "another-node-id",
   386  			Mode:           structs.CSIVolumeClaimRead,
   387  			AccessMode:     structs.CSIVolumeAccessModeSingleNodeReader,
   388  			AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
   389  			State:          structs.CSIVolumeClaimStateTaken,
   390  		}
   391  	}
   392  
   393  	if r.callCounts["claim"] >= 0 {
   394  		*r.hasExistingClaim = false
   395  		*r.schedulable = true
   396  	}
   397  
   398  	return vol
   399  }
   400  
   401  type mockVolumeMounter struct {
   402  	callCounts map[string]int
   403  }
   404  
   405  func (vm mockVolumeMounter) MountVolume(ctx context.Context, vol *structs.CSIVolume, alloc *structs.Allocation, usageOpts *csimanager.UsageOptions, publishContext map[string]string) (*csimanager.MountInfo, error) {
   406  	vm.callCounts["mount"]++
   407  	return &csimanager.MountInfo{
   408  		Source: filepath.Join("test-alloc-dir", alloc.ID, vol.ID, usageOpts.ToFS()),
   409  	}, nil
   410  }
   411  func (vm mockVolumeMounter) UnmountVolume(ctx context.Context, volID, remoteID, allocID string, usageOpts *csimanager.UsageOptions) error {
   412  	vm.callCounts["unmount"]++
   413  	return nil
   414  }
   415  
   416  type mockPluginManager struct {
   417  	mounter mockVolumeMounter
   418  }
   419  
   420  func (mgr mockPluginManager) MounterForPlugin(ctx context.Context, pluginID string) (csimanager.VolumeMounter, error) {
   421  	return mgr.mounter, nil
   422  }
   423  
   424  // no-op methods to fulfill the interface
   425  func (mgr mockPluginManager) PluginManager() pluginmanager.PluginManager { return nil }
   426  func (mgr mockPluginManager) Shutdown()                                  {}
   427  
   428  type mockAllocRunner struct {
   429  	res     *cstructs.AllocHookResources
   430  	caps    *drivers.Capabilities
   431  	capFunc func() (*drivers.Capabilities, error)
   432  }
   433  
   434  func (ar mockAllocRunner) GetAllocHookResources() *cstructs.AllocHookResources {
   435  	return ar.res
   436  }
   437  
   438  func (ar mockAllocRunner) SetAllocHookResources(res *cstructs.AllocHookResources) {
   439  	ar.res = res
   440  }
   441  
   442  func (ar mockAllocRunner) GetTaskDriverCapabilities(taskName string) (*drivers.Capabilities, error) {
   443  	if ar.capFunc != nil {
   444  		return ar.capFunc()
   445  	}
   446  	return ar.caps, nil
   447  }