Skip to content

Commit

Permalink
Driver: Implement Expand Volume (#1)
Browse files Browse the repository at this point in the history
# Description
Add the ability to resize a Block Storage volume and expand the
file-system.

---------

Signed-off-by: Pierre-Emmanuel Jacquier <15922119+pierre-emmanuelJ@users.noreply.github.com>
  • Loading branch information
pierre-emmanuelJ authored Apr 9, 2024
1 parent 61f9d24 commit 6ad189a
Show file tree
Hide file tree
Showing 33 changed files with 10,140 additions and 2 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ go.work.sum
kubeconfig

release

bin/
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Improvements

* Driver: Implement Expand Volume #1

## 0.29.3

### Improvements
Expand Down
63 changes: 61 additions & 2 deletions driver/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ var (

const (
DefaultVolumeSizeGiB = 100
MinimalVolumeSizeGiB = 10
MaximumVolumeSizeGiB = 10000
)

type controllerService struct {
Expand Down Expand Up @@ -672,11 +674,68 @@ func (d *controllerService) ListSnapshots(ctx context.Context, req *csi.ListSnap
}, nil
}

// ControllerExpandVolume resizes/updates the volume (not supported yet on Exoscale Public API)
// ControllerExpandVolume resizes Block Storage volume.
func (d *controllerService) ControllerExpandVolume(ctx context.Context, req *csi.ControllerExpandVolumeRequest) (*csi.ControllerExpandVolumeResponse, error) {
klog.V(4).Infof("ControllerExpandVolume")
zoneName, volumeID, err := getExoscaleID(req.GetVolumeId())
if err != nil {
return nil, err
}

client, err := newClientZone(ctx, d.client, zoneName)
if err != nil {
klog.Errorf("expand volume: new client zone: %v", err)
return nil, err
}

_, err = client.GetBlockStorageVolume(ctx, volumeID)
if err != nil {
if errors.Is(err, v3.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "volume %s not found", volumeID)
}

return nil, err
}

nodeExpansionRequired := true
volumeCapability := req.GetVolumeCapability()
if volumeCapability != nil {
err := validateVolumeCapability(volumeCapability)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "volumeCapabilities not supported: %s", err)
}

if _, ok := volumeCapability.GetAccessType().(*csi.VolumeCapability_Block); ok {
nodeExpansionRequired = false
}
}

newSizeInBytes, err := getNewVolumeSize(req.GetCapacityRange())
if err != nil {
return nil, status.Errorf(codes.OutOfRange, "invalid capacity range: %v", err)
}

if newSizeInBytes%GiB != 0 {
msg := fmt.Sprintf("requested size in bytes cannot be exactly converted to GiB: %d", newSizeInBytes)

klog.Error(msg)

return nil, status.Error(codes.Unimplemented, "")
return nil, fmt.Errorf(msg)
}

sizeInGiB := convertBytesToGiB(newSizeInBytes)

_, err = client.ResizeBlockStorageVolume(ctx, volumeID, v3.ResizeBlockStorageVolumeRequest{
Size: sizeInGiB,
})
if err != nil {
return nil, err
}

return &csi.ControllerExpandVolumeResponse{
CapacityBytes: newSizeInBytes,
NodeExpansionRequired: nodeExpansionRequired,
}, nil
}

// ControllerGetVolume gets a volume and return it.
Expand Down
11 changes: 11 additions & 0 deletions driver/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package driver

import "errors"

var (
errLimitLessThanRequiredBytes = errors.New("limit size is less than required size")
errRequiredBytesLessThanMinimun = errors.New("required size is less than the minimun size")
errLimitLessThanMinimum = errors.New("limit size is less than the minimun size")
errRequiredBytesGreaterThanMaximun = errors.New("required size is greater than the maximum size")
errLimitGreaterThanMaximum = errors.New("limit size is greater than the maximum size")
)
53 changes: 53 additions & 0 deletions driver/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,56 @@ func getRequiredZone(requirements *csi.TopologyRequirement, defaultZone v3.ZoneN

return v3.ZoneName(zone), nil
}

func getNewVolumeSize(capacityRange *csi.CapacityRange) (int64, error) {
MinimalVolumeSizeBytes := convertGiBToBytes(MinimalVolumeSizeGiB)
MaximumVolumeSizeBytes := convertGiBToBytes(MaximumVolumeSizeGiB)

if capacityRange == nil {
return MinimalVolumeSizeBytes, nil
}

requiredBytes := capacityRange.GetRequiredBytes()
requiredSet := requiredBytes > 0

limitBytes := capacityRange.GetLimitBytes()
limitSet := limitBytes > 0

if !requiredSet && !limitSet {
return MinimalVolumeSizeBytes, nil
}

if requiredSet && limitSet && limitBytes < requiredBytes {
return 0, errLimitLessThanRequiredBytes
}

if requiredSet && !limitSet && requiredBytes < MinimalVolumeSizeBytes {
return 0, errRequiredBytesLessThanMinimun
}

if limitSet && limitBytes < MinimalVolumeSizeBytes {
return 0, errLimitLessThanMinimum
}

if requiredSet && requiredBytes > MaximumVolumeSizeBytes {
return 0, errRequiredBytesGreaterThanMaximun
}

if !requiredSet && limitSet && limitBytes > MaximumVolumeSizeBytes {
return 0, errLimitGreaterThanMaximum
}

if requiredSet && limitSet && requiredBytes == limitBytes {
return requiredBytes, nil
}

if requiredSet {
return requiredBytes, nil
}

if limitSet {
return limitBytes, nil
}

return MinimalVolumeSizeBytes, nil
}
113 changes: 113 additions & 0 deletions driver/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package driver

import (
"testing"

"github.com/container-storage-interface/spec/lib/go/csi"
"github.com/stretchr/testify/require"
)

func TestGetNewVolumeSize(t *testing.T) {
var min int64 = convertGiBToBytes(MinimalVolumeSizeGiB)
var max int64 = convertGiBToBytes(MaximumVolumeSizeGiB)
testsBench := []struct {
capRange *csi.CapacityRange
res int64
err error
}{
{
capRange: &csi.CapacityRange{
RequiredBytes: 0,
LimitBytes: 0,
},
res: min,
err: nil,
},
{
capRange: &csi.CapacityRange{
RequiredBytes: min + 10,
LimitBytes: 0,
},
res: min + 10,
err: nil,
},
{
capRange: &csi.CapacityRange{
RequiredBytes: 0,
LimitBytes: min + 10,
},
res: min + 10,
err: nil,
},
{
capRange: &csi.CapacityRange{
RequiredBytes: min - 10,
LimitBytes: 0,
},
res: 0,
err: errRequiredBytesLessThanMinimun,
},
{
capRange: &csi.CapacityRange{
RequiredBytes: 0,
LimitBytes: min - 10,
},
res: 0,
err: errLimitLessThanMinimum,
},
{
capRange: &csi.CapacityRange{
RequiredBytes: min + 10,
LimitBytes: min + 5,
},
res: 0,
err: errLimitLessThanRequiredBytes,
},
{
capRange: &csi.CapacityRange{
RequiredBytes: min + 10,
LimitBytes: min + 5,
},
res: 0,
err: errLimitLessThanRequiredBytes,
},
{
capRange: &csi.CapacityRange{
RequiredBytes: max + 10,
LimitBytes: 0,
},
res: 0,
err: errRequiredBytesGreaterThanMaximun,
},
{
capRange: &csi.CapacityRange{
RequiredBytes: 0,
LimitBytes: max + 10,
},
res: 0,
err: errLimitGreaterThanMaximum,
},
{
capRange: &csi.CapacityRange{
RequiredBytes: min + 10,
LimitBytes: min + 10,
},
res: min + 10,
err: nil,
},
{
capRange: &csi.CapacityRange{
RequiredBytes: min + 10,
LimitBytes: min + 20,
},
res: min + 10,
err: nil,
},
}

for _, test := range testsBench {
res, err := getNewVolumeSize(test.capRange)
require.Equal(t, test.err, err)
require.Equal(t, test.res, res)
}
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/container-storage-interface/spec v1.8.0
github.com/exoscale/egoscale v0.102.4-0.20240222153804-ec94e9697218
github.com/golang/protobuf v1.5.3
github.com/stretchr/testify v1.8.4
golang.org/x/sys v0.15.0
google.golang.org/grpc v1.59.0
google.golang.org/protobuf v1.31.0
Expand Down Expand Up @@ -44,6 +45,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
Expand Down
48 changes: 48 additions & 0 deletions internal/integ/integ_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"

v3 "github.com/exoscale/egoscale/v3"
"github.com/exoscale/exoscale/csi-driver/internal/integ/cluster"
Expand Down Expand Up @@ -241,6 +242,53 @@ func TestDeleteVolume(t *testing.T) {
t.Run("storage-class-retain", testFunc(true))
}

func TestVolumeExpand(t *testing.T) {
testName := "expand-vol"
ns := k8s.CreateTestNamespace(t, cluster.Get().K8s, testName)

pvcName := generatePVCName(testName)
ns.ApplyPVC(pvcName, "10Gi", false)
ns.Apply(fmt.Sprintf(basicDeployment, pvcName))

awaitExpectation(t, "Bound", func() interface{} {
pvc, err := ns.K.ClientSet.CoreV1().PersistentVolumeClaims(ns.Name).Get(ns.CTX, pvcName, metav1.GetOptions{})
assert.NoError(t, err)
return pvc.Status.Phase
})

// Currently, resizing a block storage volume requires detaching it from the Compute Instance.
// To achieve this detachment, we delete the deployment,
// allowing the CSI to unmount and detach the volume from the node.
ns.Delete(fmt.Sprintf(basicDeployment, pvcName))

awaitExpectation(t, 0, func() interface{} {
pods, err := ns.K.ClientSet.CoreV1().Pods(ns.Name).List(ns.CTX, metav1.ListOptions{})
assert.NoError(t, err)

return len(pods.Items)
})

_, err := ns.K.ClientSet.CoreV1().PersistentVolumeClaims(ns.Name).Patch(
ns.CTX,
pvcName,
types.MergePatchType,
[]byte(`{"spec":{"resources":{"requests":{"storage":"50Gi"}}}}`),
metav1.PatchOptions{},
)
assert.NoError(t, err)

// Re-apply deployment after block storage resize.
// CSI will resize volume filesystem on applying
ns.Apply(fmt.Sprintf(basicDeployment, pvcName))

awaitExpectation(t, 0, func() interface{} {
pvc, err := ns.K.ClientSet.CoreV1().PersistentVolumeClaims(ns.Name).Get(ns.CTX, pvcName, metav1.GetOptions{})
assert.NoError(t, err)

return pvc.Status.Capacity.Storage().CmpInt64(50 * 1024 * 1024 * 1024)
})
}

const basicSnapshot = `
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
Expand Down
30 changes: 30 additions & 0 deletions internal/integ/k8s/namespace.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,36 @@ func (ns *Namespace) Apply(manifest string) {
assert.NoError(ns.t, err)
}

func (ns *Namespace) Delete(manifest string) {
decoder := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme)
obj := &unstructured.Unstructured{}
_, gvk, err := decoder.Decode([]byte(manifest), nil, obj)
if err != nil {
ns.t.Error("failed to decode manifest")
return
}

obj.SetNamespace(ns.Name)

res := ns.K.findResource(obj.GetKind())
if res == nil {
ns.t.Error("unknown resource")

return
}

gvr := gvk.GroupVersion().WithResource(res.Name)
resourceInterface := ns.K.DynamicClient.Resource(gvr).Namespace(ns.Name)

slog.Info("deleting", "resource", gvr, "name", obj.GetName())
err = resourceInterface.Delete(ns.CTX, obj.GetName(), metav1.DeleteOptions{})
if err != nil {
slog.Error("failed to delete resource", "err", err)
}

assert.NoError(ns.t, err)
}

func generateNSName(testName string) string {
return fmt.Sprintf("%s-%s-%d", "csi-test-ns", testName, rand.Int())
}
Expand Down
Loading

0 comments on commit 6ad189a

Please # to comment.