Skip to content

Commit e6271a0

Browse files
committed
feat(cinder-csi): add mkfs-options StorageClass parameter
Add support for a new `mkfs-options` StorageClass parameter that allows cluster admins to pass custom mkfs flags when formatting volumes. For example, this is useful for Ceph-backed storage where passing `-E nodiscard` to mkfs.ext4 can dramatically speed up provisioning. Implementation: - Extract mkfs-options from StorageClass params in CreateVolume and pass via VolumeContext (both new-volume and idempotent early-return paths) - Parse mkfs-options in NodeStageVolume - Use FormatAndMountSensitiveWithFormatOptions() from k8s.io/mount-utils to pass format options separately from mount options - No behavior change when mkfs-options is not set Inspired by the Ceph CSI implementation (ceph/ceph-csi#621). MountMock.Mounter() now returns a stable, memoized SafeFormatAndMount with a SetMounter escape hatch, enabling tests to script and capture the mkfs argv via FakeExec.
1 parent 9c80105 commit e6271a0

6 files changed

Lines changed: 261 additions & 7 deletions

File tree

docs/cinder-csi-plugin/using-cinder-csi-plugin.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ helm install --namespace kube-system --name cinder-csi ./charts/cinder-csi-plugi
283283
|------------------------- |-----------------------|-----------------|-----------------|
284284
| StorageClass `parameters` | `availability` | `nova` | String. Volume Availability Zone |
285285
| StorageClass `parameters` | `type` | Empty String | String. Name/ID of Volume type. Corresponding volume type should exist in cinder |
286+
| StorageClass `parameters` | `mkfs-options` | Empty String | String. Whitespace-separated arguments passed to mkfs when formatting the volume. |
286287
| VolumeSnapshotClass `parameters` | `force-create` | `false` | Enable to support creating snapshot for a volume in in-use status |
287288
| VolumeSnapshotClass `parameters` | `type` | Empty String | `snapshot` creates a VolumeSnapshot object linked to a Cinder volume snapshot. `backup` creates a VolumeSnapshot object linked to a cinder volume backup. Defaults to `snapshot` if not defined |
288289
| VolumeSnapshotClass `parameters` | `backup-max-duration-seconds-per-gb` | `20` | Defines the amount of time to wait for a backup to complete in seconds per GB of volume size |

pkg/csi/cinder/controllerserver.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,11 @@ func (cs *controllerServer) CreateVolume(ctx context.Context, req *csi.CreateVol
119119
}
120120
klog.V(4).Infof("Volume %s already exists in Availability Zone: %s of size %d GiB", vols[0].ID, vols[0].AvailabilityZone, vols[0].Size)
121121
accessibleTopology := getTopology(&vols[0], accessibleTopologyReq, cs.Driver.withTopology, ignoreVolumeAZ)
122-
return getCreateVolumeResponse(&vols[0], nil, accessibleTopology), nil
122+
existingVolCtx := map[string]string{}
123+
if mkfsOptions := volParams["mkfs-options"]; mkfsOptions != "" {
124+
existingVolCtx["mkfs-options"] = mkfsOptions
125+
}
126+
return getCreateVolumeResponse(&vols[0], existingVolCtx, accessibleTopology), nil
123127
}
124128

125129
if len(vols) > 1 {
@@ -233,6 +237,11 @@ func (cs *controllerServer) CreateVolume(ctx context.Context, req *csi.CreateVol
233237
klog.V(4).Infof("CreateVolume: Resolved scheduler hints: affinity=%s, anti-affinity=%s", affinity, antiAffinity)
234238
}
235239

240+
// Pass mkfsOptions to node via volume context
241+
if mkfsOptions := volParams["mkfs-options"]; mkfsOptions != "" {
242+
volCtx["mkfs-options"] = mkfsOptions
243+
}
244+
236245
vol, err := cloud.CreateVolume(ctx, opts, schedulerHints)
237246
if err != nil {
238247
klog.Errorf("Failed to CreateVolume: %v", err)

pkg/csi/cinder/controllerserver_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,94 @@ func TestCreateVolumeWithParam(t *testing.T) {
200200
assert.Equal(FakeAvailability, actualRes.Volume.AccessibleTopology[0].GetSegments()[topologyKey])
201201
}
202202

203+
// Test CreateVolume passes mkfs-options to VolumeContext
204+
func TestCreateVolumeWithMkfsOptions(t *testing.T) {
205+
fakeCs, osmock := fakeControllerServer()
206+
207+
// mock OpenStack
208+
properties := map[string]string{cinderCSIClusterIDKey: FakeCluster}
209+
osmock.On("CreateVolume", FakeVolName, mock.AnythingOfType("int"), FakeVolType, FakeAvailability, "", "", "", properties).Return(&FakeVol, nil)
210+
osmock.On("GetVolumesByName", FakeVolName).Return(FakeVolListEmpty, nil)
211+
osmock.On("GetBlockStorageOpts").Return(openstack.BlockStorageOpts{})
212+
213+
assert := assert.New(t)
214+
215+
// Fake request with mkfs-options
216+
fakeReq := &csi.CreateVolumeRequest{
217+
Name: FakeVolName,
218+
VolumeCapabilities: []*csi.VolumeCapability{
219+
{
220+
AccessMode: &csi.VolumeCapability_AccessMode{
221+
Mode: csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER,
222+
},
223+
},
224+
},
225+
Parameters: map[string]string{
226+
"mkfs-options": "-E nodiscard",
227+
},
228+
AccessibilityRequirements: &csi.TopologyRequirement{
229+
Requisite: []*csi.Topology{
230+
{
231+
Segments: map[string]string{topologyKey: FakeAvailability},
232+
},
233+
},
234+
},
235+
}
236+
237+
// Invoke CreateVolume
238+
actualRes, err := fakeCs.CreateVolume(FakeCtx, fakeReq)
239+
if err != nil {
240+
t.Errorf("failed to CreateVolume: %v", err)
241+
}
242+
243+
// Assert mkfs-options is in VolumeContext
244+
assert.NotNil(actualRes.Volume)
245+
assert.Equal("-E nodiscard", actualRes.Volume.VolumeContext["mkfs-options"])
246+
}
247+
248+
// Test CreateVolume passes mkfs-options on idempotent path (volume already exists)
249+
func TestCreateVolumeExistingWithMkfsOptions(t *testing.T) {
250+
fakeCs, osmock := fakeControllerServer()
251+
252+
// mock OpenStack - volume already exists
253+
osmock.On("GetVolumesByName", FakeVolName).Return(FakeVolList, nil)
254+
osmock.On("GetBlockStorageOpts").Return(openstack.BlockStorageOpts{})
255+
256+
assert := assert.New(t)
257+
258+
// Fake request with mkfs-options
259+
fakeReq := &csi.CreateVolumeRequest{
260+
Name: FakeVolName,
261+
VolumeCapabilities: []*csi.VolumeCapability{
262+
{
263+
AccessMode: &csi.VolumeCapability_AccessMode{
264+
Mode: csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER,
265+
},
266+
},
267+
},
268+
Parameters: map[string]string{
269+
"mkfs-options": "-E nodiscard",
270+
},
271+
AccessibilityRequirements: &csi.TopologyRequirement{
272+
Requisite: []*csi.Topology{
273+
{
274+
Segments: map[string]string{topologyKey: FakeAvailability},
275+
},
276+
},
277+
},
278+
}
279+
280+
// Invoke CreateVolume
281+
actualRes, err := fakeCs.CreateVolume(FakeCtx, fakeReq)
282+
if err != nil {
283+
t.Errorf("failed to CreateVolume: %v", err)
284+
}
285+
286+
// Assert mkfs-options is in VolumeContext even on idempotent path
287+
assert.NotNil(actualRes.Volume)
288+
assert.Equal("-E nodiscard", actualRes.Volume.VolumeContext["mkfs-options"])
289+
}
290+
203291
// Test CreateVolume with ignore-volume-az option
204292
func TestCreateVolumeWithIgnoreVolumeAZ(t *testing.T) {
205293
fakeCs, osmock := fakeControllerServer()

pkg/csi/cinder/nodeserver.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,16 @@ func (ns *nodeServer) NodeStageVolume(ctx context.Context, req *csi.NodeStageVol
219219
mountFlags := mnt.GetMountFlags()
220220
options = append(options, collectMountOptions(fsType, mountFlags)...)
221221
}
222+
223+
// Parse mkfs options from volume context
224+
var formatOptions []string
225+
if mkfsOpts, ok := volumeContext["mkfs-options"]; ok && mkfsOpts != "" {
226+
formatOptions = strings.Fields(mkfsOpts)
227+
klog.V(4).Infof("NodeStageVolume: mkfs-options for volume %s: %v", volumeID, formatOptions)
228+
}
229+
222230
// Mount
223-
err = ns.formatAndMountRetry(devicePath, stagingTarget, fsType, options)
231+
err = ns.formatAndMountRetry(devicePath, stagingTarget, fsType, options, formatOptions)
224232
if err != nil {
225233
return nil, status.Error(codes.Internal, err.Error())
226234
}
@@ -248,9 +256,9 @@ func (ns *nodeServer) NodeStageVolume(ctx context.Context, req *csi.NodeStageVol
248256

249257
// formatAndMountRetry attempts to format and mount a device at the given path.
250258
// If the initial mount fails, it rescans the device and retries the mount operation.
251-
func (ns *nodeServer) formatAndMountRetry(devicePath, stagingTarget, fsType string, options []string) error {
259+
func (ns *nodeServer) formatAndMountRetry(devicePath, stagingTarget, fsType string, options []string, formatOptions []string) error {
252260
m := ns.Mount
253-
err := m.Mounter().FormatAndMount(devicePath, stagingTarget, fsType, options)
261+
err := m.Mounter().FormatAndMountSensitiveWithFormatOptions(devicePath, stagingTarget, fsType, options, nil, formatOptions)
254262
if err != nil {
255263
klog.Infof("Initial format and mount failed: %v. Attempting rescan.", err)
256264
// Attempting rescan if the initial mount fails
@@ -260,7 +268,7 @@ func (ns *nodeServer) formatAndMountRetry(devicePath, stagingTarget, fsType stri
260268
return err
261269
}
262270
klog.Infof("Rescan succeeded, retrying format and mount")
263-
err = m.Mounter().FormatAndMount(devicePath, stagingTarget, fsType, options)
271+
err = m.Mounter().FormatAndMountSensitiveWithFormatOptions(devicePath, stagingTarget, fsType, options, nil, formatOptions)
264272
}
265273
return err
266274
}

pkg/csi/cinder/nodeserver_test.go

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"fmt"
2121
"os"
2222
"path/filepath"
23+
"runtime"
2324
"testing"
2425

2526
"github.com/container-storage-interface/spec/lib/go/csi"
@@ -30,6 +31,9 @@ import (
3031
"k8s.io/cloud-provider-openstack/pkg/csi/cinder/openstack"
3132
"k8s.io/cloud-provider-openstack/pkg/util/metadata"
3233
"k8s.io/cloud-provider-openstack/pkg/util/mount"
34+
mountutil "k8s.io/mount-utils"
35+
utilsexec "k8s.io/utils/exec"
36+
testingexec "k8s.io/utils/exec/testing"
3337
)
3438

3539
func fakeNodeServer() (*nodeServer, *openstack.OpenStackMock, *mount.MountMock, *metadata.MetadataMock) {
@@ -236,6 +240,134 @@ func TestNodeStageVolumeBlock(t *testing.T) {
236240
assert.Equal(expectedRes, actualRes)
237241
}
238242

243+
// TestNodeStageVolume_MkfsOptions_ArgvCapture drives NodeStageVolume with a
244+
// range of mkfs-options values and asserts that the argv handed to mkfs
245+
// starts with the user-supplied arguments.
246+
func TestNodeStageVolume_MkfsOptions_ArgvCapture(t *testing.T) {
247+
if runtime.GOOS != "linux" {
248+
t.Skipf("mkfs argv capture only runs on linux (GOOS=%s)", runtime.GOOS)
249+
}
250+
251+
stdMountVolCap := &csi.VolumeCapability{
252+
AccessType: &csi.VolumeCapability_Mount{
253+
Mount: &csi.VolumeCapability_MountVolume{},
254+
},
255+
AccessMode: &csi.VolumeCapability_AccessMode{
256+
Mode: csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER,
257+
},
258+
}
259+
260+
cases := []struct {
261+
name string
262+
mkfsOptions string
263+
expectedArguments []string
264+
}{
265+
{
266+
name: "no options",
267+
mkfsOptions: "",
268+
expectedArguments: []string{},
269+
},
270+
{
271+
name: "single flag",
272+
mkfsOptions: "-j",
273+
expectedArguments: []string{"-j"},
274+
},
275+
{
276+
name: "flag and value",
277+
mkfsOptions: "-E nodiscard",
278+
expectedArguments: []string{"-E", "nodiscard"},
279+
},
280+
{
281+
name: "multiple options",
282+
mkfsOptions: "-E nodiscard -j",
283+
expectedArguments: []string{"-E", "nodiscard", "-j"},
284+
},
285+
{
286+
// Demonstrates the bug in util.SplitTrim: it also splits on
287+
// whitespace, so the comma-containing value was shredded into
288+
// two separate mkfs args and treated by mkfs as a block count.
289+
name: "commas inside option value",
290+
mkfsOptions: "-E lazy_itable_init=0,lazy_journal_init=0",
291+
expectedArguments: []string{"-E", "lazy_itable_init=0,lazy_journal_init=0"},
292+
},
293+
{
294+
name: "extra whitespace",
295+
mkfsOptions: " -j -F ",
296+
expectedArguments: []string{"-j", "-F"},
297+
},
298+
}
299+
300+
for _, tc := range cases {
301+
t.Run(tc.name, func(t *testing.T) {
302+
fakeNs, omock, mmock, _ := fakeNodeServer()
303+
304+
mmock.On("GetDevicePath", FakeVolID).Return(FakeDevicePath, nil)
305+
mmock.On("IsLikelyNotMountPointAttach", FakeStagingTargetPath).Return(true, nil)
306+
omock.On("GetVolume", FakeVolID).Return(FakeVol, nil)
307+
308+
// Script exec:
309+
// 1. blkid → exit 2 (device is unformatted) to trigger the mkfs path.
310+
// 2. mkfs.ext4 → succeed, and capture the argv it was called with.
311+
var capturedMkfsArgs []string
312+
313+
fakeExec := &testingexec.FakeExec{}
314+
315+
blkidCmd := &testingexec.FakeCmd{
316+
CombinedOutputScript: []testingexec.FakeAction{
317+
func() ([]byte, []byte, error) {
318+
return nil, nil, &testingexec.FakeExitError{Status: 2}
319+
},
320+
},
321+
}
322+
fakeExec.CommandScript = append(fakeExec.CommandScript,
323+
func(cmd string, args ...string) utilsexec.Cmd {
324+
return testingexec.InitFakeCmd(blkidCmd, cmd, args...)
325+
},
326+
)
327+
328+
mkfsCmd := &testingexec.FakeCmd{
329+
CombinedOutputScript: []testingexec.FakeAction{
330+
func() ([]byte, []byte, error) { return nil, nil, nil },
331+
},
332+
}
333+
fakeExec.CommandScript = append(fakeExec.CommandScript,
334+
func(cmd string, args ...string) utilsexec.Cmd {
335+
capturedMkfsArgs = append([]string(nil), args...)
336+
return testingexec.InitFakeCmd(mkfsCmd, cmd, args...)
337+
},
338+
)
339+
340+
mmock.SetMounter(&mountutil.SafeFormatAndMount{
341+
Interface: mount.NewFakeMounter(),
342+
Exec: fakeExec,
343+
})
344+
345+
volCtx := map[string]string{}
346+
if tc.mkfsOptions != "" {
347+
volCtx["mkfs-options"] = tc.mkfsOptions
348+
}
349+
350+
fakeReq := &csi.NodeStageVolumeRequest{
351+
VolumeId: FakeVolID,
352+
PublishContext: map[string]string{"DevicePath": FakeDevicePath},
353+
StagingTargetPath: FakeStagingTargetPath,
354+
VolumeCapability: stdMountVolCap,
355+
VolumeContext: volCtx,
356+
}
357+
358+
_, err := fakeNs.NodeStageVolume(FakeCtx, fakeReq)
359+
assert.NoError(t, err)
360+
361+
// mount-utils constructs mkfs argv as: formatOptions ++ ["-F", "-m0", devicePath].
362+
// Our user-supplied options therefore occupy the argv prefix.
363+
assert.GreaterOrEqual(t, len(capturedMkfsArgs), len(tc.expectedArguments),
364+
"captured argv = %v", capturedMkfsArgs)
365+
assert.Equal(t, tc.expectedArguments, capturedMkfsArgs[:len(tc.expectedArguments)],
366+
"mkfs argv prefix mismatch; full argv = %v", capturedMkfsArgs)
367+
})
368+
}
369+
}
370+
239371
// Test NodeUnpublishVolume
240372
func TestNodeUnpublishVolume(t *testing.T) {
241373
fakeNs, omock, mmock, _ := fakeNodeServer()
@@ -394,5 +526,4 @@ func TestNodeGetVolumeStatsFs(t *testing.T) {
394526

395527
assert.NoError(err)
396528
assert.Equal(expectedFsRes, fsRes)
397-
398529
}

pkg/util/mount/mount_mock.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,22 @@ import (
2828
// ORIGINALLY GENERATED BY mockery with hand edits
2929
type MountMock struct {
3030
mock.Mock
31+
32+
// safeMounter is lazily constructed on first Mounter() call and returned
33+
// unchanged on subsequent calls, so tests can inspect or pre-configure
34+
// the associated FakeExec. Tests that need a custom exec script should
35+
// call SetMounter before NodeStageVolume runs.
36+
safeMounter *mount.SafeFormatAndMount
3137
}
3238

3339
// revive:enable:exported
3440

41+
// SetMounter injects a caller-built SafeFormatAndMount. Useful in tests that
42+
// want to script or capture exec invocations via a custom FakeExec.
43+
func (_m *MountMock) SetMounter(sfm *mount.SafeFormatAndMount) {
44+
_m.safeMounter = sfm
45+
}
46+
3547
func (_m *MountMock) GetDeviceStats(path string) (*DeviceStats, error) {
3648
ret := _m.Called(path)
3749

@@ -146,6 +158,10 @@ func (_m *MountMock) UnmountPath(mountPath string) error {
146158

147159
// GetBaseMounter provides a mock function
148160
func (_m *MountMock) Mounter() *mount.SafeFormatAndMount {
161+
if _m.safeMounter != nil {
162+
return _m.safeMounter
163+
}
164+
149165
scripts := []struct {
150166
cmd string
151167
output []byte
@@ -177,10 +193,11 @@ func (_m *MountMock) Mounter() *mount.SafeFormatAndMount {
177193
fakeexec.CommandScript = append(fakeexec.CommandScript, cmdAction)
178194
}
179195

180-
return &mount.SafeFormatAndMount{
196+
_m.safeMounter = &mount.SafeFormatAndMount{
181197
Interface: NewFakeMounter(),
182198
Exec: fakeexec,
183199
}
200+
return _m.safeMounter
184201
}
185202

186203
func (_m *MountMock) MakeDir(pathname string) error {

0 commit comments

Comments
 (0)