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 }