From 94f1484274daf660b10044eb237a067cf641759d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 18:15:39 +0000 Subject: [PATCH 01/63] Bump github.com/sirupsen/logrus from 1.9.3 to 1.9.4 Bumps [github.com/sirupsen/logrus](https://github.com/sirupsen/logrus) from 1.9.3 to 1.9.4. - [Release notes](https://github.com/sirupsen/logrus/releases) - [Changelog](https://github.com/sirupsen/logrus/blob/master/CHANGELOG.md) - [Commits](https://github.com/sirupsen/logrus/compare/v1.9.3...v1.9.4) --- updated-dependencies: - dependency-name: github.com/sirupsen/logrus dependency-version: 1.9.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index e554e514..a1b3ee70 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 github.com/kubernetes-csi/csi-test/v5 v5.4.0 - github.com/sirupsen/logrus v1.9.3 + github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.34.0 golang.org/x/sys v0.39.0 diff --git a/go.sum b/go.sum index efe880fb..7258d837 100644 --- a/go.sum +++ b/go.sum @@ -80,15 +80,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -135,7 +134,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= From 57c44ea0f5aab17ba98052dff868c75dce2d9198 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 20 Jan 2026 17:37:41 +0100 Subject: [PATCH 02/63] add create and delete snapshot capability --- driver/controller.go | 303 +++++++++++++++++++++++++++++++++++++++--- driver/driver_test.go | 118 +++++++++++++++- go.mod | 7 +- go.sum | 10 +- 4 files changed, 410 insertions(+), 28 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 5d3600f9..6030b8b5 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -24,13 +24,14 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/cloudscale-ch/cloudscale-go-sdk/v6" "github.com/container-storage-interface/spec/lib/go/csi" "github.com/sirupsen/logrus" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - + "google.golang.org/protobuf/types/known/timestamppb" "k8s.io/apimachinery/pkg/util/sets" ) @@ -85,6 +86,15 @@ func (d *Driver) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("volume capabilities cannot be satisified: %s", strings.Join(violations, "; "))) } + if req.GetVolumeContentSource() != nil { + if sourceSnapshot := req.GetVolumeContentSource().GetSnapshot(); sourceSnapshot != nil { + return d.createVolumeFromSnapshot(ctx, req, sourceSnapshot) + } + if sourceVolume := req.GetVolumeContentSource().GetVolume(); sourceVolume != nil { + return nil, status.Error(codes.Unimplemented, "volume cloning is not yet supported") + } + } + if req.AccessibilityRequirements != nil { for _, t := range req.AccessibilityRequirements.Requisite { zone, ok := t.Segments[topologyZonePrefix] @@ -173,7 +183,7 @@ func (d *Driver) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) return &csi.CreateVolumeResponse{Volume: &csiVolume}, nil } - volumeReq := &cloudscale.VolumeRequest{ + volumeReq := &cloudscale.VolumeCreateRequest{ Name: volumeName, SizeGB: sizeGB, Type: storageType, @@ -193,6 +203,170 @@ func (d *Driver) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) return resp, nil } +// createVolumeFromSnapshot handles volume creation from an existing snapshot +func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVolumeRequest, sourceSnapshot *csi.VolumeContentSource_SnapshotSource) (*csi.CreateVolumeResponse, error) { + sourceSnapshotID := sourceSnapshot.GetSnapshotId() + if sourceSnapshotID == "" { + return nil, status.Error(codes.InvalidArgument, "snapshotID must be provided in volume content source") + } + + volumeName := req.Name + + ll := d.log.WithFields(logrus.Fields{ + "volume_name": volumeName, + "source_snapshot_id": sourceSnapshotID, + "method": "create_volume_from_snapshot", + }) + ll.Info("create volume from snapshot called") + + // Verify snapshot exists and get its properties, must return NotFound when snapshot does not exist. + snapshot, err := d.cloudscaleClient.VolumeSnapshots.Get(ctx, sourceSnapshotID) + if err != nil { + errorResponse, ok := err.(*cloudscale.ErrorResponse) + if ok { + if errorResponse.StatusCode == http.StatusNotFound { + return nil, status.Errorf(codes.NotFound, "source snapshot %s not found", sourceSnapshotID) + } + } + return nil, status.Errorf(codes.Internal, "failed to get source snapshot: %v", err) + } + + ll = ll.WithFields(logrus.Fields{ + "snapshot_size_gb": snapshot.SizeGB, + "snapshot_volume_type": snapshot.Volume.Type, + "snapshot_zone": snapshot.Zone, + }) + + // Validate capacity requirements + // CSI spec: restored volume must be at least as large as the snapshot + // Cloudscale only supports the same size as the snapshot + if req.CapacityRange != nil { + requiredBytes := req.CapacityRange.GetRequiredBytes() + if requiredBytes > 0 { + requiredGB := int(requiredBytes / GB) + if requiredGB < snapshot.SizeGB { + return nil, status.Errorf(codes.InvalidArgument, + "requested volume size (%d GB) is smaller than snapshot size (%d GB)", + requiredGB, snapshot.SizeGB) + } + if requiredGB > snapshot.SizeGB { + return nil, status.Errorf(codes.InvalidArgument, + "cloudscale.ch API does not support creating volumes larger than snapshot size during restore. "+ + "Create volume from snapshot first, then expand it using ControllerExpandVolume. "+ + "Requested: %d GB, Snapshot: %d GB", requiredGB, snapshot.SizeGB) + } + } + + // Validate limit if specified + limitBytes := req.CapacityRange.GetLimitBytes() + if limitBytes > 0 && int64(snapshot.SizeGB)*GB > limitBytes { + return nil, status.Errorf(codes.OutOfRange, + "snapshot size (%d GB) exceeds capacity limit (%d bytes)", + snapshot.SizeGB, limitBytes) + } + } + + // cloudscale does create the volume in the same zone as the snapshot. + if req.AccessibilityRequirements != nil { + for _, t := range req.AccessibilityRequirements.Requisite { + zone, ok := t.Segments[topologyZonePrefix] + if !ok { + continue + } + if zone != snapshot.Zone.Slug { + return nil, status.Errorf(codes.InvalidArgument, + "requested zone %s does not match snapshot zone %s", zone, snapshot.Zone) + } + } + } + + // cloudscale does not support to change storage type, so we warn if parameters are specified that will be ignored + if storageType := req.Parameters[StorageTypeAttribute]; storageType != "" && storageType != snapshot.Volume.Type { + ll.WithFields(logrus.Fields{ + "requested_type": storageType, + "snapshot_volume_type": snapshot.Volume.Type, + }).Warn("storage type parameter ignored when creating from snapshot") + } + + luksEncrypted := "false" + if req.Parameters[LuksEncryptedAttribute] == "true" { + if violations := validateLuksCapabilities(req.VolumeCapabilities); len(violations) > 0 { + return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("volume capabilities cannot be satisified: %s", strings.Join(violations, "; "))) + } + luksEncrypted = "true" + } + + // Check if volume already exists + volumes, err := d.cloudscaleClient.Volumes.List(ctx, cloudscale.WithNameFilter(volumeName)) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + csiVolume := csi.Volume{ + CapacityBytes: int64(snapshot.SizeGB) * GB, + AccessibleTopology: []*csi.Topology{ + { + Segments: map[string]string{ + topologyZonePrefix: d.zone, + }, + }, + }, + VolumeContext: map[string]string{ + PublishInfoVolumeName: volumeName, + LuksEncryptedAttribute: luksEncrypted, + }, + ContentSource: req.GetVolumeContentSource(), + } + + if luksEncrypted == "true" { + csiVolume.VolumeContext[LuksCipherAttribute] = req.Parameters[LuksCipherAttribute] + csiVolume.VolumeContext[LuksKeySizeAttribute] = req.Parameters[LuksKeySizeAttribute] + } + + // Volume already exists - validate it matches request + if len(volumes) != 0 { + if len(volumes) > 1 { + return nil, fmt.Errorf("fatal issue: duplicate volume %q exists", volumeName) + } + vol := volumes[0] + + if vol.SizeGB != snapshot.SizeGB { + return nil, status.Errorf(codes.AlreadyExists, + "volume %q already exists with size %d GB, but snapshot requires %d GB", + volumeName, vol.SizeGB, snapshot.SizeGB) + } + + if vol.Zone != snapshot.Zone { + return nil, status.Errorf(codes.AlreadyExists, + "volume %q already exists in zone %s, but snapshot is in zone %s", + volumeName, vol.Zone, snapshot.Zone) + } + + ll.Info("volume from snapshot already exists") + csiVolume.VolumeId = vol.UUID + return &csi.CreateVolumeResponse{Volume: &csiVolume}, nil + } + + // Create volume from snapshot + volumeReq := &cloudscale.VolumeCreateRequest{ + Name: volumeName, + VolumeSnapshotUUID: sourceSnapshotID, + // Size, Type, Zone are inherited from snapshot - do NOT set them + } + + ll.WithField("volume_req", volumeReq).Info("creating volume from snapshot") + vol, err := d.cloudscaleClient.Volumes.Create(ctx, volumeReq) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create volume from snapshot: %v", err) + } + + csiVolume.VolumeId = vol.UUID + resp := &csi.CreateVolumeResponse{Volume: &csiVolume} + + ll.WithField("response", resp).Info("volume created from snapshot") + return resp, nil +} + // DeleteVolume deletes the given volume. The function is idempotent. func (d *Driver) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequest) (*csi.DeleteVolumeResponse, error) { if req.VolumeId == "" { @@ -255,7 +429,7 @@ func (d *Driver) ControllerPublishVolume(ctx context.Context, req *csi.Controlle }) ll.Info("controller publish volume called") - attachRequest := &cloudscale.VolumeRequest{ + attachRequest := &cloudscale.VolumeUpdateRequest{ ServerUUIDs: &[]string{req.NodeId}, } err := d.cloudscaleClient.Volumes.Update(ctx, req.VolumeId, attachRequest) @@ -329,7 +503,7 @@ func (d *Driver) ControllerUnpublishVolume(ctx context.Context, req *csi.Control ll.Info("Volume is attached to node given in request or NodeID in request is not set.") - detachRequest := &cloudscale.VolumeRequest{ + detachRequest := &cloudscale.VolumeUpdateRequest{ ServerUUIDs: &[]string{}, } err = d.cloudscaleClient.Volumes.Update(ctx, req.VolumeId, detachRequest) @@ -451,9 +625,7 @@ func (d *Driver) ControllerGetCapabilities(ctx context.Context, req *csi.Control csi.ControllerServiceCapability_RPC_PUBLISH_UNPUBLISH_VOLUME, csi.ControllerServiceCapability_RPC_LIST_VOLUMES, csi.ControllerServiceCapability_RPC_EXPAND_VOLUME, - - // TODO(arslan): enable once snapshotting is supported - // csi.ControllerServiceCapability_RPC_CREATE_DELETE_SNAPSHOT, + csi.ControllerServiceCapability_RPC_CREATE_DELETE_SNAPSHOT, // csi.ControllerServiceCapability_RPC_LIST_SNAPSHOTS, // TODO: check if this can be implemented @@ -476,20 +648,113 @@ func (d *Driver) ControllerGetCapabilities(ctx context.Context, req *csi.Control // CreateSnapshot will be called by the CO to create a new snapshot from a // source volume on behalf of a user. func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequest) (*csi.CreateSnapshotResponse, error) { - d.log.WithFields(logrus.Fields{ - "req": req, - "method": "create_snapshot", - }).Warn("create snapshot is not implemented") - return nil, status.Error(codes.Unimplemented, "") + if req.Name == "" { + return nil, status.Error(codes.InvalidArgument, "CreateSnapshotRequest Name must be provided") + } + + if req.SourceVolumeId == "" { + return nil, status.Error(codes.InvalidArgument, "CreateSnapshotRequest Source Volume Id must be provided") + } + + ll := d.log.WithFields(logrus.Fields{ + "source_volume_id": req.SourceVolumeId, + "name": req.Name, + "method": "create_snapshot", + }) + + ll.Info("find existing volume snapshots with same name") + snapshots, err := d.cloudscaleClient.VolumeSnapshots.List(ctx, cloudscale.WithNameFilter(req.Name)) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + for _, snapshot := range snapshots { + if snapshot.Volume.UUID == req.SourceVolumeId { + creationTime := timestamppb.Now() + if snapshot.CreatedAt != "" { + if t, err := time.Parse(time.RFC3339, snapshot.CreatedAt); err == nil { + creationTime = timestamppb.New(t) + } + } + + return &csi.CreateSnapshotResponse{ + Snapshot: &csi.Snapshot{ + SnapshotId: snapshot.UUID, + SourceVolumeId: snapshot.Volume.UUID, + ReadyToUse: snapshot.Status == "available", + SizeBytes: int64(snapshot.SizeGB * GB), + CreationTime: creationTime, + }, + }, nil + } + return nil, status.Error(codes.AlreadyExists, "snapshot with this name already exists for another volume.") + } + + volumeSnapshotCreateRequest := &cloudscale.VolumeSnapshotCreateRequest{ + Name: req.Name, + SourceVolume: req.SourceVolumeId, + // todo: tags? + } + + ll.WithField("volume_snapshot_create_request", volumeSnapshotCreateRequest).Info("creating volume snapshot") + snapshot, err := d.cloudscaleClient.VolumeSnapshots.Create(ctx, volumeSnapshotCreateRequest) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + creationTime := timestamppb.Now() + if snapshot.CreatedAt != "" { + if t, err := time.Parse(time.RFC3339, snapshot.CreatedAt); err == nil { + creationTime = timestamppb.New(t) + } + } + + resp := &csi.CreateSnapshotResponse{ + Snapshot: &csi.Snapshot{ + SnapshotId: snapshot.UUID, + SourceVolumeId: snapshot.Volume.UUID, + ReadyToUse: snapshot.Status == "available", // check status + SizeBytes: int64(snapshot.SizeGB * GB), + CreationTime: creationTime, + }, + } + + ll.WithField("response", resp).Info("volume snapshot created") + return resp, nil } -// DeleteSnapshost will be called by the CO to delete a snapshot. +// DeleteSnapshot will be called by the CO to delete a snapshot. func (d *Driver) DeleteSnapshot(ctx context.Context, req *csi.DeleteSnapshotRequest) (*csi.DeleteSnapshotResponse, error) { - d.log.WithFields(logrus.Fields{ - "req": req, - "method": "delete_snapshot", - }).Warn("delete snapshot is not implemented") - return nil, status.Error(codes.Unimplemented, "") + if req.SnapshotId == "" { + return nil, status.Error(codes.InvalidArgument, "DeleteSnapshot Snapshot ID must be provided") + } + + ll := d.log.WithFields(logrus.Fields{ + "snapshot_id": req.SnapshotId, + "method": "delete_snapshot", + }) + ll.Info("delete snapshot called") + + // todo: think through long running delete jobs + err := d.cloudscaleClient.VolumeSnapshots.Delete(ctx, req.SnapshotId) + if err != nil { + errorResponse, ok := err.(*cloudscale.ErrorResponse) + if ok { + if errorResponse.StatusCode == http.StatusNotFound { + // To make it idempotent, the volume might already have been + // deleted, so a 404 is ok. + ll.WithFields(logrus.Fields{ + "error": err, + "resp": errorResponse, + }).Warn("assuming snapshot is already deleted") + return &csi.DeleteSnapshotResponse{}, nil + } + } + return nil, err + } + + ll.Info("snapshot is deleted") + return &csi.DeleteSnapshotResponse{}, nil } // ListSnapshots returns the information about all snapshots on the storage @@ -538,7 +803,7 @@ func (d *Driver) ControllerExpandVolume(ctx context.Context, req *csi.Controller return &csi.ControllerExpandVolumeResponse{CapacityBytes: int64(volume.SizeGB) * GB, NodeExpansionRequired: true}, nil } - volumeReq := &cloudscale.VolumeRequest{ + volumeReq := &cloudscale.VolumeUpdateRequest{ SizeGB: resizeGigaBytes, } err = d.cloudscaleClient.Volumes.Update(ctx, volume.UUID, volumeReq) diff --git a/driver/driver_test.go b/driver/driver_test.go index e0cd7034..460f1d8e 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -106,6 +106,11 @@ func NewFakeClient(initialServers map[string]*cloudscale.Server) *cloudscale.Cli volumes: make(map[string]*cloudscale.Volume), } + fakeClient.VolumeSnapshots = &FakeVolumeSnapshotServiceOperations{ + fakeClient: fakeClient, + snapshots: make(map[string]*cloudscale.VolumeSnapshot), + } + return fakeClient } @@ -184,8 +189,13 @@ type FakeVolumeServiceOperations struct { volumes map[string]*cloudscale.Volume } -func (f FakeVolumeServiceOperations) Create(ctx context.Context, createRequest *cloudscale.VolumeRequest) (*cloudscale.Volume, error) { +func (f FakeVolumeServiceOperations) Create(ctx context.Context, createRequest *cloudscale.VolumeCreateRequest) (*cloudscale.Volume, error) { id := randString(32) + + // todo: CSI-test pass without this, but we could implement: + // - check if volumeSnapshot is present. Return error if volumeSnapshot does not exist + // - create volume with inferred values form snapshot. + vol := &cloudscale.Volume{ UUID: id, Name: createRequest.Name, @@ -254,7 +264,7 @@ func extractParams(modifiers []cloudscale.ListRequestModifier) url.Values { return params } -func (f FakeVolumeServiceOperations) Update(ctx context.Context, volumeID string, updateRequest *cloudscale.VolumeRequest) error { +func (f FakeVolumeServiceOperations) Update(ctx context.Context, volumeID string, updateRequest *cloudscale.VolumeUpdateRequest) error { vol, ok := f.volumes[volumeID] if ok != true { return generateNotFoundError() @@ -306,6 +316,23 @@ func getVolumesPerServer(f FakeVolumeServiceOperations, serverUUID string) int { } func (f FakeVolumeServiceOperations) Delete(ctx context.Context, volumeID string) error { + + // prevent deletion if snapshots exist + snapshots, err := f.fakeClient.VolumeSnapshots.List(context.Background()) + + if err != nil { + return err + } + + for _, snapshot := range snapshots { + if snapshot.Volume.UUID == volumeID { + return &cloudscale.ErrorResponse{ + StatusCode: 409, + Message: map[string]string{"detail": "volume has snapshots"}, + } + } + } + delete(f.volumes, volumeID) return nil } @@ -363,6 +390,93 @@ func (f *FakeVolumeServiceOperations) WaitFor(ctx context.Context, id string, co panic("implement me") } +type FakeVolumeSnapshotServiceOperations struct { + fakeClient *cloudscale.Client + snapshots map[string]*cloudscale.VolumeSnapshot +} + +func (f FakeVolumeSnapshotServiceOperations) Create(ctx context.Context, createRequest *cloudscale.VolumeSnapshotCreateRequest) (*cloudscale.VolumeSnapshot, error) { + + vol, err := f.fakeClient.Volumes.Get(ctx, createRequest.SourceVolume) + if err != nil { + return nil, err + } + + id := randString(32) + snap := &cloudscale.VolumeSnapshot{ + UUID: id, + Name: createRequest.Name, + SizeGB: vol.SizeGB, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Status: "available", + Volume: cloudscale.VolumeStub{ + UUID: createRequest.SourceVolume, + }, + } + + f.snapshots[id] = snap + return snap, nil +} + +func (f *FakeVolumeSnapshotServiceOperations) Get( + ctx context.Context, + snapshotID string, +) (*cloudscale.VolumeSnapshot, error) { + + snap, ok := f.snapshots[snapshotID] + if !ok { + return nil, generateNotFoundError() + } + return snap, nil +} + +func (f *FakeVolumeSnapshotServiceOperations) List( + ctx context.Context, + modifiers ...cloudscale.ListRequestModifier, +) ([]cloudscale.VolumeSnapshot, error) { + var snapshots []cloudscale.VolumeSnapshot + + for _, snapshot := range f.snapshots { + snapshots = append(snapshots, *snapshot) + } + + if len(modifiers) == 0 { + return snapshots, nil + } + if len(modifiers) > 1 { + panic("implement me (support for more than one modifier)") + } + + params := extractParams(modifiers) + + if filterName := params.Get("name"); filterName != "" { + filtered := make([]cloudscale.VolumeSnapshot, 0, 1) + for _, snapshot := range snapshots { + if snapshot.Name == filterName { + filtered = append(filtered, snapshot) + } + } + return filtered, nil + } + + panic("implement me (support for unknown param)") +} + +func (f FakeVolumeSnapshotServiceOperations) Update(ctx context.Context, resourceID string, updateRequest *cloudscale.VolumeSnapshotUpdateRequest) error { + panic("implement me") +} + +func (f *FakeVolumeSnapshotServiceOperations) Delete( + ctx context.Context, + snapshotID string, +) error { + delete(f.snapshots, snapshotID) + return nil +} +func (f FakeVolumeSnapshotServiceOperations) WaitFor(ctx context.Context, resourceID string, condition func(resource *cloudscale.VolumeSnapshot) (bool, error), opts ...backoff.RetryOption) (*cloudscale.VolumeSnapshot, error) { + panic("implement me") +} + func generateNotFoundError() *cloudscale.ErrorResponse { return &cloudscale.ErrorResponse{ StatusCode: 404, diff --git a/go.mod b/go.mod index e554e514..76f3fe4d 100644 --- a/go.mod +++ b/go.mod @@ -2,16 +2,18 @@ module github.com/cloudscale-ch/csi-cloudscale require ( github.com/cenkalti/backoff/v5 v5.0.3 - github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.1 + github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260113130452-d14a0cbe6a32 github.com/container-storage-interface/spec v1.12.0 github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 github.com/kubernetes-csi/csi-test/v5 v5.4.0 + github.com/kubernetes-csi/external-snapshotter/client/v6 v6.3.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.34.0 golang.org/x/sys v0.39.0 google.golang.org/grpc v1.77.0 + google.golang.org/protobuf v1.36.10 k8s.io/api v0.28.15 k8s.io/apimachinery v0.28.15 k8s.io/client-go v0.28.15 @@ -21,7 +23,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/emicklei/go-restful/v3 v3.9.0 // indirect + github.com/emicklei/go-restful/v3 v3.10.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect @@ -52,7 +54,6 @@ require ( golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.37.0 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect - google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index efe880fb..27445127 100644 --- a/go.sum +++ b/go.sum @@ -2,16 +2,16 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.1 h1:2P+TKwtB50hogQ2neIPX+7ARNMy7vaDU9bkMGEhOz3k= -github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.1/go.mod h1:NLC7XW7HqG0HggDaOBCvmf7WplTDaAqTF9u08yh6k0E= +github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260113130452-d14a0cbe6a32 h1:XUwopev0HXEmCVUrmuXHmDadux857+WSPWSDzj1zrhs= +github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260113130452-d14a0cbe6a32/go.mod h1:NLC7XW7HqG0HggDaOBCvmf7WplTDaAqTF9u08yh6k0E= github.com/container-storage-interface/spec v1.12.0 h1:zrFOEqpR5AghNaaDG4qyedwPBqU2fU0dWjLQMP/azK0= github.com/container-storage-interface/spec v1.12.0/go.mod h1:txsm+MA2B2WDa5kW69jNbqPnvTtfvZma7T/zsAZ9qX8= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE= -github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.10.1 h1:rc42Y5YTp7Am7CS630D7JmhRjq4UlEUuEKfrDac4bSQ= +github.com/emicklei/go-restful/v3 v3.10.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -59,6 +59,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubernetes-csi/csi-test/v5 v5.4.0 h1:u5DgYNIreSNO2+u4Nq2Wpl+bbakRSjNyxZHmDTAqnYA= github.com/kubernetes-csi/csi-test/v5 v5.4.0/go.mod h1:anAJKFUb/SdHhIHECgSKxC5LSiLzib+1I6mrWF5Hve8= +github.com/kubernetes-csi/external-snapshotter/client/v6 v6.3.0 h1:qS4r4ljINLWKJ9m9Ge3Q3sGZ/eIoDVDT2RhAdQFHb1k= +github.com/kubernetes-csi/external-snapshotter/client/v6 v6.3.0/go.mod h1:oGXx2XTEzs9ikW2V6IC1dD8trgjRsS/Mvc2JRiC618Y= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= From fdd653e6f81eb3c997c4072a55ecd40063390c65 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 20 Jan 2026 18:21:41 +0100 Subject: [PATCH 03/63] add csi-snapshotter sidecar --- README.md | 20 +++++++++++++++++++ charts/csi-cloudscale/templates/rbac.yaml | 20 ++++++++++++++++++- .../csi-cloudscale/templates/statefulset.yaml | 12 +++++++++++ charts/csi-cloudscale/values.yaml | 12 +++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ab57dcd..6b78b1f0 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,26 @@ The current version is: **`v3.5.6`**. ## Installing to Kubernetes +Follow these steps to deploy the cloudscale.ch CSI driver to your Kubernetes cluster. + +### Prerequisites for Snapshot Support + +To use CSI snapshots with this driver, your cluster must have the VolumeSnapshot CRDs and the snapshot controller installed. + +Note: Some Kubernetes distributions already include these CRDs and controllers. You only need to apply them manually if your cluster does not provide them. + +Install the snapshot resources: +``` +# Create the necessary CRDs +kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.4.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml +kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.4.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml +kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.4.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml + +# Install snapshot controller with RBAC +kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.4.0/deploy/kubernetes/snapshot-controller/rbac-snapshot-controller.yaml +kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.4.0/deploy/kubernetes/snapshot-controller/setup-snapshot-controller.yaml +``` + ### Kubernetes Compatibility The following table describes the required cloudscale.ch driver version per diff --git a/charts/csi-cloudscale/templates/rbac.yaml b/charts/csi-cloudscale/templates/rbac.yaml index 7013c80a..81b7a2c6 100644 --- a/charts/csi-cloudscale/templates/rbac.yaml +++ b/charts/csi-cloudscale/templates/rbac.yaml @@ -21,7 +21,25 @@ rules: verbs: ["get", "list"] - apiGroups: ["snapshot.storage.k8s.io"] resources: ["volumesnapshotcontents"] - verbs: ["get", "list"] + verbs: [ "get", "list", "watch", "update", "patch" ] + - apiGroups: [ "snapshot.storage.k8s.io" ] + resources: [ "volumesnapshotcontents/status" ] + verbs: [ "update", "patch" ] + - apiGroups: [ "snapshot.storage.k8s.io" ] + resources: [ "volumesnapshotclasses" ] + verbs: [ "get", "list", "watch" ] + - apiGroups: [ "groupsnapshot.storage.k8s.io" ] # todo: are we sure about this? snapshot groups are not supported + resources: [ "volumegroupsnapshotclasses" ] + verbs: [ "get", "list", "watch" ] + - apiGroups: [ "groupsnapshot.storage.k8s.io" ] + resources: [ "volumegroupsnapshotcontents" ] + verbs: [ "get", "list", "watch", "update", "patch" ] + - apiGroups: [ "groupsnapshot.storage.k8s.io" ] + resources: [ "volumegroupsnapshotcontents/status" ] + verbs: [ "update", "patch" ] + - apiGroups: [ "coordination.k8s.io" ] + resources: [ "leases" ] + verbs: [ "get", "list", "watch", "create", "update", "patch", "delete" ] - apiGroups: [ "storage.k8s.io" ] resources: [ "csinodes" ] verbs: [ "get", "list", "watch" ] diff --git a/charts/csi-cloudscale/templates/statefulset.yaml b/charts/csi-cloudscale/templates/statefulset.yaml index 36733885..779b236c 100644 --- a/charts/csi-cloudscale/templates/statefulset.yaml +++ b/charts/csi-cloudscale/templates/statefulset.yaml @@ -72,6 +72,18 @@ spec: volumeMounts: - name: socket-dir mountPath: /var/lib/csi/sockets/pluginproxy/ + - name: csi-snapshotter + image: "{{ .Values.snapshotter.image.registry }}/{{ .Values.snapshotter.image.repository }}:{{ .Values.snapshotter.image.tag }}" + args: + - "--csi-address=$(CSI_ENDPOINT)" + - "--leader-election=true" + - "--v=5" + env: + - name: CSI_ENDPOINT + value: unix:///var/lib/csi/sockets/pluginproxy/csi.sock + volumeMounts: + - name: socket-dir + mountPath: /var/lib/csi/sockets/pluginproxy/ - name: csi-cloudscale-plugin image: "{{ .Values.controller.image.registry }}/{{ .Values.controller.image.repository }}:{{ .Values.controller.image.tag }}" args : diff --git a/charts/csi-cloudscale/values.yaml b/charts/csi-cloudscale/values.yaml index 2ea7ba73..dc77db02 100644 --- a/charts/csi-cloudscale/values.yaml +++ b/charts/csi-cloudscale/values.yaml @@ -79,6 +79,18 @@ resizer: # cpu: 100m # memory: 128Mi + + +snapshotter: + image: + registry: registry.k8s.io + repository: sig-storage/csi-snapshotter + tag: v8.4.0 + pullPolicy: IfNotPresent + logLevelVerbosity: "5" + resources: {} + + controller: replicas: 1 image: From 226e6043488f4b9ee49b08b42583ccbd104578ce Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 20 Jan 2026 18:58:33 +0100 Subject: [PATCH 04/63] fix for existing snapshots on other volumes --- driver/controller.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/driver/controller.go b/driver/controller.go index dc50fc73..e8f9cc0d 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -687,7 +687,11 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ }, }, nil } - return nil, status.Error(codes.AlreadyExists, "snapshot with this name already exists for another volume.") + + // Snapshot name exists but for a different volume + if snapshot.Volume.UUID != req.SourceVolumeId { + return nil, status.Error(codes.AlreadyExists, "snapshot with this name already exists for another volume") + } } volumeSnapshotCreateRequest := &cloudscale.VolumeSnapshotCreateRequest{ From a731ebe04624d61829ca34f8fcca8af24e8349c0 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Wed, 21 Jan 2026 19:55:30 +0100 Subject: [PATCH 05/63] add integraiton test covering creation and deletion of snapshot --- test/kubernetes/integration_test.go | 272 ++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index 74bbdf49..6b1aed0c 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -22,15 +22,20 @@ import ( "github.com/cloudscale-ch/csi-cloudscale/driver" "github.com/stretchr/testify/assert" "golang.org/x/oauth2" + "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" + snapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" appsv1 "k8s.io/api/apps/v1" "k8s.io/api/core/v1" kubeerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/selection" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" @@ -158,6 +163,76 @@ func TestPod_Single_SSD_Volume(t *testing.T) { waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) } +func TestPod_Single_SSD_Volume_Snapshot(t *testing.T) { + podDescriptor := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-pod-ssd-pvc", + SizeGB: 5, + StorageClass: "cloudscale-volume-ssd", + }, + }, + } + + // submit the pod and the pvc + pod := makeKubernetesPod(t, podDescriptor) + pvcs := makeKubernetesPVCs(t, podDescriptor) + assert.Equal(t, 1, len(pvcs)) + + // wait for the pod to be running and verify that the pvc is bound + waitForPod(t, client, pod.Name) + pvc := getPVC(t, client, pvcs[0].Name) + assert.Equal(t, v1.ClaimBound, pvc.Status.Phase) + + // load the volume from the cloudscale.ch api and verify that it + // has the requested size and volume type + volume := getCloudscaleVolume(t, pvc.Spec.VolumeName) + assert.Equal(t, 5, volume.SizeGB) + assert.Equal(t, "ssd", volume.Type) + + // verify that our disk is not luks-encrypted, formatted with ext4 and 5 GB big + disk, err := getVolumeInfo(t, pod, pvc.Spec.VolumeName) + assert.NoError(t, err) + assert.Equal(t, "", disk.Luks) + assert.Equal(t, "Filesystem", disk.PVCVolumeMode) + assert.Equal(t, "ext4", disk.Filesystem) + assert.Equal(t, 5*driver.GB, disk.DeviceSize) + assert.Equal(t, 5*driver.GB, disk.FilesystemSize) + + // create a snapshot of the volume + snapshotName := pseudoUuid() + snapshot := makeKubernetesVolumeSnapshot(t, snapshotName, pvc.Name) + + // wait for the snapshot to be ready + waitForVolumeSnapshot(t, client, snapshot.Name) + snapshot = getVolumeSnapshot(t, client, snapshot.Name) + assert.NotNil(t, snapshot.Status) + assert.NotNil(t, snapshot.Status.BoundVolumeSnapshotContentName) + assert.True(t, *snapshot.Status.ReadyToUse) + + snapshotContent := getVolumeSnapshotContent(t, *snapshot.Status.BoundVolumeSnapshotContentName) + assert.NotNil(t, snapshotContent.Status) + assert.NotNil(t, snapshotContent.Status.SnapshotHandle) + + cloudscaleSnapshot := getCloudscaleVolumeSnapshot(t, *snapshotContent.Status.SnapshotHandle) + assert.NotNil(t, cloudscaleSnapshot) + assert.Equal(t, *snapshotContent.Status.SnapshotHandle, cloudscaleSnapshot.UUID) + assert.Equal(t, "available", cloudscaleSnapshot.Status) + assert.Equal(t, 5, cloudscaleSnapshot.SizeGB) + + // delete the snapshot before deleting the volume + deleteKubernetesVolumeSnapshot(t, snapshot.Name) + waitCloudscaleVolumeSnapshotDeleted(t, *snapshotContent.Status.SnapshotHandle) + + // delete the pod and the pvcs and wait until the volume was deleted from + // the cloudscale.ch account; this check is necessary to test that the + // csi-plugin properly deletes the volume from cloudscale.ch + cleanup(t, podDescriptor) + waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) +} + func TestPod_Single_SSD_Raw_Volume(t *testing.T) { podDescriptor := TestPodDescriptor{ Kind: "Pod", @@ -1452,3 +1527,200 @@ func generateMetricEntry(line string) MetricEntry { } return MetricEntry{split[0], "", split[1]} } + +// makeKubernetesVolumeSnapshot creates a VolumeSnapshot for the given PVC +func makeKubernetesVolumeSnapshot(t *testing.T, snapshotName string, pvcName string) *snapshotv1.VolumeSnapshot { + className := "cloudscale-snapshots" + + snapshot := &snapshotv1.VolumeSnapshot{ + TypeMeta: metav1.TypeMeta{ + Kind: "VolumeSnapshot", + APIVersion: "snapshot.storage.k8s.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: snapshotName, + Namespace: namespace, + }, + Spec: snapshotv1.VolumeSnapshotSpec{ + VolumeSnapshotClassName: &className, + Source: snapshotv1.VolumeSnapshotSource{ + PersistentVolumeClaimName: &pvcName, + }, + }, + } + + t.Logf("Creating volume snapshot %v", snapshotName) + snapshotClient := getDynamicSnapshotClient(t) + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(snapshot) + if err != nil { + t.Fatal(err) + } + + unstructuredSnapshot := &unstructured.Unstructured{Object: obj} + + gvr := schema.GroupVersionResource{ + Group: "snapshot.storage.k8s.io", + Version: "v1", + Resource: "volumesnapshots", + } + + created, err := snapshotClient.Resource(gvr).Namespace(namespace).Create( + context.Background(), + unstructuredSnapshot, + metav1.CreateOptions{}, + ) + if err != nil { + t.Fatal(err) + } + + var result snapshotv1.VolumeSnapshot + err = runtime.DefaultUnstructuredConverter.FromUnstructured(created.Object, &result) + if err != nil { + t.Fatal(err) + } + + return &result +} + +// deleteKubernetesVolumeSnapshot deletes the VolumeSnapshot with the given name +func deleteKubernetesVolumeSnapshot(t *testing.T, snapshotName string) { + t.Logf("Deleting volume snapshot %v", snapshotName) + snapshotClient := getDynamicSnapshotClient(t) + + gvr := schema.GroupVersionResource{ + Group: "snapshot.storage.k8s.io", + Version: "v1", + Resource: "volumesnapshots", + } + + err := snapshotClient.Resource(gvr).Namespace(namespace).Delete( + context.Background(), + snapshotName, + metav1.DeleteOptions{}, + ) + assert.NoError(t, err) +} + +// waitForVolumeSnapshot waits for the VolumeSnapshot to be ready +func waitForVolumeSnapshot(t *testing.T, client kubernetes.Interface, name string) { + start := time.Now() + + t.Logf("Waiting for volume snapshot %q to be ready ...\n", name) + + for { + snapshot := getVolumeSnapshot(t, client, name) + + if snapshot.Status != nil && snapshot.Status.ReadyToUse != nil && *snapshot.Status.ReadyToUse { + t.Logf("Volume snapshot %q is ready\n", name) + return + } + + if time.Now().UnixNano()-start.UnixNano() > (5 * time.Minute).Nanoseconds() { + t.Fatalf("timeout exceeded while waiting for volume snapshot %v to be ready", name) + return + } + + t.Logf("Volume snapshot %q not ready yet; waiting...", name) + time.Sleep(5 * time.Second) + } +} + +// getVolumeSnapshot retrieves the VolumeSnapshot with the given name +func getVolumeSnapshot(t *testing.T, client kubernetes.Interface, name string) *snapshotv1.VolumeSnapshot { + snapshotClient := getDynamicSnapshotClient(t) + + gvr := schema.GroupVersionResource{ + Group: "snapshot.storage.k8s.io", + Version: "v1", + Resource: "volumesnapshots", + } + + unstructuredSnapshot, err := snapshotClient.Resource(gvr).Namespace(namespace).Get( + context.Background(), + name, + metav1.GetOptions{}, + ) + assert.NoError(t, err) + + var snapshot snapshotv1.VolumeSnapshot + err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredSnapshot.Object, &snapshot) + assert.NoError(t, err) + + return &snapshot +} + +func getCloudscaleVolumeSnapshot(t *testing.T, snapshotHandle string) *cloudscale.VolumeSnapshot { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + snapshot, err := cloudscaleClient.VolumeSnapshots.Get(ctx, snapshotHandle) + if err != nil { + t.Fatalf("Could not find snapshot with handle %v: %v", snapshotHandle, err) + } + + return snapshot +} + +// waitCloudscaleVolumeSnapshotDeleted waits until the snapshot with the given handle was deleted +func waitCloudscaleVolumeSnapshotDeleted(t *testing.T, snapshotHandle string) { + start := time.Now() + + for { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + _, err := cloudscaleClient.VolumeSnapshots.Get(ctx, snapshotHandle) + cancel() + + if err != nil { + if cloudscaleErr, ok := err.(*cloudscale.ErrorResponse); ok { + if cloudscaleErr.StatusCode == http.StatusNotFound { + t.Logf("snapshot %v is deleted on cloudscale", snapshotHandle) + return + } + } + // Some other error - log but continue waiting + t.Logf("error checking snapshot %v: %v", snapshotHandle, err) + } + + if time.Since(start) > 5*time.Minute { + t.Errorf("timeout exceeded while waiting for snapshot %v to be deleted from cloudscale", snapshotHandle) + return + } + + t.Logf("snapshot %v not deleted on cloudscale yet; awaiting deletion", snapshotHandle) + time.Sleep(5 * time.Second) + } +} + +// getVolumeSnapshotContent retrieves the VolumeSnapshotContent for a VolumeSnapshot +func getVolumeSnapshotContent(t *testing.T, contentName string) *snapshotv1.VolumeSnapshotContent { + snapshotClient := getDynamicSnapshotClient(t) + + gvr := schema.GroupVersionResource{ + Group: "snapshot.storage.k8s.io", + Version: "v1", + Resource: "volumesnapshotcontents", + } + + unstructuredContent, err := snapshotClient.Resource(gvr).Get( + context.Background(), + contentName, + metav1.GetOptions{}, + ) + assert.NoError(t, err) + + var content snapshotv1.VolumeSnapshotContent + err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredContent.Object, &content) + assert.NoError(t, err) + + return &content +} + +// getDynamicSnapshotClient returns a dynamic client for working with VolumeSnapshots +func getDynamicSnapshotClient(t *testing.T) dynamic.Interface { + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + return dynamicClient +} From aa6bedb671595ca0220ddc7f4eae5b59c77c9861 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Wed, 21 Jan 2026 21:09:49 +0100 Subject: [PATCH 06/63] add integration test creating a new volume from a snapshot --- driver/controller.go | 13 +++ test/kubernetes/integration_test.go | 154 ++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/driver/controller.go b/driver/controller.go index e8f9cc0d..8f664eb5 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -383,6 +383,11 @@ func (d *Driver) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequest) if err != nil { errorResponse, ok := err.(*cloudscale.ErrorResponse) if ok { + ll.WithFields(logrus.Fields{ + "status_code": errorResponse.StatusCode, + "error": err, + }).Warn("cloudscale API returned error during volume deletion") + if errorResponse.StatusCode == http.StatusNotFound { // To make it idempotent, the volume might already have been // deleted, so a 404 is ok. @@ -392,6 +397,14 @@ func (d *Driver) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequest) }).Warn("assuming volume is already deleted") return &csi.DeleteVolumeResponse{}, nil } + + // Check if the error message indicates snapshots exist + if strings.Contains(err.Error(), "Snapshots exist") || + strings.Contains(err.Error(), "snapshot") { + ll.Warn("volume has snapshots, cannot delete yet") + return nil, status.Error(codes.FailedPrecondition, + "volume has existing snapshots that must be deleted first") + } } return nil, err } diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index 6b1aed0c..f1bbaee1 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -233,6 +233,114 @@ func TestPod_Single_SSD_Volume_Snapshot(t *testing.T) { waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) } +func TestPod_Create_Volume_From_Snapshot(t *testing.T) { + podDescriptor := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-pod-ssd-pvc-original", + SizeGB: 5, + StorageClass: "cloudscale-volume-ssd", + }, + }, + } + + // submit the pod and the pvc + pod := makeKubernetesPod(t, podDescriptor) + pvcs := makeKubernetesPVCs(t, podDescriptor) + assert.Equal(t, 1, len(pvcs)) + + // wait for the pod to be running and verify that the pvc is bound + waitForPod(t, client, pod.Name) + pvc := getPVC(t, client, pvcs[0].Name) + assert.Equal(t, v1.ClaimBound, pvc.Status.Phase) + + // load the volume from the cloudscale.ch api and verify that it + // has the requested size and volume type + originalVolume := getCloudscaleVolume(t, pvc.Spec.VolumeName) + assert.Equal(t, 5, originalVolume.SizeGB) + assert.Equal(t, "ssd", originalVolume.Type) + + // verify that our disk is not luks-encrypted, formatted with ext4 and 5 GB big + disk, err := getVolumeInfo(t, pod, pvc.Spec.VolumeName) + assert.NoError(t, err) + assert.Equal(t, "", disk.Luks) + assert.Equal(t, "Filesystem", disk.PVCVolumeMode) + assert.Equal(t, "ext4", disk.Filesystem) + assert.Equal(t, 5*driver.GB, disk.DeviceSize) + assert.Equal(t, 5*driver.GB, disk.FilesystemSize) + + // store the original filesystem UUID to verify it's preserved after restore + originalFilesystemUUID := disk.FilesystemUUID + + // create a snapshot of the volume + snapshotName := pseudoUuid() + snapshot := makeKubernetesVolumeSnapshot(t, snapshotName, pvc.Name) + + // wait for the snapshot to be ready + waitForVolumeSnapshot(t, client, snapshot.Name) + snapshot = getVolumeSnapshot(t, client, snapshot.Name) + assert.NotNil(t, snapshot.Status) + assert.NotNil(t, snapshot.Status.BoundVolumeSnapshotContentName) + assert.True(t, *snapshot.Status.ReadyToUse) + + // verify the snapshot exists in cloudscale.ch API + snapshotContent := getVolumeSnapshotContent(t, *snapshot.Status.BoundVolumeSnapshotContentName) + assert.NotNil(t, snapshotContent.Status) + assert.NotNil(t, snapshotContent.Status.SnapshotHandle) + + cloudscaleSnapshot := getCloudscaleVolumeSnapshot(t, *snapshotContent.Status.SnapshotHandle) + assert.NotNil(t, cloudscaleSnapshot) + assert.Equal(t, *snapshotContent.Status.SnapshotHandle, cloudscaleSnapshot.UUID) + assert.Equal(t, "available", cloudscaleSnapshot.Status) + assert.Equal(t, 5, cloudscaleSnapshot.SizeGB) + + // create a new pod with a pvc restored from the snapshot + restoredPodDescriptor := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-pod-ssd-pvc-restored", + SizeGB: 5, + StorageClass: "cloudscale-volume-ssd", + }, + }, + } + + restoredPod := makeKubernetesPod(t, restoredPodDescriptor) + restoredPVCs := makeKubernetesPVCsFromSnapshot(t, restoredPodDescriptor, snapshot.Name) + assert.Equal(t, 1, len(restoredPVCs)) + + // wait for the restored pod to be running and verify that the pvc is bound + waitForPod(t, client, restoredPod.Name) + restoredPVC := getPVC(t, client, restoredPVCs[0].Name) + assert.Equal(t, v1.ClaimBound, restoredPVC.Status.Phase) + + // load the restored volume from the cloudscale.ch api and verify that it + // has the requested size and volume type + restoredVolume := getCloudscaleVolume(t, restoredPVC.Spec.VolumeName) + assert.Equal(t, 5, restoredVolume.SizeGB) + assert.Equal(t, "ssd", restoredVolume.Type) + + // verify that the restored disk has the same properties as the original + restoredDisk, err := getVolumeInfo(t, restoredPod, restoredPVC.Spec.VolumeName) + assert.NoError(t, err) + assert.Equal(t, "", restoredDisk.Luks) + assert.Equal(t, "Filesystem", restoredDisk.PVCVolumeMode) + assert.Equal(t, "ext4", restoredDisk.Filesystem) + assert.Equal(t, 5*driver.GB, restoredDisk.DeviceSize) + assert.Equal(t, 5*driver.GB, restoredDisk.FilesystemSize) + + // verify that the filesystem UUID is preserved (data was restored, not recreated) + assert.Equal(t, originalFilesystemUUID, restoredDisk.FilesystemUUID) + + // finally cleanup the restored pod and pvc + cleanup(t, restoredPodDescriptor) + waitCloudscaleVolumeDeleted(t, restoredPVC.Spec.VolumeName) +} + func TestPod_Single_SSD_Raw_Volume(t *testing.T) { podDescriptor := TestPodDescriptor{ Kind: "Pod", @@ -1716,6 +1824,52 @@ func getVolumeSnapshotContent(t *testing.T, contentName string) *snapshotv1.Volu return &content } +// creates kubernetes pvcs from the given TestPodDescriptor, restoring from a snapshot +func makeKubernetesPVCsFromSnapshot(t *testing.T, pod TestPodDescriptor, snapshotName string) []*v1.PersistentVolumeClaim { + pvcs := make([]*v1.PersistentVolumeClaim, 0) + + for _, volume := range pod.Volumes { + volMode := v1.PersistentVolumeFilesystem + if volume.Block { + volMode = v1.PersistentVolumeBlock + } + + apiGroup := "snapshot.storage.k8s.io" + pvcs = append(pvcs, &v1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: volume.ClaimName, + }, + Spec: v1.PersistentVolumeClaimSpec{ + VolumeMode: &volMode, + AccessModes: []v1.PersistentVolumeAccessMode{ + v1.ReadWriteOnce, + }, + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceStorage: resource.MustParse(fmt.Sprintf("%vGi", volume.SizeGB)), + }, + }, + StorageClassName: strPtr(volume.StorageClass), + DataSource: &v1.TypedLocalObjectReference{ + APIGroup: &apiGroup, + Kind: "VolumeSnapshot", + Name: snapshotName, + }, + }, + }) + } + + t.Log("Creating pvc from snapshot") + for _, pvc := range pvcs { + _, err := client.CoreV1().PersistentVolumeClaims(namespace).Create(context.Background(), pvc, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + } + + return pvcs +} + // getDynamicSnapshotClient returns a dynamic client for working with VolumeSnapshots func getDynamicSnapshotClient(t *testing.T) dynamic.Interface { dynamicClient, err := dynamic.NewForConfig(config) From eaa650c98f42a139dacbd11daf10ede29ce95ada Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 22 Jan 2026 18:15:29 +0100 Subject: [PATCH 07/63] improve error handling in CreateSnapshot if snapshot does not exist --- driver/controller.go | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 8f664eb5..4bd17bff 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -678,7 +678,14 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ ll.Info("find existing volume snapshots with same name") snapshots, err := d.cloudscaleClient.VolumeSnapshots.List(ctx, cloudscale.WithNameFilter(req.Name)) if err != nil { - return nil, status.Error(codes.Internal, err.Error()) + errorResponse, ok := err.(*cloudscale.ErrorResponse) + if ok { + ll.WithFields(logrus.Fields{ + "status_code": errorResponse.StatusCode, + "error": err, + }).Warn("cloudscale API returned error during snapshot list") + } + return nil, status.Errorf(codes.Internal, "failed to list snapshots: %v", err) } for _, snapshot := range snapshots { @@ -710,13 +717,24 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ volumeSnapshotCreateRequest := &cloudscale.VolumeSnapshotCreateRequest{ Name: req.Name, SourceVolume: req.SourceVolumeId, - // todo: tags? + // todo: Tags are not currently supported in snapshot creation } ll.WithField("volume_snapshot_create_request", volumeSnapshotCreateRequest).Info("creating volume snapshot") snapshot, err := d.cloudscaleClient.VolumeSnapshots.Create(ctx, volumeSnapshotCreateRequest) if err != nil { - return nil, status.Error(codes.Internal, err.Error()) + errorResponse, ok := err.(*cloudscale.ErrorResponse) + if ok { + ll.WithFields(logrus.Fields{ + "status_code": errorResponse.StatusCode, + "error": err, + }).Warn("cloudscale API returned error during snapshot creation") + + if errorResponse.StatusCode == http.StatusNotFound { + return nil, status.Errorf(codes.NotFound, "source volume %s not found: %v", req.SourceVolumeId, err) + } + } + return nil, status.Errorf(codes.Internal, "failed to create snapshot: %v", err) } creationTime := timestamppb.Now() @@ -752,7 +770,9 @@ func (d *Driver) DeleteSnapshot(ctx context.Context, req *csi.DeleteSnapshotRequ }) ll.Info("delete snapshot called") - // todo: think through long running delete jobs + // Note: Snapshot deletion is asynchronous via the cloudscale API. + // The HTTP request returns success immediately, but the snapshot enters "deleting" state. + // Cloudscale handles the deletion asynchronously. The operation is idempotent. err := d.cloudscaleClient.VolumeSnapshots.Delete(ctx, req.SnapshotId) if err != nil { errorResponse, ok := err.(*cloudscale.ErrorResponse) From a70fa501d572187381d71f356ae9d5504e75f0b0 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 22 Jan 2026 18:19:58 +0100 Subject: [PATCH 08/63] improve error message formating Co-authored-by: Michael Weibel <307427+mweibel@users.noreply.github.com> --- driver/controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/driver/controller.go b/driver/controller.go index 4bd17bff..2380c6af 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -228,7 +228,7 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo return nil, status.Errorf(codes.NotFound, "source snapshot %s not found", sourceSnapshotID) } } - return nil, status.Errorf(codes.Internal, "failed to get source snapshot: %v", err) + return nil, status.Errorf(codes.Internal, "failed to get source snapshot: %w", err) } ll = ll.WithFields(logrus.Fields{ From bb3f5583e95f317c784d67c17ceb8ff173935cbd Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 22 Jan 2026 18:26:23 +0100 Subject: [PATCH 09/63] fix error formatting --- driver/controller.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/driver/controller.go b/driver/controller.go index 2380c6af..e29c0a28 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -228,7 +228,8 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo return nil, status.Errorf(codes.NotFound, "source snapshot %s not found", sourceSnapshotID) } } - return nil, status.Errorf(codes.Internal, "failed to get source snapshot: %w", err) + wrapped := fmt.Errorf("failed to get source snapshot: %w", err) + return nil, status.Error(codes.Internal, wrapped.Error()) } ll = ll.WithFields(logrus.Fields{ From 68317ca18e376e432049ee72c47b04493b530db4 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 22 Jan 2026 20:45:34 +0100 Subject: [PATCH 10/63] add integration test for luks volume --- test/kubernetes/integration_test.go | 122 ++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index f1bbaee1..ee0a1006 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -341,6 +341,128 @@ func TestPod_Create_Volume_From_Snapshot(t *testing.T) { waitCloudscaleVolumeDeleted(t, restoredPVC.Spec.VolumeName) } +func TestPod_Single_SSD_Luks_Volume_Snapshot(t *testing.T) { + podDescriptor := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-pod-ssd-luks-pvc-original", + SizeGB: 5, + StorageClass: "cloudscale-volume-ssd-luks", + LuksKey: "secret", + }, + }, + } + + // submit the pod and the pvc + pod := makeKubernetesPod(t, podDescriptor) + pvcs := makeKubernetesPVCs(t, podDescriptor) + assert.Equal(t, 1, len(pvcs)) + + // wait for the pod to be running and verify that the pvc is bound + waitForPod(t, client, pod.Name) + pvc := getPVC(t, client, pvcs[0].Name) + assert.Equal(t, v1.ClaimBound, pvc.Status.Phase) + + // load the volume from the cloudscale.ch api and verify that it + // has the requested size and volume type + originalVolume := getCloudscaleVolume(t, pvc.Spec.VolumeName) + assert.Equal(t, 5, originalVolume.SizeGB) + assert.Equal(t, "ssd", originalVolume.Type) + + // verify that our disk is luks-encrypted, formatted with ext4 and 5 GB big + disk, err := getVolumeInfo(t, pod, pvc.Spec.VolumeName) + assert.NoError(t, err) + assert.Equal(t, "ext4", disk.Filesystem) + assert.Equal(t, 5*driver.GB, disk.DeviceSize) + assert.Equal(t, "LUKS1", disk.Luks) + assert.Equal(t, "Filesystem", disk.PVCVolumeMode) + assert.Equal(t, "aes-xts-plain64", disk.Cipher) + assert.Equal(t, 512, disk.Keysize) + assert.Equal(t, 5*driver.GB-luksOverhead, disk.FilesystemSize) + + // store the original filesystem UUID to verify it's preserved after restore + originalFilesystemUUID := disk.FilesystemUUID + + // create a snapshot of the LUKS volume + snapshotName := pseudoUuid() + snapshot := makeKubernetesVolumeSnapshot(t, snapshotName, pvc.Name) + + // wait for the snapshot to be ready + waitForVolumeSnapshot(t, client, snapshot.Name) + snapshot = getVolumeSnapshot(t, client, snapshot.Name) + assert.NotNil(t, snapshot.Status) + assert.NotNil(t, snapshot.Status.BoundVolumeSnapshotContentName) + assert.True(t, *snapshot.Status.ReadyToUse) + + // verify the snapshot exists in cloudscale.ch API + snapshotContent := getVolumeSnapshotContent(t, *snapshot.Status.BoundVolumeSnapshotContentName) + assert.NotNil(t, snapshotContent.Status) + assert.NotNil(t, snapshotContent.Status.SnapshotHandle) + + cloudscaleSnapshot := getCloudscaleVolumeSnapshot(t, *snapshotContent.Status.SnapshotHandle) + assert.NotNil(t, cloudscaleSnapshot) + assert.Equal(t, *snapshotContent.Status.SnapshotHandle, cloudscaleSnapshot.UUID) + assert.Equal(t, "available", cloudscaleSnapshot.Status) + assert.Equal(t, 5, cloudscaleSnapshot.SizeGB) + + // create a new pod with a pvc restored from the snapshot with LUKS parameters + restoredPodDescriptor := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-pod-ssd-luks-pvc-restored", + SizeGB: 5, + StorageClass: "cloudscale-volume-ssd-luks", + LuksKey: "secret", + }, + }, + } + + restoredPod := makeKubernetesPod(t, restoredPodDescriptor) + restoredPVCs := makeKubernetesPVCsFromSnapshot(t, restoredPodDescriptor, snapshot.Name) + assert.Equal(t, 1, len(restoredPVCs)) + + // wait for the restored pod to be running and verify that the pvc is bound + waitForPod(t, client, restoredPod.Name) + restoredPVC := getPVC(t, client, restoredPVCs[0].Name) + assert.Equal(t, v1.ClaimBound, restoredPVC.Status.Phase) + + // load the restored volume from the cloudscale.ch api and verify that it + // has the requested size and volume type + restoredVolume := getCloudscaleVolume(t, restoredPVC.Spec.VolumeName) + assert.Equal(t, 5, restoredVolume.SizeGB) + assert.Equal(t, "ssd", restoredVolume.Type) + + // verify that the restored disk has LUKS encryption preserved + restoredDisk, err := getVolumeInfo(t, restoredPod, restoredPVC.Spec.VolumeName) + assert.NoError(t, err) + assert.Equal(t, "LUKS1", restoredDisk.Luks) + assert.Equal(t, "Filesystem", restoredDisk.PVCVolumeMode) + assert.Equal(t, "ext4", restoredDisk.Filesystem) + assert.Equal(t, 5*driver.GB, restoredDisk.DeviceSize) + assert.Equal(t, 5*driver.GB-luksOverhead, restoredDisk.FilesystemSize) + assert.Equal(t, "aes-xts-plain64", restoredDisk.Cipher) + assert.Equal(t, 512, restoredDisk.Keysize) + + // verify that the filesystem UUID is preserved (data was restored, not recreated) + assert.Equal(t, originalFilesystemUUID, restoredDisk.FilesystemUUID) + + // delete the snapshot before deleting the volumes + deleteKubernetesVolumeSnapshot(t, snapshot.Name) + waitCloudscaleVolumeSnapshotDeleted(t, *snapshotContent.Status.SnapshotHandle) + + // finally cleanup the restored pod and pvc + cleanup(t, restoredPodDescriptor) + waitCloudscaleVolumeDeleted(t, restoredPVC.Spec.VolumeName) + + // cleanup the original pod and pvc + cleanup(t, podDescriptor) + waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) +} + func TestPod_Single_SSD_Raw_Volume(t *testing.T) { podDescriptor := TestPodDescriptor{ Kind: "Pod", From 000c1d9e61bee464a968ce7ff7de0cbc52deca76 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 22 Jan 2026 21:15:29 +0100 Subject: [PATCH 11/63] add integration test for PVCs with wrong size --- test/kubernetes/integration_test.go | 142 ++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index ee0a1006..c0d5a570 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -463,6 +463,148 @@ func TestPod_Single_SSD_Luks_Volume_Snapshot(t *testing.T) { waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) } + +func TestPod_Snapshot_Size_Validation(t *testing.T) { + // Test that snapshot size validation works correctly + podDescriptor := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-pod-snapshot-size-pvc", + SizeGB: 5, + StorageClass: "cloudscale-volume-ssd", + }, + }, + } + + // Create volume + pod := makeKubernetesPod(t, podDescriptor) + pvcs := makeKubernetesPVCs(t, podDescriptor) + waitForPod(t, client, pod.Name) + pvc := getPVC(t, client, pvcs[0].Name) + assert.Equal(t, v1.ClaimBound, pvc.Status.Phase) + + volume := getCloudscaleVolume(t, pvc.Spec.VolumeName) + assert.Equal(t, 5, volume.SizeGB) + + // Create snapshot + snapshotName := pseudoUuid() + snapshot := makeKubernetesVolumeSnapshot(t, snapshotName, pvc.Name) + waitForVolumeSnapshot(t, client, snapshot.Name) + snapshot = getVolumeSnapshot(t, client, snapshot.Name) + assert.True(t, *snapshot.Status.ReadyToUse) + + snapshotContent := getVolumeSnapshotContent(t, *snapshot.Status.BoundVolumeSnapshotContentName) + snapshotHandle := *snapshotContent.Status.SnapshotHandle + + cloudscaleSnapshot := getCloudscaleVolumeSnapshot(t, snapshotHandle) + assert.Equal(t, 5, cloudscaleSnapshot.SizeGB) + + + // Attempt to restore with smaller size (should fail) + // Create PVC directly without pod (since it won't bind) + smallerPVCName := "csi-pod-snapshot-size-pvc-smaller" + volMode := v1.PersistentVolumeFilesystem + apiGroup := "snapshot.storage.k8s.io" + smallerPVC := &v1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: smallerPVCName, + }, + Spec: v1.PersistentVolumeClaimSpec{ + VolumeMode: &volMode, + AccessModes: []v1.PersistentVolumeAccessMode{ + v1.ReadWriteOnce, + }, + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceStorage: resource.MustParse("3Gi"), // Smaller than snapshot size (5GB) + }, + }, + StorageClassName: strPtr("cloudscale-volume-ssd"), + DataSource: &v1.TypedLocalObjectReference{ + APIGroup: &apiGroup, + Kind: "VolumeSnapshot", + Name: snapshot.Name, + }, + }, + } + + t.Log("Creating PVC from snapshot with smaller size (should fail)") + _, err := client.CoreV1().PersistentVolumeClaims(namespace).Create(context.Background(), smallerPVC, metav1.CreateOptions{}) + assert.NoError(t, err) + + // Wait a bit for the PVC to be processed + time.Sleep(10 * time.Second) + + // Check that PVC is not bound (should fail) + smallerPVC = getPVC(t, client, smallerPVCName) + assert.NotEqual(t, v1.ClaimBound, smallerPVC.Status.Phase, "PVC with smaller size should not be bound") + assert.Equal(t, v1.ClaimPending, smallerPVC.Status.Phase, "PVC should be in Pending state due to size validation failure") + + // Verify no volume was created + if smallerPVC.Spec.VolumeName != "" { + t.Logf("Warning: Volume was created despite size validation failure: %s", smallerPVC.Spec.VolumeName) + } + + // Cleanup failed PVC + err = client.CoreV1().PersistentVolumeClaims(namespace).Delete(context.Background(), smallerPVCName, metav1.DeleteOptions{}) + assert.NoError(t, err) + + // Attempt to restore with larger size (should fail) + // Create PVC directly without pod (since it won't bind) + largerPVCName := "csi-pod-snapshot-size-pvc-larger" + largerPVC := &v1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: largerPVCName, + }, + Spec: v1.PersistentVolumeClaimSpec{ + VolumeMode: &volMode, + AccessModes: []v1.PersistentVolumeAccessMode{ + v1.ReadWriteOnce, + }, + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceStorage: resource.MustParse("10Gi"), // Larger than snapshot size (5GB) + }, + }, + StorageClassName: strPtr("cloudscale-volume-ssd"), + DataSource: &v1.TypedLocalObjectReference{ + APIGroup: &apiGroup, + Kind: "VolumeSnapshot", + Name: snapshot.Name, + }, + }, + } + + t.Log("Creating PVC from snapshot with larger size (should fail)") + _, err = client.CoreV1().PersistentVolumeClaims(namespace).Create(context.Background(), largerPVC, metav1.CreateOptions{}) + assert.NoError(t, err) + + // Wait a bit for the PVC to be processed + time.Sleep(10 * time.Second) + + // Check that PVC is not bound (should fail) + largerPVC = getPVC(t, client, largerPVCName) + assert.NotEqual(t, v1.ClaimBound, largerPVC.Status.Phase, "PVC with larger size should not be bound") + assert.Equal(t, v1.ClaimPending, largerPVC.Status.Phase, "PVC should be in Pending state due to size validation failure") + + // Verify no volume was created + if largerPVC.Spec.VolumeName != "" { + t.Logf("Warning: Volume was created despite size validation failure: %s", largerPVC.Spec.VolumeName) + } + + // Cleanup failed PVC + err = client.CoreV1().PersistentVolumeClaims(namespace).Delete(context.Background(), largerPVCName, metav1.DeleteOptions{}) + assert.NoError(t, err) + + // Cleanup original resources + deleteKubernetesVolumeSnapshot(t, snapshot.Name) + waitCloudscaleVolumeSnapshotDeleted(t, snapshotHandle) + cleanup(t, podDescriptor) + waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) +} + func TestPod_Single_SSD_Raw_Volume(t *testing.T) { podDescriptor := TestPodDescriptor{ Kind: "Pod", From 576bd5ae15d7ed4427700f49c2e0865fb07989c2 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 22 Jan 2026 22:45:32 +0100 Subject: [PATCH 12/63] add examples, including luks --- .../luks-volumesnapshot.yaml | 11 ++++ .../restored-luks-pod.yaml | 17 +++++ .../restored-luks-pvc.yaml | 21 +++++++ .../restored-luks-secret.yaml | 10 +++ .../kubernetes/volume-snapshots/README.md | 62 +++++++++++++++++++ .../volume-snapshots/original-pod.yaml | 17 +++++ .../volume-snapshots/original-pvc.yaml | 12 ++++ .../volume-snapshots/restored-pod.yaml | 17 +++++ .../volume-snapshots/restored-pvc.yaml | 17 +++++ .../volume-snapshots/volumesnapshot.yaml | 10 +++ .../volume-snapshots/volumesnapshotclass.yaml | 9 +++ 11 files changed, 203 insertions(+) create mode 100644 examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml create mode 100644 examples/kubernetes/luks-encrypted-volumes/restored-luks-pod.yaml create mode 100644 examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml create mode 100644 examples/kubernetes/luks-encrypted-volumes/restored-luks-secret.yaml create mode 100644 examples/kubernetes/volume-snapshots/README.md create mode 100644 examples/kubernetes/volume-snapshots/original-pod.yaml create mode 100644 examples/kubernetes/volume-snapshots/original-pvc.yaml create mode 100644 examples/kubernetes/volume-snapshots/restored-pod.yaml create mode 100644 examples/kubernetes/volume-snapshots/restored-pvc.yaml create mode 100644 examples/kubernetes/volume-snapshots/volumesnapshot.yaml create mode 100644 examples/kubernetes/volume-snapshots/volumesnapshotclass.yaml diff --git a/examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml b/examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml new file mode 100644 index 00000000..490faa55 --- /dev/null +++ b/examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml @@ -0,0 +1,11 @@ +# VolumeSnapshot creates a snapshot of a LUKS-encrypted volume +# Make sure the VolumeSnapshotClass is created first (see ../volume-snapshots/volumesnapshotclass.yaml) +# The snapshot preserves the LUKS encryption state +apiVersion: snapshot.storage.k8s.io/v1 +kind: VolumeSnapshot +metadata: + name: my-luks-snapshot +spec: + volumeSnapshotClassName: cloudscale-snapshots + source: + persistentVolumeClaimName: csi-pod-pvc-luks diff --git a/examples/kubernetes/luks-encrypted-volumes/restored-luks-pod.yaml b/examples/kubernetes/luks-encrypted-volumes/restored-luks-pod.yaml new file mode 100644 index 00000000..62bdda2a --- /dev/null +++ b/examples/kubernetes/luks-encrypted-volumes/restored-luks-pod.yaml @@ -0,0 +1,17 @@ +# Pod using the restored LUKS volume (optional, for testing) +kind: Pod +apiVersion: v1 +metadata: + name: my-restored-luks-app +spec: + containers: + - name: my-frontend + image: busybox + volumeMounts: + - mountPath: "/data" + name: my-cloudscale-volume + command: [ "sleep", "1000000" ] + volumes: + - name: my-cloudscale-volume + persistentVolumeClaim: + claimName: my-restored-luks-volume diff --git a/examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml b/examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml new file mode 100644 index 00000000..1cc586c1 --- /dev/null +++ b/examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml @@ -0,0 +1,21 @@ +# PersistentVolumeClaim restored from a LUKS snapshot +# IMPORTANT: When restoring from a LUKS snapshot, you MUST: +# 1. Use a LUKS storage class (cloudscale-volume-ssd-luks or cloudscale-volume-bulk-luks) +# 2. Provide a LUKS secret with the pattern: ${pvc-name}-luks-key +# 3. Use the SAME LUKS key as the original volume +# 4. Match the snapshot size exactly (1Gi in this example) +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: my-restored-luks-volume +spec: + accessModes: + - ReadWriteOnce + storageClassName: cloudscale-volume-ssd-luks + resources: + requests: + storage: 1Gi + dataSource: + name: my-luks-snapshot + kind: VolumeSnapshot + apiGroup: snapshot.storage.k8s.io diff --git a/examples/kubernetes/luks-encrypted-volumes/restored-luks-secret.yaml b/examples/kubernetes/luks-encrypted-volumes/restored-luks-secret.yaml new file mode 100644 index 00000000..4e2ee1b7 --- /dev/null +++ b/examples/kubernetes/luks-encrypted-volumes/restored-luks-secret.yaml @@ -0,0 +1,10 @@ +# Secret containing the LUKS key for the restored volume +# IMPORTANT: This must use the same LUKS key as the original volume +# The secret name must follow the pattern: ${pvc-name}-luks-key +# In this case: my-restored-luks-volume-luks-key +apiVersion: v1 +kind: Secret +metadata: + name: my-restored-luks-volume-luks-key +stringData: + luksKey: "hDEKFgEZgmpuppShPG7HailSFBsy8MzlvlhALvqk0+2jTrcKrFmtttoF5IGlLVoLt/jpaWnk/kcl7JxnsZ3xQjEcYumv4WkwOv77x+c2C/kyyldTNRaCaVHG9fW9n6oicoWzsyUWcmu0d+JOorGZ792lsS9Q5gXlCg5BD2x1MoVVr8hTQArFfUX6NuHF1o0v/EGHU0A5O5wiNnqpdDjf9r56rPt0H290Nr6Y5Ijb5RTIoJFT5ww5XocrvLlR/GiXRYgzeISfbfyIr8FpfRKmjPTZdLBSXPMMdHJNcPIlRG+DfnBaTKkIFwiWXjxXZss71IKibEM7Qfjwka0KFyufwA==" diff --git a/examples/kubernetes/volume-snapshots/README.md b/examples/kubernetes/volume-snapshots/README.md new file mode 100644 index 00000000..302729ad --- /dev/null +++ b/examples/kubernetes/volume-snapshots/README.md @@ -0,0 +1,62 @@ +# Volume Snapshots Example + +This example demonstrates how to create and restore volumes from snapshots using the cloudscale.ch CSI driver. + +## Prerequisites + +Before using snapshots, ensure your cluster has the VolumeSnapshot CRDs and snapshot controller installed. +See the [main README](../../README.md#prerequisites-for-snapshot-support) for installation instructions. + +## Workflow + +1. **Create VolumeSnapshotClass** (one-time setup, required before creating snapshots): + ```bash + kubectl apply -f volumesnapshotclass.yaml + ``` + + **Note:** VolumeSnapshotClass is currently not deployed automatically with the driver. You must create it manually. + This may change in future releases where it will be deployed automatically (similar to StorageClass). + +2. **Create original volume and pod** (optional, for testing): + ```bash + kubectl apply -f original-pvc.yaml + kubectl apply -f original-pod.yaml + ``` + +3. **Create snapshot**: + ```bash + kubectl apply -f volumesnapshot.yaml + ``` + +4. **Create restored volume and pod**: + ```bash + kubectl apply -f restored-pvc.yaml + kubectl apply -f restored-pod.yaml + ``` + +## Verification + +Check snapshot status: +```bash +kubectl get volumesnapshot +kubectl describe volumesnapshot/my-snapshot +``` + +Check restored volume: +```bash +kubectl get pvc +kubectl get pod +``` + +**LUKS volumes**: For LUKS-encrypted volumes, see the [LUKS snapshot example](../luks-encrypted-volumes/). + +## Cleanup + +```bash +kubectl delete -f restored-pod.yaml +kubectl delete -f restored-pvc.yaml +kubectl delete -f volumesnapshot.yaml +kubectl delete -f original-pod.yaml +kubectl delete -f original-pvc.yaml +# Note: VolumeSnapshotClass is typically not deleted as it's a cluster resource +``` diff --git a/examples/kubernetes/volume-snapshots/original-pod.yaml b/examples/kubernetes/volume-snapshots/original-pod.yaml new file mode 100644 index 00000000..b0b48808 --- /dev/null +++ b/examples/kubernetes/volume-snapshots/original-pod.yaml @@ -0,0 +1,17 @@ +# Pod using the original volume (optional, for testing) +kind: Pod +apiVersion: v1 +metadata: + name: my-app +spec: + containers: + - name: my-frontend + image: busybox + volumeMounts: + - mountPath: "/data" + name: my-cloudscale-volume + command: [ "sleep", "1000000" ] + volumes: + - name: my-cloudscale-volume + persistentVolumeClaim: + claimName: my-volume diff --git a/examples/kubernetes/volume-snapshots/original-pvc.yaml b/examples/kubernetes/volume-snapshots/original-pvc.yaml new file mode 100644 index 00000000..24a198e5 --- /dev/null +++ b/examples/kubernetes/volume-snapshots/original-pvc.yaml @@ -0,0 +1,12 @@ +# Original PersistentVolumeClaim that will be snapshotted +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: my-volume +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + storageClassName: cloudscale-volume-ssd diff --git a/examples/kubernetes/volume-snapshots/restored-pod.yaml b/examples/kubernetes/volume-snapshots/restored-pod.yaml new file mode 100644 index 00000000..4bdd38aa --- /dev/null +++ b/examples/kubernetes/volume-snapshots/restored-pod.yaml @@ -0,0 +1,17 @@ +# Pod using the restored volume (optional, for testing) +kind: Pod +apiVersion: v1 +metadata: + name: my-restored-app +spec: + containers: + - name: my-frontend + image: busybox + volumeMounts: + - mountPath: "/data" + name: my-cloudscale-volume + command: [ "sleep", "1000000" ] + volumes: + - name: my-cloudscale-volume + persistentVolumeClaim: + claimName: my-restored-volume diff --git a/examples/kubernetes/volume-snapshots/restored-pvc.yaml b/examples/kubernetes/volume-snapshots/restored-pvc.yaml new file mode 100644 index 00000000..5250f894 --- /dev/null +++ b/examples/kubernetes/volume-snapshots/restored-pvc.yaml @@ -0,0 +1,17 @@ +# PersistentVolumeClaim restored from the snapshot +# Note: The restored volume must have the same size as the snapshot (5Gi in this example) +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: my-restored-volume +spec: + accessModes: + - ReadWriteOnce + storageClassName: cloudscale-volume-ssd + resources: + requests: + storage: 5Gi + dataSource: + name: my-snapshot + kind: VolumeSnapshot + apiGroup: snapshot.storage.k8s.io diff --git a/examples/kubernetes/volume-snapshots/volumesnapshot.yaml b/examples/kubernetes/volume-snapshots/volumesnapshot.yaml new file mode 100644 index 00000000..dade8aca --- /dev/null +++ b/examples/kubernetes/volume-snapshots/volumesnapshot.yaml @@ -0,0 +1,10 @@ +# VolumeSnapshot creates a snapshot of the original volume +# Make sure the VolumeSnapshotClass is created first (volumesnapshotclass.yaml) +apiVersion: snapshot.storage.k8s.io/v1 +kind: VolumeSnapshot +metadata: + name: my-snapshot +spec: + volumeSnapshotClassName: cloudscale-snapshots + source: + persistentVolumeClaimName: my-volume diff --git a/examples/kubernetes/volume-snapshots/volumesnapshotclass.yaml b/examples/kubernetes/volume-snapshots/volumesnapshotclass.yaml new file mode 100644 index 00000000..f05b880c --- /dev/null +++ b/examples/kubernetes/volume-snapshots/volumesnapshotclass.yaml @@ -0,0 +1,9 @@ +# VolumeSnapshotClass defines how snapshots should be created for the cloudscale.ch CSI driver. +# This is a cluster-level resource that needs to be created once before using snapshots. +# Note: This may be deployed automatically with the driver in future releases. +apiVersion: snapshot.storage.k8s.io/v1 +kind: VolumeSnapshotClass +metadata: + name: cloudscale-snapshots +driver: csi.cloudscale.ch +deletionPolicy: Delete From ec91d70ab8b07300d422b38859ab7a7f7ccc196e Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 22 Jan 2026 22:53:45 +0100 Subject: [PATCH 13/63] remove volume group snapshot permissions --- charts/csi-cloudscale/templates/rbac.yaml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/charts/csi-cloudscale/templates/rbac.yaml b/charts/csi-cloudscale/templates/rbac.yaml index 79059b70..e0ecec16 100644 --- a/charts/csi-cloudscale/templates/rbac.yaml +++ b/charts/csi-cloudscale/templates/rbac.yaml @@ -28,15 +28,6 @@ rules: - apiGroups: [ "snapshot.storage.k8s.io" ] resources: [ "volumesnapshotclasses" ] verbs: [ "get", "list", "watch" ] - - apiGroups: [ "groupsnapshot.storage.k8s.io" ] # todo: are we sure about this? snapshot groups are not supported - resources: [ "volumegroupsnapshotclasses" ] - verbs: [ "get", "list", "watch" ] - - apiGroups: [ "groupsnapshot.storage.k8s.io" ] - resources: [ "volumegroupsnapshotcontents" ] - verbs: [ "get", "list", "watch", "update", "patch" ] - - apiGroups: [ "groupsnapshot.storage.k8s.io" ] - resources: [ "volumegroupsnapshotcontents/status" ] - verbs: [ "update", "patch" ] - apiGroups: [ "coordination.k8s.io" ] resources: [ "leases" ] verbs: [ "get", "list", "watch", "create", "update", "patch", "delete" ] From 6180be83160f85b99bc818efb181db4cbcf0acc1 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 26 Jan 2026 15:02:25 +0100 Subject: [PATCH 14/63] add propper permission role and bindings --- charts/csi-cloudscale/templates/rbac.yaml | 46 +++++++++++++++++------ 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/charts/csi-cloudscale/templates/rbac.yaml b/charts/csi-cloudscale/templates/rbac.yaml index e0ecec16..aa6cb9a0 100644 --- a/charts/csi-cloudscale/templates/rbac.yaml +++ b/charts/csi-cloudscale/templates/rbac.yaml @@ -16,18 +16,6 @@ rules: - apiGroups: [""] resources: ["events"] verbs: ["list", "watch", "create", "update", "patch"] - - apiGroups: ["snapshot.storage.k8s.io"] - resources: ["volumesnapshots"] - verbs: [ "get", "list", "watch", "update" ] - - apiGroups: ["snapshot.storage.k8s.io"] - resources: ["volumesnapshotcontents"] - verbs: [ "get", "list", "watch", "update", "patch" ] - - apiGroups: [ "snapshot.storage.k8s.io" ] - resources: [ "volumesnapshotcontents/status" ] - verbs: [ "update", "patch" ] - - apiGroups: [ "snapshot.storage.k8s.io" ] - resources: [ "volumesnapshotclasses" ] - verbs: [ "get", "list", "watch" ] - apiGroups: [ "coordination.k8s.io" ] resources: [ "leases" ] verbs: [ "get", "list", "watch", "create", "update", "patch", "delete" ] @@ -61,6 +49,27 @@ rules: --- kind: ClusterRole apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ include "csi-cloudscale.driver-name" . }}-snapshotter-role +rules: + - apiGroups: ["snapshot.storage.k8s.io"] + resources: ["volumesnapshots"] + verbs: [ "get", "list", "watch", "update" ] + - apiGroups: ["snapshot.storage.k8s.io"] + resources: ["volumesnapshotcontents"] + verbs: [ "get", "list", "watch", "update", "patch" ] + - apiGroups: [ "snapshot.storage.k8s.io" ] + resources: [ "volumesnapshotcontents/status" ] + verbs: [ "update", "patch" ] + - apiGroups: [ "snapshot.storage.k8s.io" ] + resources: [ "volumesnapshotclasses" ] + verbs: [ "get", "list", "watch" ] + - apiGroups: [""] + resources: ["events"] + verbs: ["list", "watch", "create", "update", "patch"] +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 metadata: name: {{ include "csi-cloudscale.driver-name" . }}-resizer-role rules: @@ -108,6 +117,19 @@ roleRef: --- kind: ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ include "csi-cloudscale.driver-name" . }}-snapshotter-binding +subjects: + - kind: ServiceAccount + name: {{ include "csi-cloudscale.controller-service-account-name" . }} + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: {{ include "csi-cloudscale.driver-name" . }}-snapshotter-role + apiGroup: rbac.authorization.k8s.io +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 metadata: name: {{ include "csi-cloudscale.driver-name" . }}-resizer-binding subjects: From 1186d233638e04f68bf8ab1d292b6e26b915b87d Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 26 Jan 2026 15:14:38 +0100 Subject: [PATCH 15/63] explain reason for DynamicSnapshotClient --- test/kubernetes/integration_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index c0d5a570..622845ce 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -2134,7 +2134,12 @@ func makeKubernetesPVCsFromSnapshot(t *testing.T, pod TestPodDescriptor, snapsho return pvcs } -// getDynamicSnapshotClient returns a dynamic client for working with VolumeSnapshots +// getDynamicSnapshotClient returns a dynamic client for working with VolumeSnapshots. +// VolumeSnapshot is a Custom Resource Definition (CRD), not a built-in Kubernetes resource. +// Unlike built-in resources (Pods, PVCs, etc.) which have typed clientsets, CRDs require +// a dynamic client that works with unstructured objects. The external-snapshotter client +// package provides the types (snapshotv1.VolumeSnapshot) but not a full typed clientset, +// so we use the dynamic client with GroupVersionResource to interact with the API. func getDynamicSnapshotClient(t *testing.T) dynamic.Interface { dynamicClient, err := dynamic.NewForConfig(config) if err != nil { From c3e163c43b3973ae98c190ead3113c6e0dffc904 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 26 Jan 2026 15:41:07 +0100 Subject: [PATCH 16/63] get rid of dynamicClient, use typed clientset from external-snapshotter --- test/kubernetes/integration_test.go | 99 +++++------------------------ 1 file changed, 16 insertions(+), 83 deletions(-) diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index 622845ce..0f2eb7d3 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -22,20 +22,17 @@ import ( "github.com/cloudscale-ch/csi-cloudscale/driver" "github.com/stretchr/testify/assert" "golang.org/x/oauth2" - "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" snapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + snapshotclientset "github.com/kubernetes-csi/external-snapshotter/client/v6/clientset/versioned" appsv1 "k8s.io/api/apps/v1" "k8s.io/api/core/v1" kubeerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/selection" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" @@ -81,6 +78,7 @@ type DiskInfo struct { var ( client kubernetes.Interface + snapshotClient snapshotclientset.Interface config *rest.Config cloudscaleClient *cloudscale.Client ) @@ -463,7 +461,6 @@ func TestPod_Single_SSD_Luks_Volume_Snapshot(t *testing.T) { waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) } - func TestPod_Snapshot_Size_Validation(t *testing.T) { // Test that snapshot size validation works correctly podDescriptor := TestPodDescriptor{ @@ -501,7 +498,6 @@ func TestPod_Snapshot_Size_Validation(t *testing.T) { cloudscaleSnapshot := getCloudscaleVolumeSnapshot(t, snapshotHandle) assert.Equal(t, 5, cloudscaleSnapshot.SizeGB) - // Attempt to restore with smaller size (should fail) // Create PVC directly without pod (since it won't bind) smallerPVCName := "csi-pod-snapshot-size-pvc-smaller" @@ -1170,6 +1166,12 @@ func setup() error { return err } + // create the snapshot clientset for working with VolumeSnapshot CRDs + snapshotClient, err = snapshotclientset.NewForConfig(config) + if err != nil { + return err + } + // create test namespace _, err = client.CoreV1().Namespaces().Create( context.Background(), @@ -1922,51 +1924,22 @@ func makeKubernetesVolumeSnapshot(t *testing.T, snapshotName string, pvcName str } t.Logf("Creating volume snapshot %v", snapshotName) - snapshotClient := getDynamicSnapshotClient(t) - - obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(snapshot) - if err != nil { - t.Fatal(err) - } - - unstructuredSnapshot := &unstructured.Unstructured{Object: obj} - - gvr := schema.GroupVersionResource{ - Group: "snapshot.storage.k8s.io", - Version: "v1", - Resource: "volumesnapshots", - } - - created, err := snapshotClient.Resource(gvr).Namespace(namespace).Create( + created, err := snapshotClient.SnapshotV1().VolumeSnapshots(namespace).Create( context.Background(), - unstructuredSnapshot, + snapshot, metav1.CreateOptions{}, ) if err != nil { t.Fatal(err) } - var result snapshotv1.VolumeSnapshot - err = runtime.DefaultUnstructuredConverter.FromUnstructured(created.Object, &result) - if err != nil { - t.Fatal(err) - } - - return &result + return created } // deleteKubernetesVolumeSnapshot deletes the VolumeSnapshot with the given name func deleteKubernetesVolumeSnapshot(t *testing.T, snapshotName string) { t.Logf("Deleting volume snapshot %v", snapshotName) - snapshotClient := getDynamicSnapshotClient(t) - - gvr := schema.GroupVersionResource{ - Group: "snapshot.storage.k8s.io", - Version: "v1", - Resource: "volumesnapshots", - } - - err := snapshotClient.Resource(gvr).Namespace(namespace).Delete( + err := snapshotClient.SnapshotV1().VolumeSnapshots(namespace).Delete( context.Background(), snapshotName, metav1.DeleteOptions{}, @@ -2000,26 +1973,13 @@ func waitForVolumeSnapshot(t *testing.T, client kubernetes.Interface, name strin // getVolumeSnapshot retrieves the VolumeSnapshot with the given name func getVolumeSnapshot(t *testing.T, client kubernetes.Interface, name string) *snapshotv1.VolumeSnapshot { - snapshotClient := getDynamicSnapshotClient(t) - - gvr := schema.GroupVersionResource{ - Group: "snapshot.storage.k8s.io", - Version: "v1", - Resource: "volumesnapshots", - } - - unstructuredSnapshot, err := snapshotClient.Resource(gvr).Namespace(namespace).Get( + snapshot, err := snapshotClient.SnapshotV1().VolumeSnapshots(namespace).Get( context.Background(), name, metav1.GetOptions{}, ) assert.NoError(t, err) - - var snapshot snapshotv1.VolumeSnapshot - err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredSnapshot.Object, &snapshot) - assert.NoError(t, err) - - return &snapshot + return snapshot } func getCloudscaleVolumeSnapshot(t *testing.T, snapshotHandle string) *cloudscale.VolumeSnapshot { @@ -2066,26 +2026,13 @@ func waitCloudscaleVolumeSnapshotDeleted(t *testing.T, snapshotHandle string) { // getVolumeSnapshotContent retrieves the VolumeSnapshotContent for a VolumeSnapshot func getVolumeSnapshotContent(t *testing.T, contentName string) *snapshotv1.VolumeSnapshotContent { - snapshotClient := getDynamicSnapshotClient(t) - - gvr := schema.GroupVersionResource{ - Group: "snapshot.storage.k8s.io", - Version: "v1", - Resource: "volumesnapshotcontents", - } - - unstructuredContent, err := snapshotClient.Resource(gvr).Get( + content, err := snapshotClient.SnapshotV1().VolumeSnapshotContents().Get( context.Background(), contentName, metav1.GetOptions{}, ) assert.NoError(t, err) - - var content snapshotv1.VolumeSnapshotContent - err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredContent.Object, &content) - assert.NoError(t, err) - - return &content + return content } // creates kubernetes pvcs from the given TestPodDescriptor, restoring from a snapshot @@ -2133,17 +2080,3 @@ func makeKubernetesPVCsFromSnapshot(t *testing.T, pod TestPodDescriptor, snapsho return pvcs } - -// getDynamicSnapshotClient returns a dynamic client for working with VolumeSnapshots. -// VolumeSnapshot is a Custom Resource Definition (CRD), not a built-in Kubernetes resource. -// Unlike built-in resources (Pods, PVCs, etc.) which have typed clientsets, CRDs require -// a dynamic client that works with unstructured objects. The external-snapshotter client -// package provides the types (snapshotv1.VolumeSnapshot) but not a full typed clientset, -// so we use the dynamic client with GroupVersionResource to interact with the API. -func getDynamicSnapshotClient(t *testing.T) dynamic.Interface { - dynamicClient, err := dynamic.NewForConfig(config) - if err != nil { - t.Fatal(err) - } - return dynamicClient -} From 73a757f90a27acf8b117130751fd2344d5b058d3 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 26 Jan 2026 18:22:55 +0100 Subject: [PATCH 17/63] use errors.As to prevent issues with wraped errors --- driver/controller.go | 29 +++++++++++++++-------------- test/kubernetes/integration_test.go | 6 ++++-- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index e29c0a28..ebc3528c 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -19,6 +19,7 @@ package driver import ( "context" + "errors" "fmt" "net/http" "regexp" @@ -222,8 +223,8 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo // Verify snapshot exists and get its properties, must return NotFound when snapshot does not exist. snapshot, err := d.cloudscaleClient.VolumeSnapshots.Get(ctx, sourceSnapshotID) if err != nil { - errorResponse, ok := err.(*cloudscale.ErrorResponse) - if ok { + var errorResponse *cloudscale.ErrorResponse + if errors.As(err, &errorResponse) { if errorResponse.StatusCode == http.StatusNotFound { return nil, status.Errorf(codes.NotFound, "source snapshot %s not found", sourceSnapshotID) } @@ -382,8 +383,8 @@ func (d *Driver) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequest) err := d.cloudscaleClient.Volumes.Delete(ctx, req.VolumeId) if err != nil { - errorResponse, ok := err.(*cloudscale.ErrorResponse) - if ok { + var errorResponse *cloudscale.ErrorResponse + if errors.As(err, &errorResponse) { ll.WithFields(logrus.Fields{ "status_code": errorResponse.StatusCode, "error": err, @@ -486,8 +487,8 @@ func (d *Driver) ControllerUnpublishVolume(ctx context.Context, req *csi.Control // check if volume exist before trying to detach it volume, err := d.cloudscaleClient.Volumes.Get(ctx, req.VolumeId) if err != nil { - errorResponse, ok := err.(*cloudscale.ErrorResponse) - if ok { + var errorResponse *cloudscale.ErrorResponse + if errors.As(err, &errorResponse) { if errorResponse.StatusCode == http.StatusNotFound { ll.Info("assuming volume is detached because it does not exist") return &csi.ControllerUnpublishVolumeResponse{}, nil @@ -679,8 +680,8 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ ll.Info("find existing volume snapshots with same name") snapshots, err := d.cloudscaleClient.VolumeSnapshots.List(ctx, cloudscale.WithNameFilter(req.Name)) if err != nil { - errorResponse, ok := err.(*cloudscale.ErrorResponse) - if ok { + var errorResponse *cloudscale.ErrorResponse + if errors.As(err, &errorResponse) { ll.WithFields(logrus.Fields{ "status_code": errorResponse.StatusCode, "error": err, @@ -724,8 +725,8 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ ll.WithField("volume_snapshot_create_request", volumeSnapshotCreateRequest).Info("creating volume snapshot") snapshot, err := d.cloudscaleClient.VolumeSnapshots.Create(ctx, volumeSnapshotCreateRequest) if err != nil { - errorResponse, ok := err.(*cloudscale.ErrorResponse) - if ok { + var errorResponse *cloudscale.ErrorResponse + if errors.As(err, &errorResponse) { ll.WithFields(logrus.Fields{ "status_code": errorResponse.StatusCode, "error": err, @@ -776,8 +777,8 @@ func (d *Driver) DeleteSnapshot(ctx context.Context, req *csi.DeleteSnapshotRequ // Cloudscale handles the deletion asynchronously. The operation is idempotent. err := d.cloudscaleClient.VolumeSnapshots.Delete(ctx, req.SnapshotId) if err != nil { - errorResponse, ok := err.(*cloudscale.ErrorResponse) - if ok { + var errorResponse *cloudscale.ErrorResponse + if errors.As(err, &errorResponse) { if errorResponse.StatusCode == http.StatusNotFound { // To make it idempotent, the volume might already have been // deleted, so a 404 is ok. @@ -981,8 +982,8 @@ func validateLuksCapabilities(caps []*csi.VolumeCapability) []string { } func reraiseNotFound(err error, log *logrus.Entry, operation string) error { - errorResponse, ok := err.(*cloudscale.ErrorResponse) - if ok { + var errorResponse *cloudscale.ErrorResponse + if errors.As(err, &errorResponse) { lt := log.WithFields(logrus.Fields{ "error": err, "errorResponse": errorResponse, diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index 0f2eb7d3..0af09f2c 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -1645,7 +1645,8 @@ func waitCloudscaleVolumeDeleted(t *testing.T, volumeName string) { return } if err != nil { - if cloudscaleErr, ok := err.(*cloudscale.ErrorResponse); ok { + var cloudscaleErr *cloudscale.ErrorResponse + if errors.As(err, &cloudscaleErr) { if cloudscaleErr.StatusCode == http.StatusNotFound { t.Logf("volume %v is deleted on cloudscale", volumeName) return @@ -2004,7 +2005,8 @@ func waitCloudscaleVolumeSnapshotDeleted(t *testing.T, snapshotHandle string) { cancel() if err != nil { - if cloudscaleErr, ok := err.(*cloudscale.ErrorResponse); ok { + var cloudscaleErr *cloudscale.ErrorResponse + if errors.As(err, &cloudscaleErr) { if cloudscaleErr.StatusCode == http.StatusNotFound { t.Logf("snapshot %v is deleted on cloudscale", snapshotHandle) return From d376c0b1b751d36fd2feeffba29c814349d68804 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 27 Jan 2026 11:39:32 +0100 Subject: [PATCH 18/63] throw InvalidArgument instead of warning for storageType missmatch --- driver/controller.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index ebc3528c..44e062f1 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -282,12 +282,13 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo } } - // cloudscale does not support to change storage type, so we warn if parameters are specified that will be ignored + // cloudscale does not support changing storage type when restoring from snapshot. + // The restored volume must have the same storage type as the snapshot. if storageType := req.Parameters[StorageTypeAttribute]; storageType != "" && storageType != snapshot.Volume.Type { - ll.WithFields(logrus.Fields{ - "requested_type": storageType, - "snapshot_volume_type": snapshot.Volume.Type, - }).Warn("storage type parameter ignored when creating from snapshot") + return nil, status.Errorf(codes.InvalidArgument, + "requested storage type %s does not match snapshot storage type %s. "+ + "Storage type cannot be changed when creating a volume from a snapshot", + storageType, snapshot.Volume.Type) } luksEncrypted := "false" From 760a5c78284195de0aed71b5c2eb8452945c17db Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 27 Jan 2026 13:11:40 +0100 Subject: [PATCH 19/63] improve error handling of existing snapshots and log levels --- driver/controller.go | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 44e062f1..1389d86c 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -283,7 +283,7 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo } // cloudscale does not support changing storage type when restoring from snapshot. - // The restored volume must have the same storage type as the snapshot. + // The restored volume must have the same storage type as the source volume of the snapshot. if storageType := req.Parameters[StorageTypeAttribute]; storageType != "" && storageType != snapshot.Volume.Type { return nil, status.Errorf(codes.InvalidArgument, "requested storage type %s does not match snapshot storage type %s. "+ @@ -386,27 +386,30 @@ func (d *Driver) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequest) if err != nil { var errorResponse *cloudscale.ErrorResponse if errors.As(err, &errorResponse) { - ll.WithFields(logrus.Fields{ - "status_code": errorResponse.StatusCode, - "error": err, - }).Warn("cloudscale API returned error during volume deletion") - if errorResponse.StatusCode == http.StatusNotFound { // To make it idempotent, the volume might already have been // deleted, so a 404 is ok. ll.WithFields(logrus.Fields{ "error": err, "resp": errorResponse, - }).Warn("assuming volume is already deleted") + }).Debug("assuming volume is already deleted") return &csi.DeleteVolumeResponse{}, nil } - // Check if the error message indicates snapshots exist - if strings.Contains(err.Error(), "Snapshots exist") || - strings.Contains(err.Error(), "snapshot") { - ll.Warn("volume has snapshots, cannot delete yet") - return nil, status.Error(codes.FailedPrecondition, - "volume has existing snapshots that must be deleted first") + ll.WithFields(logrus.Fields{ + "status_code": errorResponse.StatusCode, + "error": err, + }).Debug("cloudscale API returned error during volume deletion") + + // Check if the error indicates snapshots exist (HTTP 400 with specific error message) + // The API returns HTTP 400 with: {"detail": "Snapshots exist for this volume. The snapshot must be deleted before the volume can be deleted."} + if errorResponse.StatusCode == http.StatusBadRequest && + strings.Contains(err.Error(), "Snapshots exist for this volume. The snapshot must be deleted before the volume can be deleted.") { + ll.WithFields(logrus.Fields{ + "error": err, + "resp": errorResponse, + }).Warn("volume has snapshots, cannot delete yet") + return nil, status.Error(codes.FailedPrecondition, "volume has existing snapshots that must be deleted first") } } return nil, err @@ -781,12 +784,12 @@ func (d *Driver) DeleteSnapshot(ctx context.Context, req *csi.DeleteSnapshotRequ var errorResponse *cloudscale.ErrorResponse if errors.As(err, &errorResponse) { if errorResponse.StatusCode == http.StatusNotFound { - // To make it idempotent, the volume might already have been + // To make it idempotent, the snapshot might already have been // deleted, so a 404 is ok. ll.WithFields(logrus.Fields{ "error": err, "resp": errorResponse, - }).Warn("assuming snapshot is already deleted") + }).Debug("assuming snapshot is already deleted") return &csi.DeleteSnapshotResponse{}, nil } } From 40920bef3df2e7a1410865f9e9e1147c0e69f1f5 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 27 Jan 2026 13:27:05 +0100 Subject: [PATCH 20/63] simplify createdAt parsing --- driver/controller.go | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 1389d86c..22800a93 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -401,8 +401,7 @@ func (d *Driver) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequest) "error": err, }).Debug("cloudscale API returned error during volume deletion") - // Check if the error indicates snapshots exist (HTTP 400 with specific error message) - // The API returns HTTP 400 with: {"detail": "Snapshots exist for this volume. The snapshot must be deleted before the volume can be deleted."} + // Check if the error indicates snapshots exist (HTTP 400 with error message "Snapshots exist for this volume") if errorResponse.StatusCode == http.StatusBadRequest && strings.Contains(err.Error(), "Snapshots exist for this volume. The snapshot must be deleted before the volume can be deleted.") { ll.WithFields(logrus.Fields{ @@ -696,12 +695,11 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ for _, snapshot := range snapshots { if snapshot.Volume.UUID == req.SourceVolumeId { - creationTime := timestamppb.Now() - if snapshot.CreatedAt != "" { - if t, err := time.Parse(time.RFC3339, snapshot.CreatedAt); err == nil { - creationTime = timestamppb.New(t) - } + t, err := time.Parse(time.RFC3339, snapshot.CreatedAt) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to parse snapshot CreatedAt timestamp %q: %v", snapshot.CreatedAt, err) } + creationTime := timestamppb.New(t) return &csi.CreateSnapshotResponse{ Snapshot: &csi.Snapshot{ @@ -743,12 +741,11 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ return nil, status.Errorf(codes.Internal, "failed to create snapshot: %v", err) } - creationTime := timestamppb.Now() - if snapshot.CreatedAt != "" { - if t, err := time.Parse(time.RFC3339, snapshot.CreatedAt); err == nil { - creationTime = timestamppb.New(t) - } + t, err := time.Parse(time.RFC3339, snapshot.CreatedAt) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to parse snapshot CreatedAt timestamp %q: %v", snapshot.CreatedAt, err) } + creationTime := timestamppb.New(t) resp := &csi.CreateSnapshotResponse{ Snapshot: &csi.Snapshot{ From f044acff8b63f9d3c7c0b776a33f1a1845b46619 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 27 Jan 2026 17:18:18 +0100 Subject: [PATCH 21/63] shorten compared error message --- driver/controller.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 22800a93..56f2e3da 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -402,8 +402,7 @@ func (d *Driver) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequest) }).Debug("cloudscale API returned error during volume deletion") // Check if the error indicates snapshots exist (HTTP 400 with error message "Snapshots exist for this volume") - if errorResponse.StatusCode == http.StatusBadRequest && - strings.Contains(err.Error(), "Snapshots exist for this volume. The snapshot must be deleted before the volume can be deleted.") { + if errorResponse.StatusCode == http.StatusBadRequest && strings.Contains(strings.ToLower(err.Error()), strings.ToLower("Snapshots exist for this volume")) { ll.WithFields(logrus.Fields{ "error": err, "resp": errorResponse, From 567d5611ad9dec7b89a1cd80551a241ef109f636 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 27 Jan 2026 18:20:13 +0100 Subject: [PATCH 22/63] replace custom CRD and snapshot controller installation with kustomize --- README.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a332a65a..78d93e8e 100644 --- a/README.md +++ b/README.md @@ -77,16 +77,10 @@ To use CSI snapshots with this driver, your cluster must have the VolumeSnapshot Note: Some Kubernetes distributions already include these CRDs and controllers. You only need to apply them manually if your cluster does not provide them. -Install the snapshot resources: +Install the snapshot resources using kustomize (recommended): ``` -# Create the necessary CRDs -kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.4.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml -kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.4.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml -kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.4.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml - -# Install snapshot controller with RBAC -kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.4.0/deploy/kubernetes/snapshot-controller/rbac-snapshot-controller.yaml -kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.4.0/deploy/kubernetes/snapshot-controller/setup-snapshot-controller.yaml +kubectl apply -k https://github.com/kubernetes-csi/external-snapshotter/client/config/crd?ref=v8.4.0 +kubectl apply -k https://github.com/kubernetes-csi/external-snapshotter/deploy/kubernetes/snapshot-controller?ref=v8.4.0 ``` ### Kubernetes Compatibility From c8de2779f1f6e77765f0f912876bec301a543ea3 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 29 Jan 2026 10:11:00 +0100 Subject: [PATCH 23/63] ignoring storage type parameter, only add debug information --- driver/controller.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 56f2e3da..6559a941 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -283,12 +283,10 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo } // cloudscale does not support changing storage type when restoring from snapshot. - // The restored volume must have the same storage type as the source volume of the snapshot. - if storageType := req.Parameters[StorageTypeAttribute]; storageType != "" && storageType != snapshot.Volume.Type { - return nil, status.Errorf(codes.InvalidArgument, - "requested storage type %s does not match snapshot storage type %s. "+ - "Storage type cannot be changed when creating a volume from a snapshot", - storageType, snapshot.Volume.Type) + // The restored volume type is inherited from the source volume of the snapshot. + if storageType := req.Parameters[StorageTypeAttribute]; storageType != "" { + ll.WithField("requested_type", storageType). + Debug("ignoring storage type parameter when restoring from snapshot") } luksEncrypted := "false" From da0b9497372fa07faeb52f469abd5dc6a876aea4 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 29 Jan 2026 10:11:59 +0100 Subject: [PATCH 24/63] setup instructions for volumesnapshotclass.yaml --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 78d93e8e..c228338c 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ Install the snapshot resources using kustomize (recommended): ``` kubectl apply -k https://github.com/kubernetes-csi/external-snapshotter/client/config/crd?ref=v8.4.0 kubectl apply -k https://github.com/kubernetes-csi/external-snapshotter/deploy/kubernetes/snapshot-controller?ref=v8.4.0 +# setup volumesnapshotclass in your cluster +kubectl apply -f examples/kubernetes/volume-snapshots/volumesnapshotclass.yaml ``` ### Kubernetes Compatibility From 9436917031138cb48ce3bfc565052365062e6139 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 29 Jan 2026 10:13:01 +0100 Subject: [PATCH 25/63] remove leader-election config, as we run it on single replica --- charts/csi-cloudscale/templates/statefulset.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/charts/csi-cloudscale/templates/statefulset.yaml b/charts/csi-cloudscale/templates/statefulset.yaml index 6f315449..c2a0c27b 100644 --- a/charts/csi-cloudscale/templates/statefulset.yaml +++ b/charts/csi-cloudscale/templates/statefulset.yaml @@ -76,7 +76,6 @@ spec: image: "{{ .Values.snapshotter.image.registry }}/{{ .Values.snapshotter.image.repository }}:{{ .Values.snapshotter.image.tag }}" args: - "--csi-address=$(CSI_ENDPOINT)" - - "--leader-election=true" - "--v=5" env: - name: CSI_ENDPOINT From 23d371c9168a64bb7580676e0e284be3155b2569 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 29 Jan 2026 12:25:59 +0100 Subject: [PATCH 26/63] add documentation for luks examples --- .../luks-encrypted-volumes/README.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 examples/kubernetes/luks-encrypted-volumes/README.md diff --git a/examples/kubernetes/luks-encrypted-volumes/README.md b/examples/kubernetes/luks-encrypted-volumes/README.md new file mode 100644 index 00000000..3d7ef3b0 --- /dev/null +++ b/examples/kubernetes/luks-encrypted-volumes/README.md @@ -0,0 +1,135 @@ +# LUKS Encrypted Volumes Example + +This example demonstrates how to create and restore LUKS-encrypted volumes from snapshots using the cloudscale.ch CSI driver. + +## Prerequisites + +1. **Snapshot CRDs installed**: See the [main README](../../README.md#prerequisites-for-snapshot-support) +2. **VolumeSnapshotClass created**: See the [volume-snapshots example](../volume-snapshots/) +3. **LUKS storage classes available**: `cloudscale-volume-ssd-luks` or `cloudscale-volume-bulk-luks` + +## Workflow + +### 1. Create Original LUKS Volume + +```bash +# Create the LUKS secret (contains the encryption key) +kubectl apply -f luks-secret.yaml + +# Create the PVC (this will create a LUKS-encrypted volume) +kubectl apply -f luks-pvc.yaml + +# Optional: Create a pod to use the volume +kubectl apply -f luks-pod.yaml +``` + +**Note:** The pod will remain in `ContainerCreating` state until: +- The PVC is bound (volume provisioned) +- The LUKS volume is decrypted and mounted on the node +- This can take 30-60 seconds depending on volume size + +### 2. Create Snapshot + +```bash +kubectl apply -f luks-volumesnapshot.yaml +``` + +Wait for the snapshot to be ready: +```bash +kubectl get volumesnapshot my-luks-snapshot +# Wait until READYTOUSE is true +``` + +### 3. Restore from Snapshot + +```bash +# Create the LUKS secret for the restored volume +# IMPORTANT: Use the SAME key as the original volume +kubectl apply -f restored-luks-secret.yaml + +# Create the restored PVC (from snapshot) +kubectl apply -f restored-luks-pvc.yaml + +# Optional: Create a pod to use the restored volume +kubectl apply -f restored-luks-pod.yaml +``` + +**Note:** Restored pods will also remain in `ContainerCreating` until: +- The volume is created from the snapshot +- The PVC is bound +- The LUKS volume is decrypted and mounted +- This can take 1-2 minutes for snapshot restore + +## Verification + +Check PVC status: +```bash +kubectl get pvc +# Wait until STATUS is Bound +``` + +Check pod status: +```bash +kubectl get pod +# Pods will be in ContainerCreating until PVCs are bound and volumes are mounted +``` + +Check pod events if stuck: +```bash +kubectl describe pod my-csi-app-luks +kubectl describe pod my-restored-luks-app +``` + +## Important Notes + +1. **LUKS Key Matching**: The restored volume MUST use the same LUKS key as the original volume. The key is stored in the secret. + +2. **Secret Naming**: The secret name must follow the pattern `${pvc-name}-luks-key`: + - Original PVC `csi-pod-pvc-luks` → Secret `csi-pod-pvc-luks-luks-key` + - Restored PVC `my-restored-luks-volume` → Secret `my-restored-luks-volume-luks-key` + +3. **Storage Class**: Both original and restored volumes must use a LUKS storage class (`cloudscale-volume-ssd-luks` or `cloudscale-volume-bulk-luks`). + +4. **Size Matching**: The restored volume size must match the snapshot size exactly (1Gi in this example). + +5. **ContainerCreating State**: It's **expected** for pods to remain in `ContainerCreating` state for 30-120 seconds while: + - Volumes are being provisioned/restored + - LUKS volumes are being decrypted + - Filesystems are being mounted + +## Troubleshooting + +If pods remain stuck in `ContainerCreating` for more than 5 minutes: + +1. Check PVC status: + ```bash + kubectl get pvc + kubectl describe pvc + ``` + +2. Check for events: + ```bash + kubectl get events --sort-by='.lastTimestamp' + ``` + +3. Verify secrets exist: + ```bash + kubectl get secret -luks-key + ``` + +4. Check node logs for LUKS errors: + ```bash + kubectl logs -n kube-system -l app=csi-cloudscale-node + ``` + +## Cleanup + +```bash +kubectl delete -f restored-luks-pod.yaml +kubectl delete -f restored-luks-pvc.yaml +kubectl delete -f restored-luks-secret.yaml +kubectl delete -f luks-volumesnapshot.yaml +kubectl delete -f luks-pod.yaml +kubectl delete -f luks-pvc.yaml +kubectl delete -f luks-secret.yaml +``` From 64768a122e820e62ecfeeabc5f90b8a2d464f776 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 29 Jan 2026 12:33:03 +0100 Subject: [PATCH 27/63] fail integration tests with clear error if CRDs or VolumeSnapshotClass is missing --- test/kubernetes/integration_test.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index 0af09f2c..dcde0b03 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -27,7 +27,7 @@ import ( snapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" snapshotclientset "github.com/kubernetes-csi/external-snapshotter/client/v6/clientset/versioned" appsv1 "k8s.io/api/apps/v1" - "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" kubeerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -1907,6 +1907,22 @@ func generateMetricEntry(line string) MetricEntry { func makeKubernetesVolumeSnapshot(t *testing.T, snapshotName string, pvcName string) *snapshotv1.VolumeSnapshot { className := "cloudscale-snapshots" + // Verify that the VolumeSnapshotClass exists before creating the VolumeSnapshot + // This helps catch configuration issues early (e.g., CRDs not installed) + _, err := snapshotClient.SnapshotV1().VolumeSnapshotClasses().Get( + context.Background(), + className, + metav1.GetOptions{}, + ) + if err != nil { + if kubeerrors.IsNotFound(err) { + t.Fatalf("VolumeSnapshotClass %q not found. "+ + "This usually means the snapshot CRDs are not installed. "+ + "See the readme for setup installation instrucitons and and ensure the VolumeSnapshotClass resource exists. Error: %v", className, err) + } + t.Fatalf("Failed to get VolumeSnapshotClass %q: %v", className, err) + } + snapshot := &snapshotv1.VolumeSnapshot{ TypeMeta: metav1.TypeMeta{ Kind: "VolumeSnapshot", From b84e9a270314de8940d1045b6c2e14dfce6d0e5e Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 2 Feb 2026 18:42:13 +0100 Subject: [PATCH 28/63] fix volume and snapshot cleanup in TestPod_Create_Volume_From_Snapshot --- test/kubernetes/integration_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index dcde0b03..7c165edf 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -334,9 +334,17 @@ func TestPod_Create_Volume_From_Snapshot(t *testing.T) { // verify that the filesystem UUID is preserved (data was restored, not recreated) assert.Equal(t, originalFilesystemUUID, restoredDisk.FilesystemUUID) - // finally cleanup the restored pod and pvc + // delete the snapshot before deleting the volumes (cloudscale requires snapshots deleted before source volume) + deleteKubernetesVolumeSnapshot(t, snapshot.Name) + waitCloudscaleVolumeSnapshotDeleted(t, *snapshotContent.Status.SnapshotHandle) + + // cleanup restored pod and pvc cleanup(t, restoredPodDescriptor) waitCloudscaleVolumeDeleted(t, restoredPVC.Spec.VolumeName) + + // cleanup original pod and pvc + cleanup(t, podDescriptor) + waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) } func TestPod_Single_SSD_Luks_Volume_Snapshot(t *testing.T) { From 44c0954022fdebf34db700463c0041c4099e25a3 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 3 Feb 2026 14:28:06 +0100 Subject: [PATCH 29/63] cleanup luks examples --- .../luks-encrypted-volumes/README.md | 131 ++++-------------- .../luks-encrypted-volumes/luks-pod.yaml | 4 +- .../luks-encrypted-volumes/luks-pvc.yaml | 2 +- .../luks-encrypted-volumes/luks-secret.yaml | 2 +- .../luks-volumesnapshot.yaml | 7 +- .../restored-luks-pod.yaml | 5 +- .../restored-luks-pvc.yaml | 10 +- .../restored-luks-secret.yaml | 6 +- 8 files changed, 35 insertions(+), 132 deletions(-) diff --git a/examples/kubernetes/luks-encrypted-volumes/README.md b/examples/kubernetes/luks-encrypted-volumes/README.md index 3d7ef3b0..459cbc73 100644 --- a/examples/kubernetes/luks-encrypted-volumes/README.md +++ b/examples/kubernetes/luks-encrypted-volumes/README.md @@ -1,126 +1,43 @@ # LUKS Encrypted Volumes Example -This example demonstrates how to create and restore LUKS-encrypted volumes from snapshots using the cloudscale.ch CSI driver. +Demonstrates creating and restoring LUKS-encrypted volumes from snapshots. ## Prerequisites -1. **Snapshot CRDs installed**: See the [main README](../../README.md#prerequisites-for-snapshot-support) -2. **VolumeSnapshotClass created**: See the [volume-snapshots example](../volume-snapshots/) -3. **LUKS storage classes available**: `cloudscale-volume-ssd-luks` or `cloudscale-volume-bulk-luks` +- Snapshot CRDs installed (see [main README](../../README.md#prerequisites-for-snapshot-support)) +- VolumeSnapshotClass created (see [volume-snapshots example](../volume-snapshots/)) +- LUKS storage classes available: `cloudscale-volume-ssd-luks` or `cloudscale-volume-bulk-luks` ## Workflow -### 1. Create Original LUKS Volume - -```bash -# Create the LUKS secret (contains the encryption key) -kubectl apply -f luks-secret.yaml - -# Create the PVC (this will create a LUKS-encrypted volume) -kubectl apply -f luks-pvc.yaml - -# Optional: Create a pod to use the volume -kubectl apply -f luks-pod.yaml -``` - -**Note:** The pod will remain in `ContainerCreating` state until: -- The PVC is bound (volume provisioned) -- The LUKS volume is decrypted and mounted on the node -- This can take 30-60 seconds depending on volume size - -### 2. Create Snapshot - -```bash -kubectl apply -f luks-volumesnapshot.yaml -``` - -Wait for the snapshot to be ready: -```bash -kubectl get volumesnapshot my-luks-snapshot -# Wait until READYTOUSE is true -``` - -### 3. Restore from Snapshot - -```bash -# Create the LUKS secret for the restored volume -# IMPORTANT: Use the SAME key as the original volume -kubectl apply -f restored-luks-secret.yaml - -# Create the restored PVC (from snapshot) -kubectl apply -f restored-luks-pvc.yaml - -# Optional: Create a pod to use the restored volume -kubectl apply -f restored-luks-pod.yaml -``` - -**Note:** Restored pods will also remain in `ContainerCreating` until: -- The volume is created from the snapshot -- The PVC is bound -- The LUKS volume is decrypted and mounted -- This can take 1-2 minutes for snapshot restore - -## Verification - -Check PVC status: -```bash -kubectl get pvc -# Wait until STATUS is Bound -``` - -Check pod status: -```bash -kubectl get pod -# Pods will be in ContainerCreating until PVCs are bound and volumes are mounted -``` - -Check pod events if stuck: -```bash -kubectl describe pod my-csi-app-luks -kubectl describe pod my-restored-luks-app -``` - -## Important Notes - -1. **LUKS Key Matching**: The restored volume MUST use the same LUKS key as the original volume. The key is stored in the secret. - -2. **Secret Naming**: The secret name must follow the pattern `${pvc-name}-luks-key`: - - Original PVC `csi-pod-pvc-luks` → Secret `csi-pod-pvc-luks-luks-key` - - Restored PVC `my-restored-luks-volume` → Secret `my-restored-luks-volume-luks-key` - -3. **Storage Class**: Both original and restored volumes must use a LUKS storage class (`cloudscale-volume-ssd-luks` or `cloudscale-volume-bulk-luks`). - -4. **Size Matching**: The restored volume size must match the snapshot size exactly (1Gi in this example). - -5. **ContainerCreating State**: It's **expected** for pods to remain in `ContainerCreating` state for 30-120 seconds while: - - Volumes are being provisioned/restored - - LUKS volumes are being decrypted - - Filesystems are being mounted - -## Troubleshooting - -If pods remain stuck in `ContainerCreating` for more than 5 minutes: - -1. Check PVC status: +1. Create secret and PVC: ```bash - kubectl get pvc - kubectl describe pvc + kubectl apply -f luks-secret.yaml + kubectl apply -f luks-pvc.yaml + kubectl apply -f luks-pod.yaml ``` -2. Check for events: +2. Create snapshot: ```bash - kubectl get events --sort-by='.lastTimestamp' - ``` + kubectl apply -f luks-volumesnapshot.yaml + kubectl get volumesnapshot luks-snapshot # wait for READYTOUSE=true + ```co -3. Verify secrets exist: +3. Restore from snapshot: ```bash - kubectl get secret -luks-key + kubectl apply -f restored-luks-secret.yaml + kubectl apply -f restored-luks-pvc.yaml + kubectl apply -f restored-luks-pod.yaml ``` -4. Check node logs for LUKS errors: - ```bash - kubectl logs -n kube-system -l app=csi-cloudscale-node - ``` +## Secret Naming Pattern + +**Storage class requirement:** The LUKS storage class enforces the secret naming pattern `${pvc.name}-luks-key`. + +- Original PVC `luks-volume` → requires secret `luks-volume-luks-key` +- Restored PVC `luks-volume-restored` → requires secret `luks-volume-restored-luks-key` + +**Important:** Different PVC names require different secret names (storage class requirement), but the key **VALUE** must be the same because restored volumes contain the same encrypted data. ## Cleanup diff --git a/examples/kubernetes/luks-encrypted-volumes/luks-pod.yaml b/examples/kubernetes/luks-encrypted-volumes/luks-pod.yaml index 73f42c67..caf9cc3e 100644 --- a/examples/kubernetes/luks-encrypted-volumes/luks-pod.yaml +++ b/examples/kubernetes/luks-encrypted-volumes/luks-pod.yaml @@ -1,7 +1,7 @@ kind: Pod apiVersion: v1 metadata: - name: my-csi-app-luks + name: luks-app spec: containers: - name: my-frontend @@ -13,4 +13,4 @@ spec: volumes: - name: my-cloudscale-volume persistentVolumeClaim: - claimName: csi-pod-pvc-luks + claimName: luks-volume diff --git a/examples/kubernetes/luks-encrypted-volumes/luks-pvc.yaml b/examples/kubernetes/luks-encrypted-volumes/luks-pvc.yaml index 4ab62292..50d7d3a8 100644 --- a/examples/kubernetes/luks-encrypted-volumes/luks-pvc.yaml +++ b/examples/kubernetes/luks-encrypted-volumes/luks-pvc.yaml @@ -1,7 +1,7 @@ apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: csi-pod-pvc-luks + name: luks-volume spec: accessModes: - ReadWriteOnce diff --git a/examples/kubernetes/luks-encrypted-volumes/luks-secret.yaml b/examples/kubernetes/luks-encrypted-volumes/luks-secret.yaml index 85945ce9..3d8a9014 100644 --- a/examples/kubernetes/luks-encrypted-volumes/luks-secret.yaml +++ b/examples/kubernetes/luks-encrypted-volumes/luks-secret.yaml @@ -1,6 +1,6 @@ apiVersion: v1 kind: Secret metadata: - name: csi-pod-pvc-luks-luks-key + name: luks-volume-luks-key stringData: luksKey: "hDEKFgEZgmpuppShPG7HailSFBsy8MzlvlhALvqk0+2jTrcKrFmtttoF5IGlLVoLt/jpaWnk/kcl7JxnsZ3xQjEcYumv4WkwOv77x+c2C/kyyldTNRaCaVHG9fW9n6oicoWzsyUWcmu0d+JOorGZ792lsS9Q5gXlCg5BD2x1MoVVr8hTQArFfUX6NuHF1o0v/EGHU0A5O5wiNnqpdDjf9r56rPt0H290Nr6Y5Ijb5RTIoJFT5ww5XocrvLlR/GiXRYgzeISfbfyIr8FpfRKmjPTZdLBSXPMMdHJNcPIlRG+DfnBaTKkIFwiWXjxXZss71IKibEM7Qfjwka0KFyufwA==" diff --git a/examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml b/examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml index 490faa55..62f1c668 100644 --- a/examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml +++ b/examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml @@ -1,11 +1,8 @@ -# VolumeSnapshot creates a snapshot of a LUKS-encrypted volume -# Make sure the VolumeSnapshotClass is created first (see ../volume-snapshots/volumesnapshotclass.yaml) -# The snapshot preserves the LUKS encryption state apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot metadata: - name: my-luks-snapshot + name: luks-snapshot spec: volumeSnapshotClassName: cloudscale-snapshots source: - persistentVolumeClaimName: csi-pod-pvc-luks + persistentVolumeClaimName: luks-volume diff --git a/examples/kubernetes/luks-encrypted-volumes/restored-luks-pod.yaml b/examples/kubernetes/luks-encrypted-volumes/restored-luks-pod.yaml index 62bdda2a..95822710 100644 --- a/examples/kubernetes/luks-encrypted-volumes/restored-luks-pod.yaml +++ b/examples/kubernetes/luks-encrypted-volumes/restored-luks-pod.yaml @@ -1,8 +1,7 @@ -# Pod using the restored LUKS volume (optional, for testing) kind: Pod apiVersion: v1 metadata: - name: my-restored-luks-app + name: luks-app-restored spec: containers: - name: my-frontend @@ -14,4 +13,4 @@ spec: volumes: - name: my-cloudscale-volume persistentVolumeClaim: - claimName: my-restored-luks-volume + claimName: luks-volume-restored diff --git a/examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml b/examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml index 1cc586c1..f0ea2dd6 100644 --- a/examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml +++ b/examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml @@ -1,13 +1,7 @@ -# PersistentVolumeClaim restored from a LUKS snapshot -# IMPORTANT: When restoring from a LUKS snapshot, you MUST: -# 1. Use a LUKS storage class (cloudscale-volume-ssd-luks or cloudscale-volume-bulk-luks) -# 2. Provide a LUKS secret with the pattern: ${pvc-name}-luks-key -# 3. Use the SAME LUKS key as the original volume -# 4. Match the snapshot size exactly (1Gi in this example) apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: my-restored-luks-volume + name: luks-volume-restored spec: accessModes: - ReadWriteOnce @@ -16,6 +10,6 @@ spec: requests: storage: 1Gi dataSource: - name: my-luks-snapshot + name: luks-snapshot kind: VolumeSnapshot apiGroup: snapshot.storage.k8s.io diff --git a/examples/kubernetes/luks-encrypted-volumes/restored-luks-secret.yaml b/examples/kubernetes/luks-encrypted-volumes/restored-luks-secret.yaml index 4e2ee1b7..ba265a8f 100644 --- a/examples/kubernetes/luks-encrypted-volumes/restored-luks-secret.yaml +++ b/examples/kubernetes/luks-encrypted-volumes/restored-luks-secret.yaml @@ -1,10 +1,6 @@ -# Secret containing the LUKS key for the restored volume -# IMPORTANT: This must use the same LUKS key as the original volume -# The secret name must follow the pattern: ${pvc-name}-luks-key -# In this case: my-restored-luks-volume-luks-key apiVersion: v1 kind: Secret metadata: - name: my-restored-luks-volume-luks-key + name: luks-volume-restored-luks-key stringData: luksKey: "hDEKFgEZgmpuppShPG7HailSFBsy8MzlvlhALvqk0+2jTrcKrFmtttoF5IGlLVoLt/jpaWnk/kcl7JxnsZ3xQjEcYumv4WkwOv77x+c2C/kyyldTNRaCaVHG9fW9n6oicoWzsyUWcmu0d+JOorGZ792lsS9Q5gXlCg5BD2x1MoVVr8hTQArFfUX6NuHF1o0v/EGHU0A5O5wiNnqpdDjf9r56rPt0H290Nr6Y5Ijb5RTIoJFT5ww5XocrvLlR/GiXRYgzeISfbfyIr8FpfRKmjPTZdLBSXPMMdHJNcPIlRG+DfnBaTKkIFwiWXjxXZss71IKibEM7Qfjwka0KFyufwA==" From fe09ed24733e10718091ecbe784e8e0695ae1d12 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 3 Feb 2026 15:00:56 +0100 Subject: [PATCH 30/63] cleanup volume snapshot examples, prevent naming conflict --- .../luks-encrypted-volumes/README.md | 4 +-- .../luks-volumesnapshot.yaml | 2 +- .../restored-luks-pvc.yaml | 2 +- .../kubernetes/volume-snapshots/README.md | 36 ++++++------------- .../volume-snapshots/original-pod.yaml | 1 - .../volume-snapshots/original-pvc.yaml | 1 - .../volume-snapshots/restored-pod.yaml | 1 - .../volume-snapshots/restored-pvc.yaml | 4 +-- .../volume-snapshots/volumesnapshot.yaml | 4 +-- .../volume-snapshots/volumesnapshotclass.yaml | 3 -- 10 files changed, 16 insertions(+), 42 deletions(-) diff --git a/examples/kubernetes/luks-encrypted-volumes/README.md b/examples/kubernetes/luks-encrypted-volumes/README.md index 459cbc73..fadcb6bc 100644 --- a/examples/kubernetes/luks-encrypted-volumes/README.md +++ b/examples/kubernetes/luks-encrypted-volumes/README.md @@ -20,8 +20,8 @@ Demonstrates creating and restoring LUKS-encrypted volumes from snapshots. 2. Create snapshot: ```bash kubectl apply -f luks-volumesnapshot.yaml - kubectl get volumesnapshot luks-snapshot # wait for READYTOUSE=true - ```co + kubectl get volumesnapshot luks-volume-snapshot # wait for READYTOUSE=true + ``` 3. Restore from snapshot: ```bash diff --git a/examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml b/examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml index 62f1c668..d842d883 100644 --- a/examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml +++ b/examples/kubernetes/luks-encrypted-volumes/luks-volumesnapshot.yaml @@ -1,7 +1,7 @@ apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot metadata: - name: luks-snapshot + name: luks-volume-snapshot spec: volumeSnapshotClassName: cloudscale-snapshots source: diff --git a/examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml b/examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml index f0ea2dd6..8c1ed490 100644 --- a/examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml +++ b/examples/kubernetes/luks-encrypted-volumes/restored-luks-pvc.yaml @@ -10,6 +10,6 @@ spec: requests: storage: 1Gi dataSource: - name: luks-snapshot + name: luks-volume-snapshot kind: VolumeSnapshot apiGroup: snapshot.storage.k8s.io diff --git a/examples/kubernetes/volume-snapshots/README.md b/examples/kubernetes/volume-snapshots/README.md index 302729ad..24026d8a 100644 --- a/examples/kubernetes/volume-snapshots/README.md +++ b/examples/kubernetes/volume-snapshots/README.md @@ -1,54 +1,37 @@ # Volume Snapshots Example -This example demonstrates how to create and restore volumes from snapshots using the cloudscale.ch CSI driver. +Demonstrates creating and restoring volumes from snapshots. ## Prerequisites -Before using snapshots, ensure your cluster has the VolumeSnapshot CRDs and snapshot controller installed. -See the [main README](../../README.md#prerequisites-for-snapshot-support) for installation instructions. +- Snapshot CRDs and snapshot controller installed (see [main README](../../README.md#prerequisites-for-snapshot-support)) ## Workflow -1. **Create VolumeSnapshotClass** (one-time setup, required before creating snapshots): +1. Create VolumeSnapshotClass (one-time setup): ```bash kubectl apply -f volumesnapshotclass.yaml ``` - - **Note:** VolumeSnapshotClass is currently not deployed automatically with the driver. You must create it manually. - This may change in future releases where it will be deployed automatically (similar to StorageClass). -2. **Create original volume and pod** (optional, for testing): +2. Create original volume and pod: ```bash kubectl apply -f original-pvc.yaml kubectl apply -f original-pod.yaml ``` -3. **Create snapshot**: +3. Create snapshot: ```bash kubectl apply -f volumesnapshot.yaml + kubectl get volumesnapshot my-volume-snapshot # wait for READYTOUSE=true ``` -4. **Create restored volume and pod**: +4. Create restored volume and pod: ```bash kubectl apply -f restored-pvc.yaml kubectl apply -f restored-pod.yaml ``` -## Verification - -Check snapshot status: -```bash -kubectl get volumesnapshot -kubectl describe volumesnapshot/my-snapshot -``` - -Check restored volume: -```bash -kubectl get pvc -kubectl get pod -``` - -**LUKS volumes**: For LUKS-encrypted volumes, see the [LUKS snapshot example](../luks-encrypted-volumes/). +**Note:** Restored volumes must match the snapshot size exactly (5Gi in this example). ## Cleanup @@ -58,5 +41,6 @@ kubectl delete -f restored-pvc.yaml kubectl delete -f volumesnapshot.yaml kubectl delete -f original-pod.yaml kubectl delete -f original-pvc.yaml -# Note: VolumeSnapshotClass is typically not deleted as it's a cluster resource ``` + +**LUKS volumes**: For LUKS-encrypted volumes, see the [LUKS snapshot example](../luks-encrypted-volumes/). diff --git a/examples/kubernetes/volume-snapshots/original-pod.yaml b/examples/kubernetes/volume-snapshots/original-pod.yaml index b0b48808..1794a24b 100644 --- a/examples/kubernetes/volume-snapshots/original-pod.yaml +++ b/examples/kubernetes/volume-snapshots/original-pod.yaml @@ -1,4 +1,3 @@ -# Pod using the original volume (optional, for testing) kind: Pod apiVersion: v1 metadata: diff --git a/examples/kubernetes/volume-snapshots/original-pvc.yaml b/examples/kubernetes/volume-snapshots/original-pvc.yaml index 24a198e5..66f55466 100644 --- a/examples/kubernetes/volume-snapshots/original-pvc.yaml +++ b/examples/kubernetes/volume-snapshots/original-pvc.yaml @@ -1,4 +1,3 @@ -# Original PersistentVolumeClaim that will be snapshotted apiVersion: v1 kind: PersistentVolumeClaim metadata: diff --git a/examples/kubernetes/volume-snapshots/restored-pod.yaml b/examples/kubernetes/volume-snapshots/restored-pod.yaml index 4bdd38aa..65453720 100644 --- a/examples/kubernetes/volume-snapshots/restored-pod.yaml +++ b/examples/kubernetes/volume-snapshots/restored-pod.yaml @@ -1,4 +1,3 @@ -# Pod using the restored volume (optional, for testing) kind: Pod apiVersion: v1 metadata: diff --git a/examples/kubernetes/volume-snapshots/restored-pvc.yaml b/examples/kubernetes/volume-snapshots/restored-pvc.yaml index 5250f894..6fbf6d10 100644 --- a/examples/kubernetes/volume-snapshots/restored-pvc.yaml +++ b/examples/kubernetes/volume-snapshots/restored-pvc.yaml @@ -1,5 +1,3 @@ -# PersistentVolumeClaim restored from the snapshot -# Note: The restored volume must have the same size as the snapshot (5Gi in this example) apiVersion: v1 kind: PersistentVolumeClaim metadata: @@ -12,6 +10,6 @@ spec: requests: storage: 5Gi dataSource: - name: my-snapshot + name: my-volume-snapshot kind: VolumeSnapshot apiGroup: snapshot.storage.k8s.io diff --git a/examples/kubernetes/volume-snapshots/volumesnapshot.yaml b/examples/kubernetes/volume-snapshots/volumesnapshot.yaml index dade8aca..7a538eef 100644 --- a/examples/kubernetes/volume-snapshots/volumesnapshot.yaml +++ b/examples/kubernetes/volume-snapshots/volumesnapshot.yaml @@ -1,9 +1,7 @@ -# VolumeSnapshot creates a snapshot of the original volume -# Make sure the VolumeSnapshotClass is created first (volumesnapshotclass.yaml) apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot metadata: - name: my-snapshot + name: my-volume-snapshot spec: volumeSnapshotClassName: cloudscale-snapshots source: diff --git a/examples/kubernetes/volume-snapshots/volumesnapshotclass.yaml b/examples/kubernetes/volume-snapshots/volumesnapshotclass.yaml index f05b880c..ec9daeb5 100644 --- a/examples/kubernetes/volume-snapshots/volumesnapshotclass.yaml +++ b/examples/kubernetes/volume-snapshots/volumesnapshotclass.yaml @@ -1,6 +1,3 @@ -# VolumeSnapshotClass defines how snapshots should be created for the cloudscale.ch CSI driver. -# This is a cluster-level resource that needs to be created once before using snapshots. -# Note: This may be deployed automatically with the driver in future releases. apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshotClass metadata: From 4a98d81e3368e5a9425e4d52a0f4ce51bfe0ea3c Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Wed, 4 Feb 2026 18:41:45 +0100 Subject: [PATCH 31/63] remove unnecessary if in driver/controller.go Co-authored-by: Michael Weibel <307427+mweibel@users.noreply.github.com> --- driver/controller.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 6559a941..45e5170e 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -709,10 +709,7 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ }, nil } - // Snapshot name exists but for a different volume - if snapshot.Volume.UUID != req.SourceVolumeId { - return nil, status.Error(codes.AlreadyExists, "snapshot with this name already exists for another volume") - } + return nil, status.Error(codes.AlreadyExists, "snapshot with this name already exists for another volume") } volumeSnapshotCreateRequest := &cloudscale.VolumeSnapshotCreateRequest{ From 509d43b812d945f1d8d7c7d467c30e63987c4a90 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Wed, 4 Feb 2026 18:42:37 +0100 Subject: [PATCH 32/63] fix typo in test/kubernetes/integration_test.go Co-authored-by: Michael Weibel <307427+mweibel@users.noreply.github.com> --- test/kubernetes/integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index 7c165edf..ac10d7b3 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -1926,7 +1926,7 @@ func makeKubernetesVolumeSnapshot(t *testing.T, snapshotName string, pvcName str if kubeerrors.IsNotFound(err) { t.Fatalf("VolumeSnapshotClass %q not found. "+ "This usually means the snapshot CRDs are not installed. "+ - "See the readme for setup installation instrucitons and and ensure the VolumeSnapshotClass resource exists. Error: %v", className, err) + "See the readme for setup installation instructions and and ensure the VolumeSnapshotClass resource exists. Error: %v", className, err) } t.Fatalf("Failed to get VolumeSnapshotClass %q: %v", className, err) } From 64f2d90dcf0f3a0e3771354407213b72ee6ccb5f Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Wed, 4 Feb 2026 18:48:27 +0100 Subject: [PATCH 33/63] replace static log level with .Values.snapshotter.logLevelVerbosity --- charts/csi-cloudscale/templates/statefulset.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/csi-cloudscale/templates/statefulset.yaml b/charts/csi-cloudscale/templates/statefulset.yaml index c2a0c27b..3ee47cce 100644 --- a/charts/csi-cloudscale/templates/statefulset.yaml +++ b/charts/csi-cloudscale/templates/statefulset.yaml @@ -76,7 +76,7 @@ spec: image: "{{ .Values.snapshotter.image.registry }}/{{ .Values.snapshotter.image.repository }}:{{ .Values.snapshotter.image.tag }}" args: - "--csi-address=$(CSI_ENDPOINT)" - - "--v=5" + - "--v={{ .Values.snapshotter.logLevelVerbosity }}" env: - name: CSI_ENDPOINT value: unix:///var/lib/csi/sockets/pluginproxy/csi.sock From f81b6e631d8b87abb8d73d0402e1308d34ead107 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Wed, 4 Feb 2026 19:02:12 +0100 Subject: [PATCH 34/63] improve check for existing snapshots --- driver/controller.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/driver/controller.go b/driver/controller.go index 45e5170e..733cfc02 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -692,6 +692,7 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ for _, snapshot := range snapshots { if snapshot.Volume.UUID == req.SourceVolumeId { + // Idempotent: snapshot with this name already exists for this volume t, err := time.Parse(time.RFC3339, snapshot.CreatedAt) if err != nil { return nil, status.Errorf(codes.Internal, "failed to parse snapshot CreatedAt timestamp %q: %v", snapshot.CreatedAt, err) @@ -708,7 +709,10 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ }, }, nil } + } + // If snapshots exist with this name but none match the source volume, reject + if len(snapshots) > 0 { return nil, status.Error(codes.AlreadyExists, "snapshot with this name already exists for another volume") } From 9065025a544e700290647927972b524e91d2d71c Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 5 Feb 2026 09:14:24 +0100 Subject: [PATCH 35/63] wire up resources into helm chart --- charts/csi-cloudscale/templates/statefulset.yaml | 4 ++++ charts/csi-cloudscale/values.yaml | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/charts/csi-cloudscale/templates/statefulset.yaml b/charts/csi-cloudscale/templates/statefulset.yaml index 3ee47cce..69a6a770 100644 --- a/charts/csi-cloudscale/templates/statefulset.yaml +++ b/charts/csi-cloudscale/templates/statefulset.yaml @@ -77,6 +77,10 @@ spec: args: - "--csi-address=$(CSI_ENDPOINT)" - "--v={{ .Values.snapshotter.logLevelVerbosity }}" + {{- with .Values.controller.resources }} + resources: +{{ toYaml . | indent 12 }} + {{- end }} env: - name: CSI_ENDPOINT value: unix:///var/lib/csi/sockets/pluginproxy/csi.sock diff --git a/charts/csi-cloudscale/values.yaml b/charts/csi-cloudscale/values.yaml index 9d0a05b1..6e1dd0a5 100644 --- a/charts/csi-cloudscale/values.yaml +++ b/charts/csi-cloudscale/values.yaml @@ -89,7 +89,12 @@ snapshotter: pullPolicy: IfNotPresent logLevelVerbosity: "5" resources: {} - +# limits: +# cpu: 100m +# memory: 128Mi +# requests: +# cpu: 100m +# memory: 128Mi controller: replicas: 1 From 6b54627cbeb4cce3092630559a744284c0f3df4a Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 5 Feb 2026 09:17:32 +0100 Subject: [PATCH 36/63] Apply suggestion from @mweibel, simplify error creation Co-authored-by: Michael Weibel <307427+mweibel@users.noreply.github.com> --- driver/controller.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 733cfc02..213f24de 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -229,8 +229,7 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo return nil, status.Errorf(codes.NotFound, "source snapshot %s not found", sourceSnapshotID) } } - wrapped := fmt.Errorf("failed to get source snapshot: %w", err) - return nil, status.Error(codes.Internal, wrapped.Error()) + return nil, status.Errorf(codes.Internal, "failed to get source snapshot: %v", err) } ll = ll.WithFields(logrus.Fields{ From 765446da33ff2d1095666d9b6413cdcc68d344b3 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 5 Feb 2026 16:23:42 +0100 Subject: [PATCH 37/63] improve idempodency handling and csiVolume creation for createVolumeFromSnapshot --- driver/controller.go | 80 +++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 733cfc02..30ab8375 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -303,67 +303,69 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo return nil, status.Error(codes.Internal, err.Error()) } - csiVolume := csi.Volume{ - CapacityBytes: int64(snapshot.SizeGB) * GB, - AccessibleTopology: []*csi.Topology{ - { - Segments: map[string]string{ - topologyZonePrefix: d.zone, - }, - }, - }, - VolumeContext: map[string]string{ - PublishInfoVolumeName: volumeName, - LuksEncryptedAttribute: luksEncrypted, - }, - ContentSource: req.GetVolumeContentSource(), - } + var createdVolume *cloudscale.Volume - if luksEncrypted == "true" { - csiVolume.VolumeContext[LuksCipherAttribute] = req.Parameters[LuksCipherAttribute] - csiVolume.VolumeContext[LuksKeySizeAttribute] = req.Parameters[LuksKeySizeAttribute] - } - - // Volume already exists - validate it matches request if len(volumes) != 0 { + // Volume already exists - validate it matches request if len(volumes) > 1 { return nil, fmt.Errorf("fatal issue: duplicate volume %q exists", volumeName) } - vol := volumes[0] + createdVolume = &volumes[0] - if vol.SizeGB != snapshot.SizeGB { + if createdVolume.SizeGB != snapshot.SizeGB { + // todo: volume could already be resized, I'm not sure we need to enforce this return nil, status.Errorf(codes.AlreadyExists, "volume %q already exists with size %d GB, but snapshot requires %d GB", - volumeName, vol.SizeGB, snapshot.SizeGB) + volumeName, createdVolume.SizeGB, snapshot.SizeGB) } - if vol.Zone != snapshot.Zone { + if createdVolume.Zone.Slug != snapshot.Zone.Slug { + // todo: if the zone does not match the one requested, something went wrong. possibly a manually created volume with the same name. return nil, status.Errorf(codes.AlreadyExists, "volume %q already exists in zone %s, but snapshot is in zone %s", - volumeName, vol.Zone, snapshot.Zone) + volumeName, createdVolume.Zone.Slug, snapshot.Zone.Slug) } ll.Info("volume from snapshot already exists") - csiVolume.VolumeId = vol.UUID - return &csi.CreateVolumeResponse{Volume: &csiVolume}, nil + + } else { + // Volume does not exist, create volume from snapshot + volumeReq := &cloudscale.VolumeCreateRequest{ + Name: volumeName, + VolumeSnapshotUUID: sourceSnapshotID, + // Size, Type, Zone are inherited from snapshot - do NOT set them + } + + ll.WithField("volume_req", volumeReq).Info("creating volume from snapshot") + createdVolume, err = d.cloudscaleClient.Volumes.Create(ctx, volumeReq) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create volume from snapshot: %v", err) + } } - // Create volume from snapshot - volumeReq := &cloudscale.VolumeCreateRequest{ - Name: volumeName, - VolumeSnapshotUUID: sourceSnapshotID, - // Size, Type, Zone are inherited from snapshot - do NOT set them + csiVolume := csi.Volume{ + VolumeId: createdVolume.UUID, + CapacityBytes: int64(createdVolume.SizeGB) * GB, + AccessibleTopology: []*csi.Topology{ + { + Segments: map[string]string{ + topologyZonePrefix: createdVolume.Zone.Slug, + }, + }, + }, + VolumeContext: map[string]string{ + PublishInfoVolumeName: volumeName, + LuksEncryptedAttribute: luksEncrypted, + }, + ContentSource: req.GetVolumeContentSource(), } - ll.WithField("volume_req", volumeReq).Info("creating volume from snapshot") - vol, err := d.cloudscaleClient.Volumes.Create(ctx, volumeReq) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to create volume from snapshot: %v", err) + if luksEncrypted == "true" { + csiVolume.VolumeContext[LuksCipherAttribute] = req.Parameters[LuksCipherAttribute] + csiVolume.VolumeContext[LuksKeySizeAttribute] = req.Parameters[LuksKeySizeAttribute] } - csiVolume.VolumeId = vol.UUID resp := &csi.CreateVolumeResponse{Volume: &csiVolume} - ll.WithField("response", resp).Info("volume created from snapshot") return resp, nil } From ddb32a64306cd735d935b49635f230d3fb1151f0 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 5 Feb 2026 17:36:45 +0100 Subject: [PATCH 38/63] cleanup logging, comments and error code if size does not match --- driver/controller.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 30ab8375..e35cbc7b 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -142,7 +142,7 @@ func (d *Driver) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) }) ll.Info("create volume called") - // get volume first, if it's created do no thing + // get volume first, if it's created do nothing volumes, err := d.cloudscaleClient.Volumes.List(ctx, cloudscale.WithNameFilter(volumeName)) if err != nil { return nil, status.Error(codes.Internal, err.Error()) @@ -171,7 +171,7 @@ func (d *Driver) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) // volume already exist, do nothing if len(volumes) != 0 { if len(volumes) > 1 { - return nil, fmt.Errorf("fatal issue: duplicate volume %q exists", volumeName) + return nil, status.Errorf(codes.Internal, "fatal issue: duplicate volume %q exists", volumeName) } vol := volumes[0] @@ -247,12 +247,13 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo if requiredBytes > 0 { requiredGB := int(requiredBytes / GB) if requiredGB < snapshot.SizeGB { - return nil, status.Errorf(codes.InvalidArgument, + return nil, status.Errorf(codes.OutOfRange, "requested volume size (%d GB) is smaller than snapshot size (%d GB)", requiredGB, snapshot.SizeGB) } if requiredGB > snapshot.SizeGB { - return nil, status.Errorf(codes.InvalidArgument, + // todo: we could just do this after creation of the volume... + return nil, status.Errorf(codes.OutOfRange, "cloudscale.ch API does not support creating volumes larger than snapshot size during restore. "+ "Create volume from snapshot first, then expand it using ControllerExpandVolume. "+ "Requested: %d GB, Snapshot: %d GB", requiredGB, snapshot.SizeGB) @@ -312,22 +313,22 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo } createdVolume = &volumes[0] + // cloudscale API does not provide the source snapshot of a volume, + // if this would be provided, the idempotency check could be improved. + if createdVolume.SizeGB != snapshot.SizeGB { - // todo: volume could already be resized, I'm not sure we need to enforce this return nil, status.Errorf(codes.AlreadyExists, - "volume %q already exists with size %d GB, but snapshot requires %d GB", + "volume %q already exists with size %d GB (incompatible with snapshot size %d GB)", volumeName, createdVolume.SizeGB, snapshot.SizeGB) } if createdVolume.Zone.Slug != snapshot.Zone.Slug { - // todo: if the zone does not match the one requested, something went wrong. possibly a manually created volume with the same name. return nil, status.Errorf(codes.AlreadyExists, - "volume %q already exists in zone %s, but snapshot is in zone %s", + "volume %q already exists in zone %s (incompatible with snapshot zone %s)", volumeName, createdVolume.Zone.Slug, snapshot.Zone.Slug) } - ll.Info("volume from snapshot already exists") - + ll.WithField("volume_id", createdVolume.UUID).Info("volume from snapshot already exists") } else { // Volume does not exist, create volume from snapshot volumeReq := &cloudscale.VolumeCreateRequest{ @@ -721,7 +722,6 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ volumeSnapshotCreateRequest := &cloudscale.VolumeSnapshotCreateRequest{ Name: req.Name, SourceVolume: req.SourceVolumeId, - // todo: Tags are not currently supported in snapshot creation } ll.WithField("volume_snapshot_create_request", volumeSnapshotCreateRequest).Info("creating volume snapshot") From 93ad635a26b451239e79adf59f9b249e165f2527 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 5 Feb 2026 17:57:15 +0100 Subject: [PATCH 39/63] remove TestPod_Single_SSD_Volume_Snapshot as it is already covered in TestPod_Create_Volume_From_Snapshot, remove unused client param --- test/kubernetes/integration_test.go | 88 +++-------------------------- 1 file changed, 9 insertions(+), 79 deletions(-) diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index ac10d7b3..1dc47882 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -161,76 +161,6 @@ func TestPod_Single_SSD_Volume(t *testing.T) { waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) } -func TestPod_Single_SSD_Volume_Snapshot(t *testing.T) { - podDescriptor := TestPodDescriptor{ - Kind: "Pod", - Name: pseudoUuid(), - Volumes: []TestPodVolume{ - { - ClaimName: "csi-pod-ssd-pvc", - SizeGB: 5, - StorageClass: "cloudscale-volume-ssd", - }, - }, - } - - // submit the pod and the pvc - pod := makeKubernetesPod(t, podDescriptor) - pvcs := makeKubernetesPVCs(t, podDescriptor) - assert.Equal(t, 1, len(pvcs)) - - // wait for the pod to be running and verify that the pvc is bound - waitForPod(t, client, pod.Name) - pvc := getPVC(t, client, pvcs[0].Name) - assert.Equal(t, v1.ClaimBound, pvc.Status.Phase) - - // load the volume from the cloudscale.ch api and verify that it - // has the requested size and volume type - volume := getCloudscaleVolume(t, pvc.Spec.VolumeName) - assert.Equal(t, 5, volume.SizeGB) - assert.Equal(t, "ssd", volume.Type) - - // verify that our disk is not luks-encrypted, formatted with ext4 and 5 GB big - disk, err := getVolumeInfo(t, pod, pvc.Spec.VolumeName) - assert.NoError(t, err) - assert.Equal(t, "", disk.Luks) - assert.Equal(t, "Filesystem", disk.PVCVolumeMode) - assert.Equal(t, "ext4", disk.Filesystem) - assert.Equal(t, 5*driver.GB, disk.DeviceSize) - assert.Equal(t, 5*driver.GB, disk.FilesystemSize) - - // create a snapshot of the volume - snapshotName := pseudoUuid() - snapshot := makeKubernetesVolumeSnapshot(t, snapshotName, pvc.Name) - - // wait for the snapshot to be ready - waitForVolumeSnapshot(t, client, snapshot.Name) - snapshot = getVolumeSnapshot(t, client, snapshot.Name) - assert.NotNil(t, snapshot.Status) - assert.NotNil(t, snapshot.Status.BoundVolumeSnapshotContentName) - assert.True(t, *snapshot.Status.ReadyToUse) - - snapshotContent := getVolumeSnapshotContent(t, *snapshot.Status.BoundVolumeSnapshotContentName) - assert.NotNil(t, snapshotContent.Status) - assert.NotNil(t, snapshotContent.Status.SnapshotHandle) - - cloudscaleSnapshot := getCloudscaleVolumeSnapshot(t, *snapshotContent.Status.SnapshotHandle) - assert.NotNil(t, cloudscaleSnapshot) - assert.Equal(t, *snapshotContent.Status.SnapshotHandle, cloudscaleSnapshot.UUID) - assert.Equal(t, "available", cloudscaleSnapshot.Status) - assert.Equal(t, 5, cloudscaleSnapshot.SizeGB) - - // delete the snapshot before deleting the volume - deleteKubernetesVolumeSnapshot(t, snapshot.Name) - waitCloudscaleVolumeSnapshotDeleted(t, *snapshotContent.Status.SnapshotHandle) - - // delete the pod and the pvcs and wait until the volume was deleted from - // the cloudscale.ch account; this check is necessary to test that the - // csi-plugin properly deletes the volume from cloudscale.ch - cleanup(t, podDescriptor) - waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) -} - func TestPod_Create_Volume_From_Snapshot(t *testing.T) { podDescriptor := TestPodDescriptor{ Kind: "Pod", @@ -277,8 +207,8 @@ func TestPod_Create_Volume_From_Snapshot(t *testing.T) { snapshot := makeKubernetesVolumeSnapshot(t, snapshotName, pvc.Name) // wait for the snapshot to be ready - waitForVolumeSnapshot(t, client, snapshot.Name) - snapshot = getVolumeSnapshot(t, client, snapshot.Name) + waitForVolumeSnapshot(t, snapshot.Name) + snapshot = getVolumeSnapshot(t, snapshot.Name) assert.NotNil(t, snapshot.Status) assert.NotNil(t, snapshot.Status.BoundVolumeSnapshotContentName) assert.True(t, *snapshot.Status.ReadyToUse) @@ -396,8 +326,8 @@ func TestPod_Single_SSD_Luks_Volume_Snapshot(t *testing.T) { snapshot := makeKubernetesVolumeSnapshot(t, snapshotName, pvc.Name) // wait for the snapshot to be ready - waitForVolumeSnapshot(t, client, snapshot.Name) - snapshot = getVolumeSnapshot(t, client, snapshot.Name) + waitForVolumeSnapshot(t, snapshot.Name) + snapshot = getVolumeSnapshot(t, snapshot.Name) assert.NotNil(t, snapshot.Status) assert.NotNil(t, snapshot.Status.BoundVolumeSnapshotContentName) assert.True(t, *snapshot.Status.ReadyToUse) @@ -496,8 +426,8 @@ func TestPod_Snapshot_Size_Validation(t *testing.T) { // Create snapshot snapshotName := pseudoUuid() snapshot := makeKubernetesVolumeSnapshot(t, snapshotName, pvc.Name) - waitForVolumeSnapshot(t, client, snapshot.Name) - snapshot = getVolumeSnapshot(t, client, snapshot.Name) + waitForVolumeSnapshot(t, snapshot.Name) + snapshot = getVolumeSnapshot(t, snapshot.Name) assert.True(t, *snapshot.Status.ReadyToUse) snapshotContent := getVolumeSnapshotContent(t, *snapshot.Status.BoundVolumeSnapshotContentName) @@ -1973,13 +1903,13 @@ func deleteKubernetesVolumeSnapshot(t *testing.T, snapshotName string) { } // waitForVolumeSnapshot waits for the VolumeSnapshot to be ready -func waitForVolumeSnapshot(t *testing.T, client kubernetes.Interface, name string) { +func waitForVolumeSnapshot(t *testing.T, name string) { start := time.Now() t.Logf("Waiting for volume snapshot %q to be ready ...\n", name) for { - snapshot := getVolumeSnapshot(t, client, name) + snapshot := getVolumeSnapshot(t, name) if snapshot.Status != nil && snapshot.Status.ReadyToUse != nil && *snapshot.Status.ReadyToUse { t.Logf("Volume snapshot %q is ready\n", name) @@ -1997,7 +1927,7 @@ func waitForVolumeSnapshot(t *testing.T, client kubernetes.Interface, name strin } // getVolumeSnapshot retrieves the VolumeSnapshot with the given name -func getVolumeSnapshot(t *testing.T, client kubernetes.Interface, name string) *snapshotv1.VolumeSnapshot { +func getVolumeSnapshot(t *testing.T, name string) *snapshotv1.VolumeSnapshot { snapshot, err := snapshotClient.SnapshotV1().VolumeSnapshots(namespace).Get( context.Background(), name, From 60ef0a5da812eb5936950adba69b04662c85f941 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 9 Feb 2026 09:37:52 +0100 Subject: [PATCH 40/63] fix return code and add save guard to fake driver volume deletion --- driver/driver_test.go | 76 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/driver/driver_test.go b/driver/driver_test.go index 488c884f..2f386165 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -329,6 +329,10 @@ func getVolumesPerServer(f *FakeVolumeServiceOperations, serverUUID string) int } func (f *FakeVolumeServiceOperations) Delete(ctx context.Context, volumeID string) error { + _, ok := f.volumes[volumeID] + if ok != true { + return generateNotFoundError() + } // prevent deletion if snapshots exist snapshots, err := f.fakeClient.VolumeSnapshots.List(context.Background()) @@ -340,8 +344,8 @@ func (f *FakeVolumeServiceOperations) Delete(ctx context.Context, volumeID strin for _, snapshot := range snapshots { if snapshot.Volume.UUID == volumeID { return &cloudscale.ErrorResponse{ - StatusCode: 409, - Message: map[string]string{"detail": "volume has snapshots"}, + StatusCode: 400, + Message: map[string]string{"detail": "Snapshots exist for this volume"}, } } } @@ -819,3 +823,71 @@ func TestNodeOperations_CrossOperationLocking(t *testing.T) { execStage <- struct{}{} <-respStage } + +// TestDeleteVolume_FailsWhenSnapshotsExist verifies that DeleteVolume returns +// codes.FailedPrecondition when the volume has existing snapshots, matching +// the CSI spec requirement for volumes that cannot be deleted independently +// of their snapshots. +func TestDeleteVolume_FailsWhenSnapshotsExist(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + + // Create a volume + vol, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ + Name: "test-volume-with-snapshot", + VolumeCapabilities: makeVolumeCapabilityObject(false), + CapacityRange: &csi.CapacityRange{ + RequiredBytes: 1 * GB, + }, + Parameters: map[string]string{ + StorageTypeAttribute: "ssd", + }, + }) + if err != nil { + t.Fatalf("Failed to create volume: %v", err) + } + volumeID := vol.Volume.VolumeId + + // Create a snapshot on the volume + snap, err := driver.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{ + Name: "test-snapshot", + SourceVolumeId: volumeID, + }) + if err != nil { + t.Fatalf("Failed to create snapshot: %v", err) + } + if snap.Snapshot.SnapshotId == "" { + t.Fatalf("Expected non-empty snapshot ID") + } + + // Attempt to delete the volume — should fail with FailedPrecondition + _, err = driver.DeleteVolume(ctx, &csi.DeleteVolumeRequest{ + VolumeId: volumeID, + }) + if err == nil { + t.Fatalf("Expected error when deleting volume with snapshots, got nil") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatalf("Expected gRPC status error, got: %v", err) + } + if st.Code() != codes.FailedPrecondition { + t.Errorf("Expected error code FailedPrecondition, got: %v", st.Code()) + } + + // Delete the snapshot, then delete the volume, should succeed now + _, err = driver.DeleteSnapshot(ctx, &csi.DeleteSnapshotRequest{ + SnapshotId: snap.Snapshot.SnapshotId, + }) + if err != nil { + t.Fatalf("Failed to delete snapshot: %v", err) + } + + _, err = driver.DeleteVolume(ctx, &csi.DeleteVolumeRequest{ + VolumeId: volumeID, + }) + if err != nil { + t.Fatalf("Expected volume deletion to succeed after snapshot removal, got: %v", err) + } +} From 52a341ecb5bf60997239fd94e18298f10cbc9b30 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 9 Feb 2026 17:17:14 +0100 Subject: [PATCH 41/63] improve wait in Snapshot_Size_Validation with better polling --- test/kubernetes/integration_test.go | 123 +++++++++++++--------------- 1 file changed, 56 insertions(+), 67 deletions(-) diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index 1dc47882..5010b462 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -436,58 +436,23 @@ func TestPod_Snapshot_Size_Validation(t *testing.T) { cloudscaleSnapshot := getCloudscaleVolumeSnapshot(t, snapshotHandle) assert.Equal(t, 5, cloudscaleSnapshot.SizeGB) - // Attempt to restore with smaller size (should fail) - // Create PVC directly without pod (since it won't bind) - smallerPVCName := "csi-pod-snapshot-size-pvc-smaller" + // Note: restoring with a smaller size is not tested here because the + // Kubernetes external-provisioner enforces that the requested PVC size + // is at least the snapshot's restore size. If a smaller size is requested, + // the provisioner automatically adjusts it before invoking the CSI driver. + // As a result, CreateVolume is never called with a smaller size in practice. + // The driver's own smaller-size validation in createVolumeFromSnapshot is + // therefore defense-in-depth only. + + // Attempt to restore with a larger size (expected to fail for this driver). + // The Kubernetes external-provisioner forwards the requested size unchanged + // when it is larger than the snapshot restore size and calls the CSI driver's + // CreateVolume. Our driver rejects such requests with codes.OutOfRange. + // The external-provisioner surfaces any CreateVolume failure as a + // ProvisioningFailed event on the PVC, which we use to verify this. + largerPVCName := "csi-pod-snapshot-size-pvc-larger" volMode := v1.PersistentVolumeFilesystem apiGroup := "snapshot.storage.k8s.io" - smallerPVC := &v1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: smallerPVCName, - }, - Spec: v1.PersistentVolumeClaimSpec{ - VolumeMode: &volMode, - AccessModes: []v1.PersistentVolumeAccessMode{ - v1.ReadWriteOnce, - }, - Resources: v1.ResourceRequirements{ - Requests: v1.ResourceList{ - v1.ResourceStorage: resource.MustParse("3Gi"), // Smaller than snapshot size (5GB) - }, - }, - StorageClassName: strPtr("cloudscale-volume-ssd"), - DataSource: &v1.TypedLocalObjectReference{ - APIGroup: &apiGroup, - Kind: "VolumeSnapshot", - Name: snapshot.Name, - }, - }, - } - - t.Log("Creating PVC from snapshot with smaller size (should fail)") - _, err := client.CoreV1().PersistentVolumeClaims(namespace).Create(context.Background(), smallerPVC, metav1.CreateOptions{}) - assert.NoError(t, err) - - // Wait a bit for the PVC to be processed - time.Sleep(10 * time.Second) - - // Check that PVC is not bound (should fail) - smallerPVC = getPVC(t, client, smallerPVCName) - assert.NotEqual(t, v1.ClaimBound, smallerPVC.Status.Phase, "PVC with smaller size should not be bound") - assert.Equal(t, v1.ClaimPending, smallerPVC.Status.Phase, "PVC should be in Pending state due to size validation failure") - - // Verify no volume was created - if smallerPVC.Spec.VolumeName != "" { - t.Logf("Warning: Volume was created despite size validation failure: %s", smallerPVC.Spec.VolumeName) - } - - // Cleanup failed PVC - err = client.CoreV1().PersistentVolumeClaims(namespace).Delete(context.Background(), smallerPVCName, metav1.DeleteOptions{}) - assert.NoError(t, err) - - // Attempt to restore with larger size (should fail) - // Create PVC directly without pod (since it won't bind) - largerPVCName := "csi-pod-snapshot-size-pvc-larger" largerPVC := &v1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: largerPVCName, @@ -512,21 +477,11 @@ func TestPod_Snapshot_Size_Validation(t *testing.T) { } t.Log("Creating PVC from snapshot with larger size (should fail)") - _, err = client.CoreV1().PersistentVolumeClaims(namespace).Create(context.Background(), largerPVC, metav1.CreateOptions{}) + _, err := client.CoreV1().PersistentVolumeClaims(namespace).Create(context.Background(), largerPVC, metav1.CreateOptions{}) assert.NoError(t, err) - // Wait a bit for the PVC to be processed - time.Sleep(10 * time.Second) - - // Check that PVC is not bound (should fail) - largerPVC = getPVC(t, client, largerPVCName) - assert.NotEqual(t, v1.ClaimBound, largerPVC.Status.Phase, "PVC with larger size should not be bound") - assert.Equal(t, v1.ClaimPending, largerPVC.Status.Phase, "PVC should be in Pending state due to size validation failure") - - // Verify no volume was created - if largerPVC.Spec.VolumeName != "" { - t.Logf("Warning: Volume was created despite size validation failure: %s", largerPVC.Spec.VolumeName) - } + // Wait for the provisioner to reject the PVC with an OutOfRange error + waitForPVCEvent(t, client, largerPVCName, "ProvisioningFailed", "does not support creating volumes larger", 60*time.Second) // Cleanup failed PVC err = client.CoreV1().PersistentVolumeClaims(namespace).Delete(context.Background(), largerPVCName, metav1.DeleteOptions{}) @@ -1554,6 +1509,38 @@ func getPVC(t *testing.T, client kubernetes.Interface, name string) *v1.Persiste return claim } +// waitForPVCEvent polls Kubernetes events for the given PVC until an event +// with the specified reason and a message containing expectedSubstring appears. +func waitForPVCEvent(t *testing.T, client kubernetes.Interface, pvcName string, reason string, expectedSubstring string, timeout time.Duration) { + t.Helper() + start := time.Now() + t.Logf("Waiting for event (reason=%q, substring=%q) on PVC %q ...", reason, expectedSubstring, pvcName) + + for { + events, err := client.CoreV1().Events(namespace).List(context.Background(), metav1.ListOptions{ + FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.kind=PersistentVolumeClaim,reason=%s", pvcName, reason), + }) + if err != nil { + t.Errorf("Failed to list events for PVC %q: %v", pvcName, err) + return + } + + for _, event := range events.Items { + if strings.Contains(event.Message, expectedSubstring) { + t.Logf("Found expected event on PVC %q: %s", pvcName, event.Message) + return + } + } + + if time.Since(start) > timeout { + t.Errorf("Timeout waiting for event (reason=%q, substring=%q) on PVC %q", reason, expectedSubstring, pvcName) + return + } + + time.Sleep(2 * time.Second) + } +} + // loads the pod with the given name from kubernetes func getPod(t *testing.T, client kubernetes.Interface, name string) *v1.Pod { pod, err := client.CoreV1().Pods(namespace).Get(context.Background(), name, metav1.GetOptions{}) @@ -1854,11 +1841,13 @@ func makeKubernetesVolumeSnapshot(t *testing.T, snapshotName string, pvcName str ) if err != nil { if kubeerrors.IsNotFound(err) { - t.Fatalf("VolumeSnapshotClass %q not found. "+ + t.Errorf("VolumeSnapshotClass %q not found. "+ "This usually means the snapshot CRDs are not installed. "+ - "See the readme for setup installation instructions and and ensure the VolumeSnapshotClass resource exists. Error: %v", className, err) + "See the readme for setup installation instructions and ensure the VolumeSnapshotClass resource exists. Error: %v", className, err) + return nil } - t.Fatalf("Failed to get VolumeSnapshotClass %q: %v", className, err) + t.Errorf("Failed to get VolumeSnapshotClass %q: %v", className, err) + return nil } snapshot := &snapshotv1.VolumeSnapshot{ @@ -1917,7 +1906,7 @@ func waitForVolumeSnapshot(t *testing.T, name string) { } if time.Now().UnixNano()-start.UnixNano() > (5 * time.Minute).Nanoseconds() { - t.Fatalf("timeout exceeded while waiting for volume snapshot %v to be ready", name) + t.Errorf("timeout exceeded while waiting for volume snapshot %v to be ready", name) return } From 67ed280dbecc0e5faa76a43f1e166838d6873302 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 9 Feb 2026 17:54:50 +0100 Subject: [PATCH 42/63] simplified waitForVolumeSnapshot --- test/kubernetes/integration_test.go | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index 5010b462..df5a2e03 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -34,6 +34,7 @@ import ( "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" "k8s.io/client-go/tools/clientcmd" @@ -1893,25 +1894,22 @@ func deleteKubernetesVolumeSnapshot(t *testing.T, snapshotName string) { // waitForVolumeSnapshot waits for the VolumeSnapshot to be ready func waitForVolumeSnapshot(t *testing.T, name string) { - start := time.Now() - - t.Logf("Waiting for volume snapshot %q to be ready ...\n", name) + t.Logf("Waiting for volume snapshot %q to be ready...", name) - for { + err := wait.PollUntilContextTimeout(t.Context(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (done bool, err error) { snapshot := getVolumeSnapshot(t, name) if snapshot.Status != nil && snapshot.Status.ReadyToUse != nil && *snapshot.Status.ReadyToUse { - t.Logf("Volume snapshot %q is ready\n", name) - return - } - - if time.Now().UnixNano()-start.UnixNano() > (5 * time.Minute).Nanoseconds() { - t.Errorf("timeout exceeded while waiting for volume snapshot %v to be ready", name) - return + t.Logf("Volume snapshot %q is ready", name) + return true, nil } t.Logf("Volume snapshot %q not ready yet; waiting...", name) - time.Sleep(5 * time.Second) + return false, nil + }) + + if err != nil { + t.Errorf("failed waiting for volume snapshot %q: %v", name, err) } } From 7e856bae27ecedad2f1057f5e2052b480f0f1b4d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:39:41 +0000 Subject: [PATCH 43/63] Bump golang.org/x/sys from 0.39.0 to 0.41.0 Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.39.0 to 0.41.0. - [Commits](https://github.com/golang/sys/compare/v0.39.0...v0.41.0) --- updated-dependencies: - dependency-name: golang.org/x/sys dependency-version: 0.41.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a65da1cf..8392c617 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.34.0 - golang.org/x/sys v0.39.0 + golang.org/x/sys v0.41.0 google.golang.org/grpc v1.77.0 k8s.io/api v0.28.15 k8s.io/apimachinery v0.28.15 diff --git a/go.sum b/go.sum index efe880fb..a2aa49ca 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= From 8e8c6982241ec6db1873f055340af48e7e245f6d Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 12 Feb 2026 18:46:29 +0100 Subject: [PATCH 44/63] add ControllerServiceCapability_RPC_LIST_SNAPSHOTS capability --- driver/controller.go | 151 ++++++++++++++++----- driver/driver_test.go | 200 ++++++++++++++++++++++++++++ test/kubernetes/integration_test.go | 92 +++++++++++++ 3 files changed, 413 insertions(+), 30 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 7f9f66d3..2915f242 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -23,6 +23,7 @@ import ( "fmt" "net/http" "regexp" + "sort" "strconv" "strings" "time" @@ -643,7 +644,7 @@ func (d *Driver) ControllerGetCapabilities(ctx context.Context, req *csi.Control csi.ControllerServiceCapability_RPC_LIST_VOLUMES, csi.ControllerServiceCapability_RPC_EXPAND_VOLUME, csi.ControllerServiceCapability_RPC_CREATE_DELETE_SNAPSHOT, - // csi.ControllerServiceCapability_RPC_LIST_SNAPSHOTS, + csi.ControllerServiceCapability_RPC_LIST_SNAPSHOTS, // TODO: check if this can be implemented // csi.ControllerServiceCapability_RPC_GET_CAPACITY, @@ -662,6 +663,21 @@ func (d *Driver) ControllerGetCapabilities(ctx context.Context, req *csi.Control return resp, nil } +// toCSISnapshot converts a cloudscale VolumeSnapshot to a CSI Snapshot. +func toCSISnapshot(snap cloudscale.VolumeSnapshot) (*csi.Snapshot, error) { + t, err := time.Parse(time.RFC3339, snap.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to parse snapshot CreatedAt timestamp %q: %w", snap.CreatedAt, err) + } + return &csi.Snapshot{ + SnapshotId: snap.UUID, + SourceVolumeId: snap.Volume.UUID, + ReadyToUse: snap.Status == "available", + SizeBytes: int64(snap.SizeGB * GB), + CreationTime: timestamppb.New(t), + }, nil +} + // CreateSnapshot will be called by the CO to create a new snapshot from a // source volume on behalf of a user. func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequest) (*csi.CreateSnapshotResponse, error) { @@ -695,21 +711,11 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ for _, snapshot := range snapshots { if snapshot.Volume.UUID == req.SourceVolumeId { // Idempotent: snapshot with this name already exists for this volume - t, err := time.Parse(time.RFC3339, snapshot.CreatedAt) + csiSnap, err := toCSISnapshot(snapshot) if err != nil { - return nil, status.Errorf(codes.Internal, "failed to parse snapshot CreatedAt timestamp %q: %v", snapshot.CreatedAt, err) + return nil, status.Errorf(codes.Internal, "%v", err) } - creationTime := timestamppb.New(t) - - return &csi.CreateSnapshotResponse{ - Snapshot: &csi.Snapshot{ - SnapshotId: snapshot.UUID, - SourceVolumeId: snapshot.Volume.UUID, - ReadyToUse: snapshot.Status == "available", - SizeBytes: int64(snapshot.SizeGB * GB), - CreationTime: creationTime, - }, - }, nil + return &csi.CreateSnapshotResponse{Snapshot: csiSnap}, nil } } @@ -740,21 +746,12 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ return nil, status.Errorf(codes.Internal, "failed to create snapshot: %v", err) } - t, err := time.Parse(time.RFC3339, snapshot.CreatedAt) + csiSnap, err := toCSISnapshot(*snapshot) if err != nil { - return nil, status.Errorf(codes.Internal, "failed to parse snapshot CreatedAt timestamp %q: %v", snapshot.CreatedAt, err) + return nil, status.Errorf(codes.Internal, "%v", err) } - creationTime := timestamppb.New(t) - resp := &csi.CreateSnapshotResponse{ - Snapshot: &csi.Snapshot{ - SnapshotId: snapshot.UUID, - SourceVolumeId: snapshot.Volume.UUID, - ReadyToUse: snapshot.Status == "available", // check status - SizeBytes: int64(snapshot.SizeGB * GB), - CreationTime: creationTime, - }, - } + resp := &csi.CreateSnapshotResponse{Snapshot: csiSnap} ll.WithField("response", resp).Info("volume snapshot created") return resp, nil @@ -798,14 +795,108 @@ func (d *Driver) DeleteSnapshot(ctx context.Context, req *csi.DeleteSnapshotRequ // ListSnapshots returns the information about all snapshots on the storage // system within the given parameters regardless of how they were created. -// ListSnapshots shold not list a snapshot that is being created but has not +// ListSnapshots should not list a snapshot that is being created but has not // been cut successfully yet. func (d *Driver) ListSnapshots(ctx context.Context, req *csi.ListSnapshotsRequest) (*csi.ListSnapshotsResponse, error) { - d.log.WithFields(logrus.Fields{ + ll := d.log.WithFields(logrus.Fields{ "req": req, "method": "list_snapshots", - }).Warn("list snapshots is not implemented") - return nil, status.Error(codes.Unimplemented, "") + }) + ll.Info("list snapshots called") + + // If snapshot_id is specified, use Get() for a direct lookup. + if req.SnapshotId != "" { + snap, err := d.cloudscaleClient.VolumeSnapshots.Get(ctx, req.SnapshotId) + if err != nil { + var errResp *cloudscale.ErrorResponse + if errors.As(err, &errResp) && errResp.StatusCode == http.StatusNotFound { + // Per CSI spec: if snapshot_id is specified and not found, return empty. + return &csi.ListSnapshotsResponse{}, nil + } + return nil, status.Errorf(codes.Internal, "failed to get snapshot %s: %v", req.SnapshotId, err) + } + + // Apply source_volume_id filter if both specified. + if req.SourceVolumeId != "" && snap.Volume.UUID != req.SourceVolumeId { + return &csi.ListSnapshotsResponse{}, nil + } + + // Exclude non-available snapshots. + if snap.Status != "available" { + return &csi.ListSnapshotsResponse{}, nil + } + + csiSnap, err := toCSISnapshot(*snap) + if err != nil { + return nil, status.Errorf(codes.Internal, "%v", err) + } + return &csi.ListSnapshotsResponse{ + Entries: []*csi.ListSnapshotsResponse_Entry{{Snapshot: csiSnap}}, + }, nil + } + + // List all snapshots from the cloudscale API. + allSnapshots, err := d.cloudscaleClient.VolumeSnapshots.List(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list snapshots: %v", err) + } + + // Filter by source_volume_id and status. + var filtered []cloudscale.VolumeSnapshot + for _, snap := range allSnapshots { + if snap.Status != "available" { + continue + } + if req.SourceVolumeId != "" && snap.Volume.UUID != req.SourceVolumeId { + continue + } + filtered = append(filtered, snap) + } + + // Sort by UUID for deterministic pagination order. + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].UUID < filtered[j].UUID + }) + + // Parse starting_token as an integer index. + startIndex := 0 + if req.StartingToken != "" { + startIndex, err = strconv.Atoi(req.StartingToken) + if err != nil || startIndex < 0 || startIndex > len(filtered) { + return nil, status.Errorf(codes.Aborted, + "invalid starting_token %q, start ListSnapshots with an empty starting_token", + req.StartingToken) + } + } + + // Apply pagination. + remaining := filtered[startIndex:] + endCount := len(remaining) + if req.MaxEntries > 0 && int(req.MaxEntries) < endCount { + endCount = int(req.MaxEntries) + } + + var entries []*csi.ListSnapshotsResponse_Entry + for _, snap := range remaining[:endCount] { + csiSnap, err := toCSISnapshot(snap) + if err != nil { + return nil, status.Errorf(codes.Internal, "%v", err) + } + entries = append(entries, &csi.ListSnapshotsResponse_Entry{Snapshot: csiSnap}) + } + + var nextToken string + if startIndex+endCount < len(filtered) { + nextToken = strconv.Itoa(startIndex + endCount) + } + + resp := &csi.ListSnapshotsResponse{ + Entries: entries, + NextToken: nextToken, + } + + ll.WithField("response_entries", len(entries)).Info("snapshots listed") + return resp, nil } // ControllerExpandVolume is called from the resizer to increase the volume size. diff --git a/driver/driver_test.go b/driver/driver_test.go index 2f386165..7879a17c 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -824,6 +824,206 @@ func TestNodeOperations_CrossOperationLocking(t *testing.T) { <-respStage } +// createVolumeForTest is a helper that creates a CSI volume and returns the volume ID. +func createVolumeForTest(t *testing.T, driver *Driver, name string) string { + t.Helper() + vol, err := driver.CreateVolume(context.Background(), &csi.CreateVolumeRequest{ + Name: name, + VolumeCapabilities: makeVolumeCapabilityObject(false), + CapacityRange: &csi.CapacityRange{RequiredBytes: 1 * GB}, + Parameters: map[string]string{StorageTypeAttribute: "ssd"}, + }) + if err != nil { + t.Fatalf("Failed to create volume %s: %v", name, err) + } + return vol.Volume.VolumeId +} + +// createSnapshotForTest is a helper that creates a CSI snapshot and returns the snapshot response. +func createSnapshotForTest(t *testing.T, driver *Driver, name, volumeID string) *csi.CreateSnapshotResponse { + t.Helper() + snap, err := driver.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{ + Name: name, + SourceVolumeId: volumeID, + }) + if err != nil { + t.Fatalf("Failed to create snapshot %s: %v", name, err) + } + return snap +} + +func TestListSnapshots_All(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + + volID := createVolumeForTest(t, driver, "vol-list-all") + createSnapshotForTest(t, driver, "snap-1", volID) + createSnapshotForTest(t, driver, "snap-2", volID) + createSnapshotForTest(t, driver, "snap-3", volID) + + resp, err := driver.ListSnapshots(ctx, &csi.ListSnapshotsRequest{}) + if err != nil { + t.Fatalf("ListSnapshots returned error: %v", err) + } + if len(resp.Entries) != 3 { + t.Errorf("Expected 3 snapshots, got %d", len(resp.Entries)) + } + for _, entry := range resp.Entries { + if entry.Snapshot.SnapshotId == "" { + t.Error("Expected non-empty SnapshotId") + } + if entry.Snapshot.SourceVolumeId == "" { + t.Error("Expected non-empty SourceVolumeId") + } + if entry.Snapshot.CreationTime == nil { + t.Error("Expected non-nil CreationTime") + } + } +} + +func TestListSnapshots_BySnapshotId(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + + volID := createVolumeForTest(t, driver, "vol-by-id") + snap1 := createSnapshotForTest(t, driver, "snap-target", volID) + createSnapshotForTest(t, driver, "snap-other", volID) + + resp, err := driver.ListSnapshots(ctx, &csi.ListSnapshotsRequest{ + SnapshotId: snap1.Snapshot.SnapshotId, + }) + if err != nil { + t.Fatalf("ListSnapshots returned error: %v", err) + } + if len(resp.Entries) != 1 { + t.Fatalf("Expected 1 snapshot, got %d", len(resp.Entries)) + } + if resp.Entries[0].Snapshot.SnapshotId != snap1.Snapshot.SnapshotId { + t.Errorf("Expected snapshot ID %s, got %s", snap1.Snapshot.SnapshotId, resp.Entries[0].Snapshot.SnapshotId) + } +} + +func TestListSnapshots_BySnapshotId_NotFound(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + + resp, err := driver.ListSnapshots(ctx, &csi.ListSnapshotsRequest{ + SnapshotId: "non-existent-id", + }) + if err != nil { + t.Fatalf("Expected no error for non-existent snapshot ID, got: %v", err) + } + if len(resp.Entries) != 0 { + t.Errorf("Expected empty entries for non-existent snapshot ID, got %d", len(resp.Entries)) + } +} + +func TestListSnapshots_BySourceVolumeId(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + + volID1 := createVolumeForTest(t, driver, "vol-source-1") + volID2 := createVolumeForTest(t, driver, "vol-source-2") + createSnapshotForTest(t, driver, "snap-vol1", volID1) + createSnapshotForTest(t, driver, "snap-vol2", volID2) + + resp, err := driver.ListSnapshots(ctx, &csi.ListSnapshotsRequest{ + SourceVolumeId: volID1, + }) + if err != nil { + t.Fatalf("ListSnapshots returned error: %v", err) + } + if len(resp.Entries) != 1 { + t.Fatalf("Expected 1 snapshot for volume %s, got %d", volID1, len(resp.Entries)) + } + if resp.Entries[0].Snapshot.SourceVolumeId != volID1 { + t.Errorf("Expected source volume ID %s, got %s", volID1, resp.Entries[0].Snapshot.SourceVolumeId) + } +} + +func TestListSnapshots_BySourceVolumeId_NotFound(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + + resp, err := driver.ListSnapshots(ctx, &csi.ListSnapshotsRequest{ + SourceVolumeId: "non-existent-volume", + }) + if err != nil { + t.Fatalf("Expected no error for non-existent source volume, got: %v", err) + } + if len(resp.Entries) != 0 { + t.Errorf("Expected empty entries for non-existent source volume, got %d", len(resp.Entries)) + } +} + +func TestListSnapshots_Pagination(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + + volID := createVolumeForTest(t, driver, "vol-pagination") + for i := 0; i < 5; i++ { + createSnapshotForTest(t, driver, "snap-page-"+strconv.Itoa(i), volID) + } + + // Request first page with max_entries=2 + resp, err := driver.ListSnapshots(ctx, &csi.ListSnapshotsRequest{ + MaxEntries: 2, + }) + if err != nil { + t.Fatalf("ListSnapshots returned error: %v", err) + } + if len(resp.Entries) != 2 { + t.Fatalf("Expected 2 entries on first page, got %d", len(resp.Entries)) + } + if resp.NextToken == "" { + t.Fatal("Expected non-empty NextToken for first page") + } + + // Request second page with starting_token + resp2, err := driver.ListSnapshots(ctx, &csi.ListSnapshotsRequest{ + StartingToken: resp.NextToken, + }) + if err != nil { + t.Fatalf("ListSnapshots with starting_token returned error: %v", err) + } + if len(resp2.Entries) != 3 { + t.Fatalf("Expected 3 remaining entries, got %d", len(resp2.Entries)) + } + if resp2.NextToken != "" { + t.Errorf("Expected empty NextToken for last page, got %q", resp2.NextToken) + } + + // Verify no duplicate snapshot IDs between pages + seen := make(map[string]bool) + for _, e := range resp.Entries { + seen[e.Snapshot.SnapshotId] = true + } + for _, e := range resp2.Entries { + if seen[e.Snapshot.SnapshotId] { + t.Errorf("Duplicate snapshot ID %s across pages", e.Snapshot.SnapshotId) + } + } +} + +func TestListSnapshots_InvalidStartingToken(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + + _, err := driver.ListSnapshots(ctx, &csi.ListSnapshotsRequest{ + StartingToken: "not-a-number", + }) + if err == nil { + t.Fatal("Expected error for invalid starting_token, got nil") + } + st, ok := status.FromError(err) + if !ok { + t.Fatalf("Expected gRPC status error, got: %v", err) + } + if st.Code() != codes.Aborted { + t.Errorf("Expected codes.Aborted, got %v", st.Code()) + } +} + // TestDeleteVolume_FailsWhenSnapshotsExist verifies that DeleteVolume returns // codes.FailedPrecondition when the volume has existing snapshots, matching // the CSI spec requirement for volumes that cannot be deleted independently diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index df5a2e03..7d02b78a 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -495,6 +495,98 @@ func TestPod_Snapshot_Size_Validation(t *testing.T) { waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) } +func TestListSnapshots_MultipleSnapshots(t *testing.T) { + // Create two independent volumes, each with one snapshot, to verify that + // the CSI driver correctly advertises LIST_SNAPSHOTS and the snapshot + // controller can reconcile multiple snapshots concurrently. + + podDescriptor1 := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-list-snap-pvc-1", + SizeGB: 5, + StorageClass: "cloudscale-volume-ssd", + }, + }, + } + podDescriptor2 := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-list-snap-pvc-2", + SizeGB: 5, + StorageClass: "cloudscale-volume-ssd", + }, + }, + } + + // Create both pods and PVCs + pod1 := makeKubernetesPod(t, podDescriptor1) + pvcs1 := makeKubernetesPVCs(t, podDescriptor1) + pod2 := makeKubernetesPod(t, podDescriptor2) + pvcs2 := makeKubernetesPVCs(t, podDescriptor2) + + // Wait for both pods to be running + waitForPod(t, client, pod1.Name) + waitForPod(t, client, pod2.Name) + pvc1 := getPVC(t, client, pvcs1[0].Name) + pvc2 := getPVC(t, client, pvcs2[0].Name) + assert.Equal(t, v1.ClaimBound, pvc1.Status.Phase) + assert.Equal(t, v1.ClaimBound, pvc2.Status.Phase) + + // Create a snapshot for each volume + snap1Name := pseudoUuid() + snap2Name := pseudoUuid() + snapshot1 := makeKubernetesVolumeSnapshot(t, snap1Name, pvc1.Name) + snapshot2 := makeKubernetesVolumeSnapshot(t, snap2Name, pvc2.Name) + + // Wait for both snapshots to be ready + waitForVolumeSnapshot(t, snapshot1.Name) + waitForVolumeSnapshot(t, snapshot2.Name) + + snapshot1 = getVolumeSnapshot(t, snapshot1.Name) + snapshot2 = getVolumeSnapshot(t, snapshot2.Name) + assert.True(t, *snapshot1.Status.ReadyToUse) + assert.True(t, *snapshot2.Status.ReadyToUse) + + // Retrieve the cloudscale snapshot handles + content1 := getVolumeSnapshotContent(t, *snapshot1.Status.BoundVolumeSnapshotContentName) + content2 := getVolumeSnapshotContent(t, *snapshot2.Status.BoundVolumeSnapshotContentName) + handle1 := *content1.Status.SnapshotHandle + handle2 := *content2.Status.SnapshotHandle + + // Verify both snapshots exist in the cloudscale API + csSnap1 := getCloudscaleVolumeSnapshot(t, handle1) + csSnap2 := getCloudscaleVolumeSnapshot(t, handle2) + assert.Equal(t, "available", csSnap1.Status) + assert.Equal(t, "available", csSnap2.Status) + + // Verify both snapshots appear in a full listing from the cloudscale API + allSnapshots, err := cloudscaleClient.VolumeSnapshots.List(context.Background()) + assert.NoError(t, err) + + foundHandles := map[string]bool{} + for _, s := range allSnapshots { + foundHandles[s.UUID] = true + } + assert.True(t, foundHandles[handle1], "snapshot 1 not found in cloudscale VolumeSnapshots.List()") + assert.True(t, foundHandles[handle2], "snapshot 2 not found in cloudscale VolumeSnapshots.List()") + + // Cleanup: delete snapshots before volumes (cloudscale requirement) + deleteKubernetesVolumeSnapshot(t, snapshot1.Name) + deleteKubernetesVolumeSnapshot(t, snapshot2.Name) + waitCloudscaleVolumeSnapshotDeleted(t, handle1) + waitCloudscaleVolumeSnapshotDeleted(t, handle2) + + cleanup(t, podDescriptor1) + cleanup(t, podDescriptor2) + waitCloudscaleVolumeDeleted(t, pvc1.Spec.VolumeName) + waitCloudscaleVolumeDeleted(t, pvc2.Spec.VolumeName) +} + func TestPod_Single_SSD_Raw_Volume(t *testing.T) { podDescriptor := TestPodDescriptor{ Kind: "Pod", From 9c575ca2f14319ae0cef5361a16cddc3443e595f Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Fri, 13 Feb 2026 11:09:54 +0100 Subject: [PATCH 45/63] add missing permissions to provisioner-role --- charts/csi-cloudscale/templates/rbac.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/charts/csi-cloudscale/templates/rbac.yaml b/charts/csi-cloudscale/templates/rbac.yaml index aa6cb9a0..e9c9c28e 100644 --- a/charts/csi-cloudscale/templates/rbac.yaml +++ b/charts/csi-cloudscale/templates/rbac.yaml @@ -6,7 +6,7 @@ metadata: rules: - apiGroups: [""] resources: ["persistentvolumes"] - verbs: ["get", "list", "watch", "create", "delete"] + verbs: ["get", "list", "watch", "create", "patch", "delete"] - apiGroups: [""] resources: ["persistentvolumeclaims"] verbs: ["get", "list", "watch", "update"] @@ -16,6 +16,12 @@ rules: - apiGroups: [""] resources: ["events"] verbs: ["list", "watch", "create", "update", "patch"] + - apiGroups: ["snapshot.storage.k8s.io"] + resources: ["volumesnapshots"] + verbs: ["get", "list", "watch", "update"] + - apiGroups: ["snapshot.storage.k8s.io"] + resources: ["volumesnapshotcontents"] + verbs: ["get", "list"] - apiGroups: [ "coordination.k8s.io" ] resources: [ "leases" ] verbs: [ "get", "list", "watch", "create", "update", "patch", "delete" ] From fa04e366638cfca1c8d703318d93aa40b14c4f30 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Fri, 13 Feb 2026 12:57:34 +0100 Subject: [PATCH 46/63] handle snapshot limit --- driver/controller.go | 7 +++++ driver/driver_test.go | 64 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 2915f242..9f8f8c3d 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -71,6 +71,10 @@ var ( // maxVolumesPerServerErrorMessage is the error message returned by the cloudscale.ch // API when the per-server volume limit would be exceeded. maxVolumesPerServerErrorMessageRe = regexp.MustCompile(`Due to internal limitations, it is currently not possible to attach more than \d+ volumes`) + + // maxSnapshotsPerVolumeErrorMessageRe is the error message returned by the cloudscale.ch + // API when the per-volume snapshot limit would be exceeded. + maxSnapshotsPerVolumeErrorMessageRe = regexp.MustCompile(`It is not possible to create more than \d+ snapshots per volume`) ) // CreateVolume creates a new volume from the given request. The function is @@ -742,6 +746,9 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ if errorResponse.StatusCode == http.StatusNotFound { return nil, status.Errorf(codes.NotFound, "source volume %s not found: %v", req.SourceVolumeId, err) } + if errorResponse.StatusCode == http.StatusBadRequest && maxSnapshotsPerVolumeErrorMessageRe.MatchString(err.Error()) { + return nil, status.Errorf(codes.ResourceExhausted, "snapshot limit reached for volume %s: %v", req.SourceVolumeId, err) + } } return nil, status.Errorf(codes.Internal, "failed to create snapshot: %v", err) } diff --git a/driver/driver_test.go b/driver/driver_test.go index 7879a17c..1719fe85 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -20,6 +20,7 @@ package driver import ( "context" "errors" + "fmt" "math/rand" "net/http" "net/url" @@ -407,8 +408,9 @@ func (f *FakeVolumeServiceOperations) WaitFor(ctx context.Context, id string, co } type FakeVolumeSnapshotServiceOperations struct { - fakeClient *cloudscale.Client - snapshots map[string]*cloudscale.VolumeSnapshot + fakeClient *cloudscale.Client + snapshots map[string]*cloudscale.VolumeSnapshot + maxSnapshotsPerVolume int // 0 means no limit } func (f FakeVolumeSnapshotServiceOperations) Create(ctx context.Context, createRequest *cloudscale.VolumeSnapshotCreateRequest) (*cloudscale.VolumeSnapshot, error) { @@ -418,6 +420,22 @@ func (f FakeVolumeSnapshotServiceOperations) Create(ctx context.Context, createR return nil, err } + // Simulate per-volume snapshot limit + if f.maxSnapshotsPerVolume > 0 { + count := 0 + for _, snap := range f.snapshots { + if snap.Volume.UUID == createRequest.SourceVolume { + count++ + } + } + if count >= f.maxSnapshotsPerVolume { + return nil, &cloudscale.ErrorResponse{ + StatusCode: 400, + Message: map[string]string{"detail": fmt.Sprintf("It is not possible to create more than %d snapshots per volume.", f.maxSnapshotsPerVolume)}, + } + } + } + id := randString(32) snap := &cloudscale.VolumeSnapshot{ UUID: id, @@ -1024,6 +1042,48 @@ func TestListSnapshots_InvalidStartingToken(t *testing.T) { } } +// TestCreateSnapshot_SnapshotLimitExhausted verifies that CreateSnapshot returns +// codes.ResourceExhausted when the cloudscale API rejects snapshot creation +// because the per-volume snapshot limit has been reached. +func TestCreateSnapshot_SnapshotLimitExhausted(t *testing.T) { + // Create a driver with a fake client that enforces a snapshot limit of 2 + initialServers := map[string]*cloudscale.Server{} + cloudscaleClient := NewFakeClient(initialServers) + fakeSnapService := cloudscaleClient.VolumeSnapshots.(*FakeVolumeSnapshotServiceOperations) + fakeSnapService.maxSnapshotsPerVolume = 2 + + driver := &Driver{ + mounter: &fakeMounter{}, + log: logrus.New().WithField("test_enabled", true), + cloudscaleClient: cloudscaleClient, + volumeLocks: NewVolumeLocks(), + } + ctx := context.Background() + + volID := createVolumeForTest(t, driver, "vol-snap-limit") + + // Create snapshots up to the limit + createSnapshotForTest(t, driver, "snap-limit-1", volID) + createSnapshotForTest(t, driver, "snap-limit-2", volID) + + // Third snapshot should fail with ResourceExhausted + _, err := driver.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{ + Name: "snap-limit-3", + SourceVolumeId: volID, + }) + if err == nil { + t.Fatal("Expected error when snapshot limit is reached, got nil") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatalf("Expected gRPC status error, got: %v", err) + } + if st.Code() != codes.ResourceExhausted { + t.Errorf("Expected codes.ResourceExhausted, got %v: %v", st.Code(), err) + } +} + // TestDeleteVolume_FailsWhenSnapshotsExist verifies that DeleteVolume returns // codes.FailedPrecondition when the volume has existing snapshots, matching // the CSI spec requirement for volumes that cannot be deleted independently From ddccdc1fca81f4afb9c45d59637839503e582c7c Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 16 Feb 2026 09:45:46 +0100 Subject: [PATCH 47/63] improve comment for listsnapshot --- driver/controller.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 2915f242..0a68f248 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -795,8 +795,8 @@ func (d *Driver) DeleteSnapshot(ctx context.Context, req *csi.DeleteSnapshotRequ // ListSnapshots returns the information about all snapshots on the storage // system within the given parameters regardless of how they were created. -// ListSnapshots should not list a snapshot that is being created but has not -// been cut successfully yet. +// Per the CSI spec, snapshots that are still in progress (not yet available) +// are excluded from the results. func (d *Driver) ListSnapshots(ctx context.Context, req *csi.ListSnapshotsRequest) (*csi.ListSnapshotsResponse, error) { ll := d.log.WithFields(logrus.Fields{ "req": req, From a9a0c585d18bb53d2fca3de5d5933c1fe8d4f55a Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 16 Feb 2026 09:49:43 +0100 Subject: [PATCH 48/63] improve toCSISnapshot error handling --- driver/controller.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 0a68f248..84ed1775 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -713,7 +713,7 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ // Idempotent: snapshot with this name already exists for this volume csiSnap, err := toCSISnapshot(snapshot) if err != nil { - return nil, status.Errorf(codes.Internal, "%v", err) + return nil, status.Errorf(codes.Internal, "toCSISnapshot: %v", err) } return &csi.CreateSnapshotResponse{Snapshot: csiSnap}, nil } @@ -748,7 +748,7 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ csiSnap, err := toCSISnapshot(*snapshot) if err != nil { - return nil, status.Errorf(codes.Internal, "%v", err) + return nil, status.Errorf(codes.Internal, "toCSISnapshot: %v", err) } resp := &csi.CreateSnapshotResponse{Snapshot: csiSnap} @@ -828,7 +828,7 @@ func (d *Driver) ListSnapshots(ctx context.Context, req *csi.ListSnapshotsReques csiSnap, err := toCSISnapshot(*snap) if err != nil { - return nil, status.Errorf(codes.Internal, "%v", err) + return nil, status.Errorf(codes.Internal, "toCSISnapshot: %v", err) } return &csi.ListSnapshotsResponse{ Entries: []*csi.ListSnapshotsResponse_Entry{{Snapshot: csiSnap}}, @@ -880,7 +880,7 @@ func (d *Driver) ListSnapshots(ctx context.Context, req *csi.ListSnapshotsReques for _, snap := range remaining[:endCount] { csiSnap, err := toCSISnapshot(snap) if err != nil { - return nil, status.Errorf(codes.Internal, "%v", err) + return nil, status.Errorf(codes.Internal, "toCSISnapshot: %v", err) } entries = append(entries, &csi.ListSnapshotsResponse_Entry{Snapshot: csiSnap}) } From f6c79f05a8aafe535c8bf27102fdb3fe018c6c69 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 17 Feb 2026 17:04:44 +0100 Subject: [PATCH 49/63] update cloudscale-go-sdk --- driver/controller.go | 10 +++++----- driver/driver_test.go | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 84ed1775..76bce111 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -235,7 +235,7 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo ll = ll.WithFields(logrus.Fields{ "snapshot_size_gb": snapshot.SizeGB, - "snapshot_volume_type": snapshot.Volume.Type, + "snapshot_volume_type": snapshot.SourceVolume.Type, "snapshot_zone": snapshot.Zone, }) @@ -671,7 +671,7 @@ func toCSISnapshot(snap cloudscale.VolumeSnapshot) (*csi.Snapshot, error) { } return &csi.Snapshot{ SnapshotId: snap.UUID, - SourceVolumeId: snap.Volume.UUID, + SourceVolumeId: snap.SourceVolume.UUID, ReadyToUse: snap.Status == "available", SizeBytes: int64(snap.SizeGB * GB), CreationTime: timestamppb.New(t), @@ -709,7 +709,7 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ } for _, snapshot := range snapshots { - if snapshot.Volume.UUID == req.SourceVolumeId { + if snapshot.SourceVolume.UUID == req.SourceVolumeId { // Idempotent: snapshot with this name already exists for this volume csiSnap, err := toCSISnapshot(snapshot) if err != nil { @@ -817,7 +817,7 @@ func (d *Driver) ListSnapshots(ctx context.Context, req *csi.ListSnapshotsReques } // Apply source_volume_id filter if both specified. - if req.SourceVolumeId != "" && snap.Volume.UUID != req.SourceVolumeId { + if req.SourceVolumeId != "" && snap.SourceVolume.UUID != req.SourceVolumeId { return &csi.ListSnapshotsResponse{}, nil } @@ -847,7 +847,7 @@ func (d *Driver) ListSnapshots(ctx context.Context, req *csi.ListSnapshotsReques if snap.Status != "available" { continue } - if req.SourceVolumeId != "" && snap.Volume.UUID != req.SourceVolumeId { + if req.SourceVolumeId != "" && snap.SourceVolume.UUID != req.SourceVolumeId { continue } filtered = append(filtered, snap) diff --git a/driver/driver_test.go b/driver/driver_test.go index 7879a17c..304a6c5a 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -342,7 +342,7 @@ func (f *FakeVolumeServiceOperations) Delete(ctx context.Context, volumeID strin } for _, snapshot := range snapshots { - if snapshot.Volume.UUID == volumeID { + if snapshot.SourceVolume.UUID == volumeID { return &cloudscale.ErrorResponse{ StatusCode: 400, Message: map[string]string{"detail": "Snapshots exist for this volume"}, @@ -425,7 +425,7 @@ func (f FakeVolumeSnapshotServiceOperations) Create(ctx context.Context, createR SizeGB: vol.SizeGB, CreatedAt: time.Now().UTC().Format(time.RFC3339), Status: "available", - Volume: cloudscale.VolumeStub{ + SourceVolume: cloudscale.VolumeStub{ UUID: createRequest.SourceVolume, }, } diff --git a/go.mod b/go.mod index 3c541dd9..21915308 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/cloudscale-ch/csi-cloudscale require ( github.com/cenkalti/backoff/v5 v5.0.3 - github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260113130452-d14a0cbe6a32 + github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260213173932-ae95a6ffee99 github.com/container-storage-interface/spec v1.12.0 github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 27445127..0c3eba74 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260113130452-d14a0cbe6a32 h1:XUwopev0HXEmCVUrmuXHmDadux857+WSPWSDzj1zrhs= -github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260113130452-d14a0cbe6a32/go.mod h1:NLC7XW7HqG0HggDaOBCvmf7WplTDaAqTF9u08yh6k0E= +github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260213173932-ae95a6ffee99 h1:0Mf7/BVgEfOUCLBvnJChUWGTkwDCGJZrN5KA/EPR7q0= +github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260213173932-ae95a6ffee99/go.mod h1:NLC7XW7HqG0HggDaOBCvmf7WplTDaAqTF9u08yh6k0E= github.com/container-storage-interface/spec v1.12.0 h1:zrFOEqpR5AghNaaDG4qyedwPBqU2fU0dWjLQMP/azK0= github.com/container-storage-interface/spec v1.12.0/go.mod h1:txsm+MA2B2WDa5kW69jNbqPnvTtfvZma7T/zsAZ9qX8= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= From b280fc778a85147327fd3ce13d9a8c1d132ce6d3 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 17 Feb 2026 17:40:24 +0100 Subject: [PATCH 50/63] make multiple snapshots possible by fixing idenmpodency check during CreateSnapshot --- driver/controller.go | 13 ++++- test/kubernetes/integration_test.go | 81 +++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 76bce111..645b3236 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -696,7 +696,7 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ }) ll.Info("find existing volume snapshots with same name") - snapshots, err := d.cloudscaleClient.VolumeSnapshots.List(ctx, cloudscale.WithNameFilter(req.Name)) + allSnapshots, err := d.cloudscaleClient.VolumeSnapshots.List(ctx, cloudscale.WithNameFilter(req.Name)) if err != nil { var errorResponse *cloudscale.ErrorResponse if errors.As(err, &errorResponse) { @@ -708,6 +708,17 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ return nil, status.Errorf(codes.Internal, "failed to list snapshots: %v", err) } + // The cloudscale API may ignore the ?name= query parameter for volume + // snapshots (it is undocumented). Filter client-side to guarantee an + // exact name match. If the API adds support later, WithNameFilter above + // reduces the result set as an optimisation and this loop is a no-op. + var snapshots []cloudscale.VolumeSnapshot + for _, s := range allSnapshots { + if s.Name == req.Name { + snapshots = append(snapshots, s) + } + } + for _, snapshot := range snapshots { if snapshot.SourceVolume.UUID == req.SourceVolumeId { // Idempotent: snapshot with this name already exists for this volume diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index 7d02b78a..723cee98 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -495,10 +495,10 @@ func TestPod_Snapshot_Size_Validation(t *testing.T) { waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) } -func TestListSnapshots_MultipleSnapshots(t *testing.T) { +func TestCreateMultipleSnapshots_DifferentVolumes(t *testing.T) { // Create two independent volumes, each with one snapshot, to verify that - // the CSI driver correctly advertises LIST_SNAPSHOTS and the snapshot - // controller can reconcile multiple snapshots concurrently. + // the CSI driver correctly handles creating snapshots from different + // volumes and the snapshot controller can reconcile them concurrently. podDescriptor1 := TestPodDescriptor{ Kind: "Pod", @@ -587,6 +587,81 @@ func TestListSnapshots_MultipleSnapshots(t *testing.T) { waitCloudscaleVolumeDeleted(t, pvc2.Spec.VolumeName) } +func TestCreateMultipleSnapshots_SameVolume(t *testing.T) { + // Create two snapshots from the same volume to verify that the CSI driver + // correctly creates distinct snapshots. This exercises the name-based + // idempotency check in CreateSnapshot: the driver must not return an + // existing snapshot when asked to create a new one with a different name. + + podDescriptor := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-multi-snap-same-vol-pvc", + SizeGB: 5, + StorageClass: "cloudscale-volume-ssd", + }, + }, + } + + pod := makeKubernetesPod(t, podDescriptor) + pvcs := makeKubernetesPVCs(t, podDescriptor) + + waitForPod(t, client, pod.Name) + pvc := getPVC(t, client, pvcs[0].Name) + assert.Equal(t, v1.ClaimBound, pvc.Status.Phase) + + // Create the first snapshot and wait for it to be ready + snap1Name := pseudoUuid() + snapshot1 := makeKubernetesVolumeSnapshot(t, snap1Name, pvc.Name) + waitForVolumeSnapshot(t, snapshot1.Name) + + snapshot1 = getVolumeSnapshot(t, snapshot1.Name) + assert.NotNil(t, snapshot1.Status) + assert.NotNil(t, snapshot1.Status.BoundVolumeSnapshotContentName) + assert.True(t, *snapshot1.Status.ReadyToUse) + + content1 := getVolumeSnapshotContent(t, *snapshot1.Status.BoundVolumeSnapshotContentName) + handle1 := *content1.Status.SnapshotHandle + + // Create the second snapshot from the same volume + snap2Name := pseudoUuid() + snapshot2 := makeKubernetesVolumeSnapshot(t, snap2Name, pvc.Name) + waitForVolumeSnapshot(t, snapshot2.Name) + + snapshot2 = getVolumeSnapshot(t, snapshot2.Name) + assert.NotNil(t, snapshot2.Status) + assert.NotNil(t, snapshot2.Status.BoundVolumeSnapshotContentName) + assert.True(t, *snapshot2.Status.ReadyToUse) + + content2 := getVolumeSnapshotContent(t, *snapshot2.Status.BoundVolumeSnapshotContentName) + handle2 := *content2.Status.SnapshotHandle + + // The two snapshots must have different cloudscale snapshot handles. + // If the name filter in CreateSnapshot is broken, the driver returns + // the first snapshot's handle for both, and this assertion fails. + assert.NotEqual(t, handle1, handle2, "both snapshots got the same cloudscale handle; the driver likely returned the existing snapshot instead of creating a new one") + + // Verify both snapshots exist independently in the cloudscale API + csSnap1 := getCloudscaleVolumeSnapshot(t, handle1) + csSnap2 := getCloudscaleVolumeSnapshot(t, handle2) + assert.Equal(t, "available", csSnap1.Status) + assert.Equal(t, "available", csSnap2.Status) + + // Both snapshots must reference the same source volume + assert.Equal(t, csSnap1.SourceVolume.UUID, csSnap2.SourceVolume.UUID) + + // Cleanup: delete snapshots before the volume (cloudscale requirement) + deleteKubernetesVolumeSnapshot(t, snapshot1.Name) + deleteKubernetesVolumeSnapshot(t, snapshot2.Name) + waitCloudscaleVolumeSnapshotDeleted(t, handle1) + waitCloudscaleVolumeSnapshotDeleted(t, handle2) + + cleanup(t, podDescriptor) + waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) +} + func TestPod_Single_SSD_Raw_Volume(t *testing.T) { podDescriptor := TestPodDescriptor{ Kind: "Pod", From 3d8cacfb4546e1c7e57886c2be92d3a32bd3a00a Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Fri, 13 Feb 2026 12:57:34 +0100 Subject: [PATCH 51/63] handle snapshot limit --- driver/controller.go | 7 +++++ driver/driver_test.go | 64 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 645b3236..193fbe0a 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -71,6 +71,10 @@ var ( // maxVolumesPerServerErrorMessage is the error message returned by the cloudscale.ch // API when the per-server volume limit would be exceeded. maxVolumesPerServerErrorMessageRe = regexp.MustCompile(`Due to internal limitations, it is currently not possible to attach more than \d+ volumes`) + + // maxSnapshotsPerVolumeErrorMessageRe is the error message returned by the cloudscale.ch + // API when the per-volume snapshot limit would be exceeded. + maxSnapshotsPerVolumeErrorMessageRe = regexp.MustCompile(`It is not possible to create more than \d+ snapshots per volume`) ) // CreateVolume creates a new volume from the given request. The function is @@ -753,6 +757,9 @@ func (d *Driver) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshotRequ if errorResponse.StatusCode == http.StatusNotFound { return nil, status.Errorf(codes.NotFound, "source volume %s not found: %v", req.SourceVolumeId, err) } + if errorResponse.StatusCode == http.StatusBadRequest && maxSnapshotsPerVolumeErrorMessageRe.MatchString(err.Error()) { + return nil, status.Errorf(codes.ResourceExhausted, "snapshot limit reached for volume %s: %v", req.SourceVolumeId, err) + } } return nil, status.Errorf(codes.Internal, "failed to create snapshot: %v", err) } diff --git a/driver/driver_test.go b/driver/driver_test.go index 304a6c5a..753f82a2 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -20,6 +20,7 @@ package driver import ( "context" "errors" + "fmt" "math/rand" "net/http" "net/url" @@ -407,8 +408,9 @@ func (f *FakeVolumeServiceOperations) WaitFor(ctx context.Context, id string, co } type FakeVolumeSnapshotServiceOperations struct { - fakeClient *cloudscale.Client - snapshots map[string]*cloudscale.VolumeSnapshot + fakeClient *cloudscale.Client + snapshots map[string]*cloudscale.VolumeSnapshot + maxSnapshotsPerVolume int // 0 means no limit } func (f FakeVolumeSnapshotServiceOperations) Create(ctx context.Context, createRequest *cloudscale.VolumeSnapshotCreateRequest) (*cloudscale.VolumeSnapshot, error) { @@ -418,6 +420,22 @@ func (f FakeVolumeSnapshotServiceOperations) Create(ctx context.Context, createR return nil, err } + // Simulate per-volume snapshot limit + if f.maxSnapshotsPerVolume > 0 { + count := 0 + for _, snap := range f.snapshots { + if snap.Volume.UUID == createRequest.SourceVolume { + count++ + } + } + if count >= f.maxSnapshotsPerVolume { + return nil, &cloudscale.ErrorResponse{ + StatusCode: 400, + Message: map[string]string{"detail": fmt.Sprintf("It is not possible to create more than %d snapshots per volume.", f.maxSnapshotsPerVolume)}, + } + } + } + id := randString(32) snap := &cloudscale.VolumeSnapshot{ UUID: id, @@ -1024,6 +1042,48 @@ func TestListSnapshots_InvalidStartingToken(t *testing.T) { } } +// TestCreateSnapshot_SnapshotLimitExhausted verifies that CreateSnapshot returns +// codes.ResourceExhausted when the cloudscale API rejects snapshot creation +// because the per-volume snapshot limit has been reached. +func TestCreateSnapshot_SnapshotLimitExhausted(t *testing.T) { + // Create a driver with a fake client that enforces a snapshot limit of 2 + initialServers := map[string]*cloudscale.Server{} + cloudscaleClient := NewFakeClient(initialServers) + fakeSnapService := cloudscaleClient.VolumeSnapshots.(*FakeVolumeSnapshotServiceOperations) + fakeSnapService.maxSnapshotsPerVolume = 2 + + driver := &Driver{ + mounter: &fakeMounter{}, + log: logrus.New().WithField("test_enabled", true), + cloudscaleClient: cloudscaleClient, + volumeLocks: NewVolumeLocks(), + } + ctx := context.Background() + + volID := createVolumeForTest(t, driver, "vol-snap-limit") + + // Create snapshots up to the limit + createSnapshotForTest(t, driver, "snap-limit-1", volID) + createSnapshotForTest(t, driver, "snap-limit-2", volID) + + // Third snapshot should fail with ResourceExhausted + _, err := driver.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{ + Name: "snap-limit-3", + SourceVolumeId: volID, + }) + if err == nil { + t.Fatal("Expected error when snapshot limit is reached, got nil") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatalf("Expected gRPC status error, got: %v", err) + } + if st.Code() != codes.ResourceExhausted { + t.Errorf("Expected codes.ResourceExhausted, got %v: %v", st.Code(), err) + } +} + // TestDeleteVolume_FailsWhenSnapshotsExist verifies that DeleteVolume returns // codes.FailedPrecondition when the volume has existing snapshots, matching // the CSI spec requirement for volumes that cannot be deleted independently From 1ec6697fc8ed955adf7f8473f48b487fa553e9bb Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Fri, 20 Feb 2026 13:58:50 +0100 Subject: [PATCH 52/63] update type to match updated sdk --- driver/driver_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/driver/driver_test.go b/driver/driver_test.go index 753f82a2..7014286e 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -408,9 +408,9 @@ func (f *FakeVolumeServiceOperations) WaitFor(ctx context.Context, id string, co } type FakeVolumeSnapshotServiceOperations struct { - fakeClient *cloudscale.Client - snapshots map[string]*cloudscale.VolumeSnapshot - maxSnapshotsPerVolume int // 0 means no limit + fakeClient *cloudscale.Client + snapshots map[string]*cloudscale.VolumeSnapshot + maxSnapshotsPerVolume int // 0 means no limit } func (f FakeVolumeSnapshotServiceOperations) Create(ctx context.Context, createRequest *cloudscale.VolumeSnapshotCreateRequest) (*cloudscale.VolumeSnapshot, error) { @@ -424,7 +424,7 @@ func (f FakeVolumeSnapshotServiceOperations) Create(ctx context.Context, createR if f.maxSnapshotsPerVolume > 0 { count := 0 for _, snap := range f.snapshots { - if snap.Volume.UUID == createRequest.SourceVolume { + if snap.SourceVolume.UUID == createRequest.SourceVolume { count++ } } From 96c804387151e8aea40b19f32ed51749eb71efb3 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Fri, 20 Feb 2026 16:42:34 +0100 Subject: [PATCH 53/63] resize volume on creation if requested size is bigger than snapshot --- driver/controller.go | 80 ++++++++----- driver/driver_test.go | 174 +++++++++++++++++++++++++++- driver/node.go | 16 +++ test/kubernetes/integration_test.go | 84 ++++++-------- 4 files changed, 271 insertions(+), 83 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 193fbe0a..96e81dab 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -243,36 +243,36 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo "snapshot_zone": snapshot.Zone, }) - // Validate capacity requirements - // CSI spec: restored volume must be at least as large as the snapshot - // Cloudscale only supports the same size as the snapshot + // Determine target volume size. + // cloudscale creates volumes from snapshots at the snapshot's native size. + // If the requested capacity is larger, we expand the volume after creation. + storageType := snapshot.SourceVolume.Type + targetSizeGB := snapshot.SizeGB + if req.CapacityRange != nil { - requiredBytes := req.CapacityRange.GetRequiredBytes() - if requiredBytes > 0 { - requiredGB := int(requiredBytes / GB) - if requiredGB < snapshot.SizeGB { - return nil, status.Errorf(codes.OutOfRange, - "requested volume size (%d GB) is smaller than snapshot size (%d GB)", - requiredGB, snapshot.SizeGB) - } - if requiredGB > snapshot.SizeGB { - // todo: we could just do this after creation of the volume... - return nil, status.Errorf(codes.OutOfRange, - "cloudscale.ch API does not support creating volumes larger than snapshot size during restore. "+ - "Create volume from snapshot first, then expand it using ControllerExpandVolume. "+ - "Requested: %d GB, Snapshot: %d GB", requiredGB, snapshot.SizeGB) - } + calculatedSize, err := calculateStorageGB(req.CapacityRange, storageType) + if err != nil { + return nil, status.Error(codes.OutOfRange, err.Error()) + } + if calculatedSize < snapshot.SizeGB { + return nil, status.Errorf(codes.OutOfRange, + "requested volume size (%d GB) is smaller than snapshot size (%d GB)", + calculatedSize, snapshot.SizeGB) + } + if calculatedSize > snapshot.SizeGB { + targetSizeGB = calculatedSize } - // Validate limit if specified limitBytes := req.CapacityRange.GetLimitBytes() - if limitBytes > 0 && int64(snapshot.SizeGB)*GB > limitBytes { + if limitBytes > 0 && int64(targetSizeGB)*GB > limitBytes { return nil, status.Errorf(codes.OutOfRange, - "snapshot size (%d GB) exceeds capacity limit (%d bytes)", - snapshot.SizeGB, limitBytes) + "volume size (%d GB) exceeds capacity limit (%d bytes)", + targetSizeGB, limitBytes) } } + ll = ll.WithField("target_size_gb", targetSizeGB) + // cloudscale does create the volume in the same zone as the snapshot. if req.AccessibilityRequirements != nil { for _, t := range req.AccessibilityRequirements.Requisite { @@ -311,7 +311,6 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo var createdVolume *cloudscale.Volume if len(volumes) != 0 { - // Volume already exists - validate it matches request if len(volumes) > 1 { return nil, fmt.Errorf("fatal issue: duplicate volume %q exists", volumeName) } @@ -320,19 +319,26 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo // cloudscale API does not provide the source snapshot of a volume, // if this would be provided, the idempotency check could be improved. - if createdVolume.SizeGB != snapshot.SizeGB { - return nil, status.Errorf(codes.AlreadyExists, - "volume %q already exists with size %d GB (incompatible with snapshot size %d GB)", - volumeName, createdVolume.SizeGB, snapshot.SizeGB) - } - if createdVolume.Zone.Slug != snapshot.Zone.Slug { return nil, status.Errorf(codes.AlreadyExists, "volume %q already exists in zone %s (incompatible with snapshot zone %s)", volumeName, createdVolume.Zone.Slug, snapshot.Zone.Slug) } - ll.WithField("volume_id", createdVolume.UUID).Info("volume from snapshot already exists") + if createdVolume.SizeGB < snapshot.SizeGB { + return nil, status.Errorf(codes.AlreadyExists, + "volume %q already exists with size %d GB (incompatible with snapshot size %d GB)", + volumeName, createdVolume.SizeGB, snapshot.SizeGB) + } + + // createdVolume.SizeGB >= snapshot.SizeGB: volume was created from snapshot. + // If createdVolume.SizeGB >= targetSizeGB, it's fully done. + // If createdVolume.SizeGB < targetSizeGB, expansion is needed (handled below). + + ll.WithFields(logrus.Fields{ + "volume_id": createdVolume.UUID, + "existing_size_gb": createdVolume.SizeGB, + }).Info("volume from snapshot already exists") } else { // Volume does not exist, create volume from snapshot volumeReq := &cloudscale.VolumeCreateRequest{ @@ -348,6 +354,20 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo } } + if createdVolume.SizeGB < targetSizeGB { + ll.WithFields(logrus.Fields{ + "current_size_gb": createdVolume.SizeGB, + "target_size_gb": targetSizeGB, + }).Info("expanding volume to requested size") + + updateReq := &cloudscale.VolumeUpdateRequest{SizeGB: targetSizeGB} + if err := d.cloudscaleClient.Volumes.Update(ctx, createdVolume.UUID, updateReq); err != nil { + return nil, status.Errorf(codes.Internal, + "volume created from snapshot but expansion failed: %v", err) + } + createdVolume.SizeGB = targetSizeGB + } + csiVolume := csi.Volume{ VolumeId: createdVolume.UUID, CapacityBytes: int64(createdVolume.SizeGB) * GB, diff --git a/driver/driver_test.go b/driver/driver_test.go index 7014286e..55d141d0 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -36,6 +36,7 @@ import ( "github.com/google/uuid" "github.com/kubernetes-csi/csi-test/v5/pkg/sanity" "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "k8s.io/mount-utils" @@ -206,10 +207,6 @@ type FakeVolumeServiceOperations struct { func (f *FakeVolumeServiceOperations) Create(ctx context.Context, createRequest *cloudscale.VolumeCreateRequest) (*cloudscale.Volume, error) { id := randString(32) - // todo: CSI-test pass without this, but we could implement: - // - check if volumeSnapshot is present. Return error if volumeSnapshot does not exist - // - create volume with inferred values form snapshot. - vol := &cloudscale.Volume{ UUID: id, Name: createRequest.Name, @@ -217,7 +214,19 @@ func (f *FakeVolumeServiceOperations) Create(ctx context.Context, createRequest Type: createRequest.Type, ServerUUIDs: createRequest.ServerUUIDs, } - vol.Zone = DefaultZone + + if createRequest.VolumeSnapshotUUID != "" { + snap, err := f.fakeClient.VolumeSnapshots.Get(ctx, createRequest.VolumeSnapshotUUID) + if err != nil { + return nil, err + } + vol.SizeGB = snap.SizeGB + vol.Type = snap.SourceVolume.Type + vol.Zone = snap.Zone + } else { + vol.Zone = DefaultZone + } + if vol.ServerUUIDs == nil { noservers := make([]string, 0, 1) vol.ServerUUIDs = &noservers @@ -445,8 +454,10 @@ func (f FakeVolumeSnapshotServiceOperations) Create(ctx context.Context, createR Status: "available", SourceVolume: cloudscale.VolumeStub{ UUID: createRequest.SourceVolume, + Type: vol.Type, }, } + snap.Zone = vol.Zone f.snapshots[id] = snap return snap, nil @@ -1151,3 +1162,156 @@ func TestDeleteVolume_FailsWhenSnapshotsExist(t *testing.T) { t.Fatalf("Expected volume deletion to succeed after snapshot removal, got: %v", err) } } + +func createVolumeAndSnapshot(t *testing.T, driver *Driver, sizeGB int) (string, string) { + t.Helper() + ctx := context.Background() + + vol, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ + Name: "src-vol-" + strconv.Itoa(rand.Int()), + VolumeCapabilities: makeVolumeCapabilityObject(false), + CapacityRange: &csi.CapacityRange{RequiredBytes: int64(sizeGB) * GB}, + Parameters: map[string]string{StorageTypeAttribute: "ssd"}, + }) + if err != nil { + t.Fatalf("Failed to create source volume: %v", err) + } + + snap, err := driver.CreateSnapshot(ctx, &csi.CreateSnapshotRequest{ + Name: "snap-" + strconv.Itoa(rand.Int()), + SourceVolumeId: vol.Volume.VolumeId, + }) + if err != nil { + t.Fatalf("Failed to create snapshot: %v", err) + } + + return vol.Volume.VolumeId, snap.Snapshot.SnapshotId +} + +func TestCreateVolumeFromSnapshot_EqualSize(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + _, snapshotID := createVolumeAndSnapshot(t, driver, 5) + + resp, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ + Name: "restored-equal", + VolumeCapabilities: makeVolumeCapabilityObject(false), + CapacityRange: &csi.CapacityRange{RequiredBytes: 5 * GB}, + VolumeContentSource: &csi.VolumeContentSource{ + Type: &csi.VolumeContentSource_Snapshot{ + Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, + }, + }, + }) + assert.NoError(t, err) + assert.Equal(t, int64(5*GB), resp.Volume.CapacityBytes) +} + +func TestCreateVolumeFromSnapshot_LargerSize(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + _, snapshotID := createVolumeAndSnapshot(t, driver, 5) + + resp, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ + Name: "restored-larger", + VolumeCapabilities: makeVolumeCapabilityObject(false), + CapacityRange: &csi.CapacityRange{RequiredBytes: 10 * GB}, + VolumeContentSource: &csi.VolumeContentSource{ + Type: &csi.VolumeContentSource_Snapshot{ + Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, + }, + }, + }) + assert.NoError(t, err) + assert.Equal(t, int64(10*GB), resp.Volume.CapacityBytes) + + vol, err := driver.cloudscaleClient.Volumes.Get(ctx, resp.Volume.VolumeId) + assert.NoError(t, err) + assert.Equal(t, 10, vol.SizeGB) +} + +func TestCreateVolumeFromSnapshot_SmallerSize_Rejected(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + _, snapshotID := createVolumeAndSnapshot(t, driver, 5) + + _, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ + Name: "restored-smaller", + VolumeCapabilities: makeVolumeCapabilityObject(false), + CapacityRange: &csi.CapacityRange{RequiredBytes: 3 * GB}, + VolumeContentSource: &csi.VolumeContentSource{ + Type: &csi.VolumeContentSource_Snapshot{ + Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, + }, + }, + }) + assert.Error(t, err) + st, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, codes.OutOfRange, st.Code()) +} + +func TestCreateVolumeFromSnapshot_Idempotent_AlreadyExpanded(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + _, snapshotID := createVolumeAndSnapshot(t, driver, 5) + + req := &csi.CreateVolumeRequest{ + Name: "restored-idempotent", + VolumeCapabilities: makeVolumeCapabilityObject(false), + CapacityRange: &csi.CapacityRange{RequiredBytes: 10 * GB}, + VolumeContentSource: &csi.VolumeContentSource{ + Type: &csi.VolumeContentSource_Snapshot{ + Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, + }, + }, + } + + resp1, err := driver.CreateVolume(ctx, req) + assert.NoError(t, err) + assert.Equal(t, int64(10*GB), resp1.Volume.CapacityBytes) + + resp2, err := driver.CreateVolume(ctx, req) + assert.NoError(t, err) + assert.Equal(t, resp1.Volume.VolumeId, resp2.Volume.VolumeId) + assert.Equal(t, int64(10*GB), resp2.Volume.CapacityBytes) +} + +func TestCreateVolumeFromSnapshot_Idempotent_NeedsExpansion(t *testing.T) { + driver := createDriverForTest(t) + ctx := context.Background() + _, snapshotID := createVolumeAndSnapshot(t, driver, 5) + + // First create at snapshot size (equal) + resp1, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ + Name: "restored-needs-expand", + VolumeCapabilities: makeVolumeCapabilityObject(false), + CapacityRange: &csi.CapacityRange{RequiredBytes: 5 * GB}, + VolumeContentSource: &csi.VolumeContentSource{ + Type: &csi.VolumeContentSource_Snapshot{ + Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, + }, + }, + }) + assert.NoError(t, err) + assert.Equal(t, int64(5*GB), resp1.Volume.CapacityBytes) + + // Now call again with a larger size -- simulates retry after expand failure + resp2, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ + Name: "restored-needs-expand", + VolumeCapabilities: makeVolumeCapabilityObject(false), + CapacityRange: &csi.CapacityRange{RequiredBytes: 10 * GB}, + VolumeContentSource: &csi.VolumeContentSource{ + Type: &csi.VolumeContentSource_Snapshot{ + Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, + }, + }, + }) + assert.NoError(t, err) + assert.Equal(t, resp1.Volume.VolumeId, resp2.Volume.VolumeId) + assert.Equal(t, int64(10*GB), resp2.Volume.CapacityBytes) + + vol, err := driver.cloudscaleClient.Volumes.Get(ctx, resp2.Volume.VolumeId) + assert.NoError(t, err) + assert.Equal(t, 10, vol.SizeGB) +} diff --git a/driver/node.go b/driver/node.go index 5e26109f..d1db3a71 100644 --- a/driver/node.go +++ b/driver/node.go @@ -153,6 +153,22 @@ func (d *Driver) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolumeRe ll.Info("source device is already mounted to the stagingTargetPath path") } + // Resize the filesystem if the block device is larger (e.g. snapshot + // restored to a larger volume). Kubernetes only calls NodeExpandVolume + // for PVC resizes, not for freshly created volumes, so we must handle + // it here. See https://github.com/kubernetes/kubernetes/issues/94929. + r := mount.NewResizeFs(utilexec.New()) + needResize, err := r.NeedResize(source, stagingTargetPath) + if err != nil { + ll.WithError(err).Warn("unable to check if filesystem needs resize") + } else if needResize { + ll.Info("resizing filesystem to match block device size") + if _, err := r.Resize(source, stagingTargetPath); err != nil { + return nil, status.Errorf(codes.Internal, "failed to resize filesystem on %s: %v", source, err) + } + ll.Info("filesystem resized successfully") + } + ll.Info("formatting and mounting stage volume is finished") return &csi.NodeStageVolumeResponse{}, nil } diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index 723cee98..aeb3c38e 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -400,8 +400,7 @@ func TestPod_Single_SSD_Luks_Volume_Snapshot(t *testing.T) { waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) } -func TestPod_Snapshot_Size_Validation(t *testing.T) { - // Test that snapshot size validation works correctly +func TestPod_Snapshot_Restore_Larger_Size(t *testing.T) { podDescriptor := TestPodDescriptor{ Kind: "Pod", Name: pseudoUuid(), @@ -414,7 +413,6 @@ func TestPod_Snapshot_Size_Validation(t *testing.T) { }, } - // Create volume pod := makeKubernetesPod(t, podDescriptor) pvcs := makeKubernetesPVCs(t, podDescriptor) waitForPod(t, client, pod.Name) @@ -424,7 +422,6 @@ func TestPod_Snapshot_Size_Validation(t *testing.T) { volume := getCloudscaleVolume(t, pvc.Spec.VolumeName) assert.Equal(t, 5, volume.SizeGB) - // Create snapshot snapshotName := pseudoUuid() snapshot := makeKubernetesVolumeSnapshot(t, snapshotName, pvc.Name) waitForVolumeSnapshot(t, snapshot.Name) @@ -437,60 +434,51 @@ func TestPod_Snapshot_Size_Validation(t *testing.T) { cloudscaleSnapshot := getCloudscaleVolumeSnapshot(t, snapshotHandle) assert.Equal(t, 5, cloudscaleSnapshot.SizeGB) - // Note: restoring with a smaller size is not tested here because the - // Kubernetes external-provisioner enforces that the requested PVC size - // is at least the snapshot's restore size. If a smaller size is requested, - // the provisioner automatically adjusts it before invoking the CSI driver. - // As a result, CreateVolume is never called with a smaller size in practice. - // The driver's own smaller-size validation in createVolumeFromSnapshot is - // therefore defense-in-depth only. - - // Attempt to restore with a larger size (expected to fail for this driver). - // The Kubernetes external-provisioner forwards the requested size unchanged - // when it is larger than the snapshot restore size and calls the CSI driver's - // CreateVolume. Our driver rejects such requests with codes.OutOfRange. - // The external-provisioner surfaces any CreateVolume failure as a - // ProvisioningFailed event on the PVC, which we use to verify this. - largerPVCName := "csi-pod-snapshot-size-pvc-larger" - volMode := v1.PersistentVolumeFilesystem - apiGroup := "snapshot.storage.k8s.io" - largerPVC := &v1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: largerPVCName, - }, - Spec: v1.PersistentVolumeClaimSpec{ - VolumeMode: &volMode, - AccessModes: []v1.PersistentVolumeAccessMode{ - v1.ReadWriteOnce, - }, - Resources: v1.ResourceRequirements{ - Requests: v1.ResourceList{ - v1.ResourceStorage: resource.MustParse("10Gi"), // Larger than snapshot size (5GB) - }, - }, - StorageClassName: strPtr("cloudscale-volume-ssd"), - DataSource: &v1.TypedLocalObjectReference{ - APIGroup: &apiGroup, - Kind: "VolumeSnapshot", - Name: snapshot.Name, + // Restore from snapshot with a larger size (10 GB > 5 GB snapshot). + // The driver creates the volume at the snapshot's native size, then + // expands it to the requested capacity within CreateVolume. + restoredPodDescriptor := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-pod-snapshot-larger-pvc", + SizeGB: 10, + StorageClass: "cloudscale-volume-ssd", }, }, } - t.Log("Creating PVC from snapshot with larger size (should fail)") - _, err := client.CoreV1().PersistentVolumeClaims(namespace).Create(context.Background(), largerPVC, metav1.CreateOptions{}) - assert.NoError(t, err) + restoredPod := makeKubernetesPod(t, restoredPodDescriptor) + restoredPVCs := makeKubernetesPVCsFromSnapshot(t, restoredPodDescriptor, snapshot.Name) + assert.Equal(t, 1, len(restoredPVCs)) - // Wait for the provisioner to reject the PVC with an OutOfRange error - waitForPVCEvent(t, client, largerPVCName, "ProvisioningFailed", "does not support creating volumes larger", 60*time.Second) + waitForPod(t, client, restoredPod.Name) + restoredPVC := getPVC(t, client, restoredPVCs[0].Name) + assert.Equal(t, v1.ClaimBound, restoredPVC.Status.Phase) - // Cleanup failed PVC - err = client.CoreV1().PersistentVolumeClaims(namespace).Delete(context.Background(), largerPVCName, metav1.DeleteOptions{}) + restoredVolume := getCloudscaleVolume(t, restoredPVC.Spec.VolumeName) + assert.Equal(t, 10, restoredVolume.SizeGB) + assert.Equal(t, "ssd", restoredVolume.Type) + + restoredDisk, err := getVolumeInfo(t, restoredPod, restoredPVC.Spec.VolumeName) assert.NoError(t, err) + assert.Equal(t, "Filesystem", restoredDisk.PVCVolumeMode) + assert.Equal(t, "ext4", restoredDisk.Filesystem) + assert.Equal(t, 10*driver.GB, restoredDisk.DeviceSize) + + // The filesystem may report slightly less than the full device size due to + // filesystem overhead; verify it is at least the original snapshot size and + // that it has grown beyond the snapshot's filesystem footprint. + assert.True(t, restoredDisk.FilesystemSize > 5*driver.GB, + "filesystem should be larger than snapshot size (5 GB), got %d", restoredDisk.FilesystemSize) - // Cleanup original resources deleteKubernetesVolumeSnapshot(t, snapshot.Name) waitCloudscaleVolumeSnapshotDeleted(t, snapshotHandle) + + cleanup(t, restoredPodDescriptor) + waitCloudscaleVolumeDeleted(t, restoredPVC.Spec.VolumeName) + cleanup(t, podDescriptor) waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) } From 4cab46cc735cf72486dd66936c04fa1a2bad2012 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 23 Feb 2026 14:47:24 +0100 Subject: [PATCH 54/63] fix resize for luks volumes after creation from snapshot --- driver/node.go | 31 ++++++++- test/kubernetes/integration_test.go | 101 ++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/driver/node.go b/driver/node.go index d1db3a71..49090b72 100644 --- a/driver/node.go +++ b/driver/node.go @@ -157,14 +157,39 @@ func (d *Driver) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolumeRe // restored to a larger volume). Kubernetes only calls NodeExpandVolume // for PVC resizes, not for freshly created volumes, so we must handle // it here. See https://github.com/kubernetes/kubernetes/issues/94929. + // For LUKS-encrypted volumes, the filesystem lives on the /dev/mapper + // device, not on the raw block device. Resolve the actual device backing + // the staging path so that both LUKS and non-LUKS volumes are handled + // correctly. + resizeSource := source + mounter := mount.New("") + devicePath, err := d.mounter.GetDeviceName(mounter, stagingTargetPath) + if err != nil { + ll.WithError(err).Warn("unable to determine device path for filesystem resize, falling back to original source") + } else { + resizeSource = devicePath + + // If the staged device is a LUKS mapping, grow the LUKS container + // first so the filesystem can see the larger size. + isLuks, _, err := isLuksMapping(devicePath) + if err != nil { + ll.WithError(err).Warn("unable to determine if device is LUKS-encrypted, skipping LUKS container resize") + } else if isLuks { + ll.WithField("device_path", devicePath).Info("resizing LUKS container before filesystem resize") + if err := luksResize(devicePath); err != nil { + return nil, status.Errorf(codes.Internal, "failed to resize LUKS container on %s: %v", devicePath, err) + } + } + } + r := mount.NewResizeFs(utilexec.New()) - needResize, err := r.NeedResize(source, stagingTargetPath) + needResize, err := r.NeedResize(resizeSource, stagingTargetPath) if err != nil { ll.WithError(err).Warn("unable to check if filesystem needs resize") } else if needResize { ll.Info("resizing filesystem to match block device size") - if _, err := r.Resize(source, stagingTargetPath); err != nil { - return nil, status.Errorf(codes.Internal, "failed to resize filesystem on %s: %v", source, err) + if _, err := r.Resize(resizeSource, stagingTargetPath); err != nil { + return nil, status.Errorf(codes.Internal, "failed to resize filesystem on %s: %v", resizeSource, err) } ll.Info("filesystem resized successfully") } diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index aeb3c38e..1a28e768 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -483,6 +483,107 @@ func TestPod_Snapshot_Restore_Larger_Size(t *testing.T) { waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) } +// Test that restoring a LUKS-encrypted snapshot into a larger volume results in +// the block device and filesystem both being expanded. +func TestPod_Luks_Snapshot_Restore_Larger_Size(t *testing.T) { + podDescriptor := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-pod-ssd-luks-pvc-original-larger", + SizeGB: 5, + StorageClass: "cloudscale-volume-ssd-luks", + LuksKey: "secret", + }, + }, + } + + // create original LUKS volume and pod + pod := makeKubernetesPod(t, podDescriptor) + pvcs := makeKubernetesPVCs(t, podDescriptor) + assert.Equal(t, 1, len(pvcs)) + + waitForPod(t, client, pod.Name) + pvc := getPVC(t, client, pvcs[0].Name) + assert.Equal(t, v1.ClaimBound, pvc.Status.Phase) + + originalVolume := getCloudscaleVolume(t, pvc.Spec.VolumeName) + assert.Equal(t, 5, originalVolume.SizeGB) + assert.Equal(t, "ssd", originalVolume.Type) + + // verify original disk properties + originalDisk, err := getVolumeInfo(t, pod, pvc.Spec.VolumeName) + assert.NoError(t, err) + assert.Equal(t, "LUKS1", originalDisk.Luks) + assert.Equal(t, "Filesystem", originalDisk.PVCVolumeMode) + assert.Equal(t, "ext4", originalDisk.Filesystem) + assert.Equal(t, 5*driver.GB, originalDisk.DeviceSize) + assert.Equal(t, 5*driver.GB-luksOverhead, originalDisk.FilesystemSize) + + // create snapshot of the LUKS volume + snapshotName := pseudoUuid() + snapshot := makeKubernetesVolumeSnapshot(t, snapshotName, pvc.Name) + waitForVolumeSnapshot(t, snapshot.Name) + snapshot = getVolumeSnapshot(t, snapshot.Name) + assert.NotNil(t, snapshot.Status) + assert.True(t, *snapshot.Status.ReadyToUse) + + snapshotContent := getVolumeSnapshotContent(t, *snapshot.Status.BoundVolumeSnapshotContentName) + snapshotHandle := *snapshotContent.Status.SnapshotHandle + + cloudscaleSnapshot := getCloudscaleVolumeSnapshot(t, snapshotHandle) + assert.NotNil(t, cloudscaleSnapshot) + assert.Equal(t, 5, cloudscaleSnapshot.SizeGB) + + // restore snapshot into a larger (10 GB) LUKS volume + restoredPodDescriptor := TestPodDescriptor{ + Kind: "Pod", + Name: pseudoUuid(), + Volumes: []TestPodVolume{ + { + ClaimName: "csi-pod-ssd-luks-pvc-restored-larger", + SizeGB: 10, + StorageClass: "cloudscale-volume-ssd-luks", + LuksKey: "secret", + }, + }, + } + + restoredPod := makeKubernetesPod(t, restoredPodDescriptor) + restoredPVCs := makeKubernetesPVCsFromSnapshot(t, restoredPodDescriptor, snapshot.Name) + assert.Equal(t, 1, len(restoredPVCs)) + + waitForPod(t, client, restoredPod.Name) + restoredPVC := getPVC(t, client, restoredPVCs[0].Name) + assert.Equal(t, v1.ClaimBound, restoredPVC.Status.Phase) + + // cloudscale volume should be 10 GB + restoredVolume := getCloudscaleVolume(t, restoredPVC.Spec.VolumeName) + assert.Equal(t, 10, restoredVolume.SizeGB) + assert.Equal(t, "ssd", restoredVolume.Type) + + // verify that LUKS and filesystem have been expanded as well + restoredDisk, err := getVolumeInfo(t, restoredPod, restoredPVC.Spec.VolumeName) + assert.NoError(t, err) + assert.Equal(t, "LUKS1", restoredDisk.Luks) + assert.Equal(t, "Filesystem", restoredDisk.PVCVolumeMode) + assert.Equal(t, "ext4", restoredDisk.Filesystem) + assert.Equal(t, 10*driver.GB, restoredDisk.DeviceSize) + assert.True(t, restoredDisk.FilesystemSize > 5*driver.GB-luksOverhead, + "filesystem should be larger than original snapshot size (5 GiB minus LUKS overhead), got %d", restoredDisk.FilesystemSize) + + // cleanup + deleteKubernetesVolumeSnapshot(t, snapshot.Name) + waitCloudscaleVolumeSnapshotDeleted(t, snapshotHandle) + + cleanup(t, restoredPodDescriptor) + waitCloudscaleVolumeDeleted(t, restoredPVC.Spec.VolumeName) + + cleanup(t, podDescriptor) + waitCloudscaleVolumeDeleted(t, pvc.Spec.VolumeName) +} + func TestCreateMultipleSnapshots_DifferentVolumes(t *testing.T) { // Create two independent volumes, each with one snapshot, to verify that // the CSI driver correctly handles creating snapshots from different From 1c26a63d0e5061b27314aedabf7a36a646cb254d Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 23 Feb 2026 16:46:05 +0100 Subject: [PATCH 55/63] clearify targetSizeGB --- driver/controller.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 96e81dab..6103b845 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -243,12 +243,13 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo "snapshot_zone": snapshot.Zone, }) - // Determine target volume size. // cloudscale creates volumes from snapshots at the snapshot's native size. // If the requested capacity is larger, we expand the volume after creation. - storageType := snapshot.SourceVolume.Type + // targetSizeGB is used to track whether we must expand to meet the requested capacity. targetSizeGB := snapshot.SizeGB + storageType := snapshot.SourceVolume.Type + if req.CapacityRange != nil { calculatedSize, err := calculateStorageGB(req.CapacityRange, storageType) if err != nil { @@ -331,12 +332,8 @@ func (d *Driver) createVolumeFromSnapshot(ctx context.Context, req *csi.CreateVo volumeName, createdVolume.SizeGB, snapshot.SizeGB) } - // createdVolume.SizeGB >= snapshot.SizeGB: volume was created from snapshot. - // If createdVolume.SizeGB >= targetSizeGB, it's fully done. - // If createdVolume.SizeGB < targetSizeGB, expansion is needed (handled below). - ll.WithFields(logrus.Fields{ - "volume_id": createdVolume.UUID, + "volume_id": createdVolume.UUID, "existing_size_gb": createdVolume.SizeGB, }).Info("volume from snapshot already exists") } else { From 577f5c62a518c1996447b09170d77870273876ca Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 23 Feb 2026 17:31:49 +0100 Subject: [PATCH 56/63] replace magic numbers with variables to convey intent --- driver/driver_test.go | 45 +++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/driver/driver_test.go b/driver/driver_test.go index 55d141d0..26ae0214 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -1188,15 +1188,22 @@ func createVolumeAndSnapshot(t *testing.T, driver *Driver, sizeGB int) (string, return vol.Volume.VolumeId, snap.Snapshot.SnapshotId } +const ( + snapshotSizeGiB = 5 // source volume and snapshot size (GiB) + expandedSizeGiB = 10 // requested size larger than snapshot (triggers expansion) + belowSnapshotSizeGiB = 3 // requested size smaller than snapshot (invalid) +) + + func TestCreateVolumeFromSnapshot_EqualSize(t *testing.T) { driver := createDriverForTest(t) ctx := context.Background() - _, snapshotID := createVolumeAndSnapshot(t, driver, 5) + _, snapshotID := createVolumeAndSnapshot(t, driver, snapshotSizeGiB) resp, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ Name: "restored-equal", VolumeCapabilities: makeVolumeCapabilityObject(false), - CapacityRange: &csi.CapacityRange{RequiredBytes: 5 * GB}, + CapacityRange: &csi.CapacityRange{RequiredBytes: int64(snapshotSizeGiB) * GB}, VolumeContentSource: &csi.VolumeContentSource{ Type: &csi.VolumeContentSource_Snapshot{ Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, @@ -1204,18 +1211,18 @@ func TestCreateVolumeFromSnapshot_EqualSize(t *testing.T) { }, }) assert.NoError(t, err) - assert.Equal(t, int64(5*GB), resp.Volume.CapacityBytes) + assert.Equal(t, int64(snapshotSizeGiB*GB), resp.Volume.CapacityBytes) } func TestCreateVolumeFromSnapshot_LargerSize(t *testing.T) { driver := createDriverForTest(t) ctx := context.Background() - _, snapshotID := createVolumeAndSnapshot(t, driver, 5) + _, snapshotID := createVolumeAndSnapshot(t, driver, snapshotSizeGiB) resp, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ Name: "restored-larger", VolumeCapabilities: makeVolumeCapabilityObject(false), - CapacityRange: &csi.CapacityRange{RequiredBytes: 10 * GB}, + CapacityRange: &csi.CapacityRange{RequiredBytes: int64(expandedSizeGiB) * GB}, VolumeContentSource: &csi.VolumeContentSource{ Type: &csi.VolumeContentSource_Snapshot{ Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, @@ -1223,22 +1230,22 @@ func TestCreateVolumeFromSnapshot_LargerSize(t *testing.T) { }, }) assert.NoError(t, err) - assert.Equal(t, int64(10*GB), resp.Volume.CapacityBytes) + assert.Equal(t, int64(expandedSizeGiB*GB), resp.Volume.CapacityBytes) vol, err := driver.cloudscaleClient.Volumes.Get(ctx, resp.Volume.VolumeId) assert.NoError(t, err) - assert.Equal(t, 10, vol.SizeGB) + assert.Equal(t, expandedSizeGiB, vol.SizeGB) } func TestCreateVolumeFromSnapshot_SmallerSize_Rejected(t *testing.T) { driver := createDriverForTest(t) ctx := context.Background() - _, snapshotID := createVolumeAndSnapshot(t, driver, 5) + _, snapshotID := createVolumeAndSnapshot(t, driver, snapshotSizeGiB) _, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ Name: "restored-smaller", VolumeCapabilities: makeVolumeCapabilityObject(false), - CapacityRange: &csi.CapacityRange{RequiredBytes: 3 * GB}, + CapacityRange: &csi.CapacityRange{RequiredBytes: int64(belowSnapshotSizeGiB) * GB}, VolumeContentSource: &csi.VolumeContentSource{ Type: &csi.VolumeContentSource_Snapshot{ Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, @@ -1254,12 +1261,12 @@ func TestCreateVolumeFromSnapshot_SmallerSize_Rejected(t *testing.T) { func TestCreateVolumeFromSnapshot_Idempotent_AlreadyExpanded(t *testing.T) { driver := createDriverForTest(t) ctx := context.Background() - _, snapshotID := createVolumeAndSnapshot(t, driver, 5) + _, snapshotID := createVolumeAndSnapshot(t, driver, snapshotSizeGiB) req := &csi.CreateVolumeRequest{ Name: "restored-idempotent", VolumeCapabilities: makeVolumeCapabilityObject(false), - CapacityRange: &csi.CapacityRange{RequiredBytes: 10 * GB}, + CapacityRange: &csi.CapacityRange{RequiredBytes: int64(expandedSizeGiB) * GB}, VolumeContentSource: &csi.VolumeContentSource{ Type: &csi.VolumeContentSource_Snapshot{ Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, @@ -1269,24 +1276,24 @@ func TestCreateVolumeFromSnapshot_Idempotent_AlreadyExpanded(t *testing.T) { resp1, err := driver.CreateVolume(ctx, req) assert.NoError(t, err) - assert.Equal(t, int64(10*GB), resp1.Volume.CapacityBytes) + assert.Equal(t, int64(expandedSizeGiB*GB), resp1.Volume.CapacityBytes) resp2, err := driver.CreateVolume(ctx, req) assert.NoError(t, err) assert.Equal(t, resp1.Volume.VolumeId, resp2.Volume.VolumeId) - assert.Equal(t, int64(10*GB), resp2.Volume.CapacityBytes) + assert.Equal(t, int64(expandedSizeGiB*GB), resp2.Volume.CapacityBytes) } func TestCreateVolumeFromSnapshot_Idempotent_NeedsExpansion(t *testing.T) { driver := createDriverForTest(t) ctx := context.Background() - _, snapshotID := createVolumeAndSnapshot(t, driver, 5) + _, snapshotID := createVolumeAndSnapshot(t, driver, snapshotSizeGiB) // First create at snapshot size (equal) resp1, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ Name: "restored-needs-expand", VolumeCapabilities: makeVolumeCapabilityObject(false), - CapacityRange: &csi.CapacityRange{RequiredBytes: 5 * GB}, + CapacityRange: &csi.CapacityRange{RequiredBytes: int64(snapshotSizeGiB) * GB}, VolumeContentSource: &csi.VolumeContentSource{ Type: &csi.VolumeContentSource_Snapshot{ Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, @@ -1294,13 +1301,13 @@ func TestCreateVolumeFromSnapshot_Idempotent_NeedsExpansion(t *testing.T) { }, }) assert.NoError(t, err) - assert.Equal(t, int64(5*GB), resp1.Volume.CapacityBytes) + assert.Equal(t, int64(snapshotSizeGiB*GB), resp1.Volume.CapacityBytes) // Now call again with a larger size -- simulates retry after expand failure resp2, err := driver.CreateVolume(ctx, &csi.CreateVolumeRequest{ Name: "restored-needs-expand", VolumeCapabilities: makeVolumeCapabilityObject(false), - CapacityRange: &csi.CapacityRange{RequiredBytes: 10 * GB}, + CapacityRange: &csi.CapacityRange{RequiredBytes: int64(expandedSizeGiB) * GB}, VolumeContentSource: &csi.VolumeContentSource{ Type: &csi.VolumeContentSource_Snapshot{ Snapshot: &csi.VolumeContentSource_SnapshotSource{SnapshotId: snapshotID}, @@ -1309,9 +1316,9 @@ func TestCreateVolumeFromSnapshot_Idempotent_NeedsExpansion(t *testing.T) { }) assert.NoError(t, err) assert.Equal(t, resp1.Volume.VolumeId, resp2.Volume.VolumeId) - assert.Equal(t, int64(10*GB), resp2.Volume.CapacityBytes) + assert.Equal(t, int64(expandedSizeGiB*GB), resp2.Volume.CapacityBytes) vol, err := driver.cloudscaleClient.Volumes.Get(ctx, resp2.Volume.VolumeId) assert.NoError(t, err) - assert.Equal(t, 10, vol.SizeGB) + assert.Equal(t, expandedSizeGiB, vol.SizeGB) } From bb5f218ac242c4960bd1f902d62a99da7558663a Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Mon, 23 Feb 2026 17:34:00 +0100 Subject: [PATCH 57/63] improve formating --- driver/driver_test.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/driver/driver_test.go b/driver/driver_test.go index 26ae0214..d9fdcd8e 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -1189,12 +1189,11 @@ func createVolumeAndSnapshot(t *testing.T, driver *Driver, sizeGB int) (string, } const ( - snapshotSizeGiB = 5 // source volume and snapshot size (GiB) - expandedSizeGiB = 10 // requested size larger than snapshot (triggers expansion) - belowSnapshotSizeGiB = 3 // requested size smaller than snapshot (invalid) + snapshotSizeGiB = 5 // source volume and snapshot size (GiB) + expandedSizeGiB = 10 // requested size larger than snapshot (triggers expansion) + belowSnapshotSizeGiB = 3 // requested size smaller than snapshot (invalid) ) - func TestCreateVolumeFromSnapshot_EqualSize(t *testing.T) { driver := createDriverForTest(t) ctx := context.Background() From ab1b489137ef06ea2089bf74bf7348e5c1da68ad Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 24 Feb 2026 16:50:59 +0100 Subject: [PATCH 58/63] fail hard instead of warnings if volume can not be resized --- driver/node.go | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/driver/node.go b/driver/node.go index 49090b72..167c5278 100644 --- a/driver/node.go +++ b/driver/node.go @@ -161,35 +161,33 @@ func (d *Driver) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolumeRe // device, not on the raw block device. Resolve the actual device backing // the staging path so that both LUKS and non-LUKS volumes are handled // correctly. - resizeSource := source mounter := mount.New("") devicePath, err := d.mounter.GetDeviceName(mounter, stagingTargetPath) if err != nil { - ll.WithError(err).Warn("unable to determine device path for filesystem resize, falling back to original source") - } else { - resizeSource = devicePath + return nil, status.Errorf(codes.Internal, "NodeStageVolume unable to get device path for %q: %v", stagingTargetPath, err) + } - // If the staged device is a LUKS mapping, grow the LUKS container - // first so the filesystem can see the larger size. - isLuks, _, err := isLuksMapping(devicePath) - if err != nil { - ll.WithError(err).Warn("unable to determine if device is LUKS-encrypted, skipping LUKS container resize") - } else if isLuks { - ll.WithField("device_path", devicePath).Info("resizing LUKS container before filesystem resize") - if err := luksResize(devicePath); err != nil { - return nil, status.Errorf(codes.Internal, "failed to resize LUKS container on %s: %v", devicePath, err) - } + // If the staged device is a LUKS mapping, grow the LUKS container first so + // the filesystem can see the larger size. + isLuks, _, err := isLuksMapping(devicePath) + if err != nil { + return nil, status.Errorf(codes.Internal, "NodeStageVolume unable to test if volume at %q is encrypted with LUKS: %v", devicePath, err) + } + if isLuks { + ll.WithField("device_path", devicePath).Info("resizing LUKS container before filesystem resize") + if err := luksResize(devicePath); err != nil { + return nil, status.Errorf(codes.Internal, "failed to resize LUKS container on %s: %v", devicePath, err) } } r := mount.NewResizeFs(utilexec.New()) - needResize, err := r.NeedResize(resizeSource, stagingTargetPath) + needResize, err := r.NeedResize(devicePath, stagingTargetPath) if err != nil { ll.WithError(err).Warn("unable to check if filesystem needs resize") } else if needResize { ll.Info("resizing filesystem to match block device size") - if _, err := r.Resize(resizeSource, stagingTargetPath); err != nil { - return nil, status.Errorf(codes.Internal, "failed to resize filesystem on %s: %v", resizeSource, err) + if _, err := r.Resize(devicePath, stagingTargetPath); err != nil { + return nil, status.Errorf(codes.Internal, "failed to resize filesystem on %s: %v", devicePath, err) } ll.Info("filesystem resized successfully") } From 7e395244972e9c19382721567fb6a6c05b95e538 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 24 Feb 2026 16:55:40 +0100 Subject: [PATCH 59/63] move test conts into test cases --- driver/driver_test.go | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/driver/driver_test.go b/driver/driver_test.go index d9fdcd8e..9e944c0d 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -1188,13 +1188,9 @@ func createVolumeAndSnapshot(t *testing.T, driver *Driver, sizeGB int) (string, return vol.Volume.VolumeId, snap.Snapshot.SnapshotId } -const ( - snapshotSizeGiB = 5 // source volume and snapshot size (GiB) - expandedSizeGiB = 10 // requested size larger than snapshot (triggers expansion) - belowSnapshotSizeGiB = 3 // requested size smaller than snapshot (invalid) -) - func TestCreateVolumeFromSnapshot_EqualSize(t *testing.T) { + const snapshotSizeGiB = 5 // source volume and snapshot size (GiB) + driver := createDriverForTest(t) ctx := context.Background() _, snapshotID := createVolumeAndSnapshot(t, driver, snapshotSizeGiB) @@ -1214,6 +1210,11 @@ func TestCreateVolumeFromSnapshot_EqualSize(t *testing.T) { } func TestCreateVolumeFromSnapshot_LargerSize(t *testing.T) { + const ( + snapshotSizeGiB = 5 // source volume and snapshot size (GiB) + expandedSizeGiB = 10 // requested size larger than snapshot (triggers expansion) + ) + driver := createDriverForTest(t) ctx := context.Background() _, snapshotID := createVolumeAndSnapshot(t, driver, snapshotSizeGiB) @@ -1237,6 +1238,11 @@ func TestCreateVolumeFromSnapshot_LargerSize(t *testing.T) { } func TestCreateVolumeFromSnapshot_SmallerSize_Rejected(t *testing.T) { + const ( + snapshotSizeGiB = 5 // source volume and snapshot size (GiB) + belowSnapshotSizeGiB = 3 // requested size smaller than snapshot (invalid) + ) + driver := createDriverForTest(t) ctx := context.Background() _, snapshotID := createVolumeAndSnapshot(t, driver, snapshotSizeGiB) @@ -1258,6 +1264,11 @@ func TestCreateVolumeFromSnapshot_SmallerSize_Rejected(t *testing.T) { } func TestCreateVolumeFromSnapshot_Idempotent_AlreadyExpanded(t *testing.T) { + const ( + snapshotSizeGiB = 5 // source volume and snapshot size (GiB) + expandedSizeGiB = 10 // requested size larger than snapshot (triggers expansion) + ) + driver := createDriverForTest(t) ctx := context.Background() _, snapshotID := createVolumeAndSnapshot(t, driver, snapshotSizeGiB) @@ -1284,6 +1295,11 @@ func TestCreateVolumeFromSnapshot_Idempotent_AlreadyExpanded(t *testing.T) { } func TestCreateVolumeFromSnapshot_Idempotent_NeedsExpansion(t *testing.T) { + const ( + snapshotSizeGiB = 5 // source volume and snapshot size (GiB) + expandedSizeGiB = 10 // requested size larger than snapshot (triggers expansion) + ) + driver := createDriverForTest(t) ctx := context.Background() _, snapshotID := createVolumeAndSnapshot(t, driver, snapshotSizeGiB) From 180d4fc077f9dff8363b45ef40bf8c6ecee3e62d Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Thu, 26 Feb 2026 17:32:10 +0100 Subject: [PATCH 60/63] update to cloudscale-go-sdk/v7 --- driver/controller.go | 2 +- driver/driver.go | 2 +- driver/driver_test.go | 2 +- driver/driver_volume_type_test.go | 2 +- go.mod | 4 ++-- go.sum | 8 ++++---- test/kubernetes/integration_test.go | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/driver/controller.go b/driver/controller.go index 6103b845..b80dcaa9 100644 --- a/driver/controller.go +++ b/driver/controller.go @@ -28,7 +28,7 @@ import ( "strings" "time" - "github.com/cloudscale-ch/cloudscale-go-sdk/v6" + "github.com/cloudscale-ch/cloudscale-go-sdk/v7" "github.com/container-storage-interface/spec/lib/go/csi" "github.com/sirupsen/logrus" "google.golang.org/grpc/codes" diff --git a/driver/driver.go b/driver/driver.go index e9f4dde3..36a6672c 100644 --- a/driver/driver.go +++ b/driver/driver.go @@ -27,7 +27,7 @@ import ( "path/filepath" "sync" - "github.com/cloudscale-ch/cloudscale-go-sdk/v6" + "github.com/cloudscale-ch/cloudscale-go-sdk/v7" "github.com/container-storage-interface/spec/lib/go/csi" "github.com/sirupsen/logrus" "golang.org/x/oauth2" diff --git a/driver/driver_test.go b/driver/driver_test.go index 9e944c0d..420640b4 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -31,7 +31,7 @@ import ( "time" "github.com/cenkalti/backoff/v5" - "github.com/cloudscale-ch/cloudscale-go-sdk/v6" + "github.com/cloudscale-ch/cloudscale-go-sdk/v7" "github.com/container-storage-interface/spec/lib/go/csi" "github.com/google/uuid" "github.com/kubernetes-csi/csi-test/v5/pkg/sanity" diff --git a/driver/driver_volume_type_test.go b/driver/driver_volume_type_test.go index ac1a51df..608fc032 100644 --- a/driver/driver_volume_type_test.go +++ b/driver/driver_volume_type_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/cloudscale-ch/cloudscale-go-sdk/v6" + "github.com/cloudscale-ch/cloudscale-go-sdk/v7" "github.com/container-storage-interface/spec/lib/go/csi" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" diff --git a/go.mod b/go.mod index 21915308..733479d0 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/cloudscale-ch/csi-cloudscale require ( github.com/cenkalti/backoff/v5 v5.0.3 - github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260213173932-ae95a6ffee99 + github.com/cloudscale-ch/cloudscale-go-sdk/v7 v7.0.0 github.com/container-storage-interface/spec v1.12.0 github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 @@ -10,7 +10,7 @@ require ( github.com/kubernetes-csi/external-snapshotter/client/v6 v6.3.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 - golang.org/x/oauth2 v0.34.0 + golang.org/x/oauth2 v0.35.0 golang.org/x/sys v0.39.0 google.golang.org/grpc v1.77.0 google.golang.org/protobuf v1.36.10 diff --git a/go.sum b/go.sum index 0c3eba74..70175cde 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260213173932-ae95a6ffee99 h1:0Mf7/BVgEfOUCLBvnJChUWGTkwDCGJZrN5KA/EPR7q0= -github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.2-0.20260213173932-ae95a6ffee99/go.mod h1:NLC7XW7HqG0HggDaOBCvmf7WplTDaAqTF9u08yh6k0E= +github.com/cloudscale-ch/cloudscale-go-sdk/v7 v7.0.0 h1:uHs90VA9fnAxfvbvo2Yz/wWTzhrEU2nJKRzBU9Rc6Ng= +github.com/cloudscale-ch/cloudscale-go-sdk/v7 v7.0.0/go.mod h1:CFpGwhqMUmhTk8wWvyc2qOq4pb35eq+7f0aAlRiMr2A= github.com/container-storage-interface/spec v1.12.0 h1:zrFOEqpR5AghNaaDG4qyedwPBqU2fU0dWjLQMP/azK0= github.com/container-storage-interface/spec v1.12.0/go.mod h1:txsm+MA2B2WDa5kW69jNbqPnvTtfvZma7T/zsAZ9qX8= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -124,8 +124,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo= golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/test/kubernetes/integration_test.go b/test/kubernetes/integration_test.go index 1a28e768..d6876159 100644 --- a/test/kubernetes/integration_test.go +++ b/test/kubernetes/integration_test.go @@ -18,7 +18,7 @@ import ( "testing" "time" - "github.com/cloudscale-ch/cloudscale-go-sdk/v6" + "github.com/cloudscale-ch/cloudscale-go-sdk/v7" "github.com/cloudscale-ch/csi-cloudscale/driver" "github.com/stretchr/testify/assert" "golang.org/x/oauth2" From 620d16e0b9e27b1b12b8305fd861dafb776f36a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:11:39 +0000 Subject: [PATCH 61/63] Bump google.golang.org/grpc from 1.77.0 to 1.79.1 Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.77.0 to 1.79.1. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.77.0...v1.79.1) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.79.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 10 +++++----- go.sum | 42 ++++++++++++++++++++++-------------------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index a65da1cf..3b9a3ce7 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.34.0 golang.org/x/sys v0.39.0 - google.golang.org/grpc v1.77.0 + google.golang.org/grpc v1.79.1 k8s.io/api v0.28.15 k8s.io/apimachinery v0.28.15 k8s.io/client-go v0.28.15 @@ -46,11 +46,11 @@ require ( github.com/onsi/gomega v1.36.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.3.0 // indirect - golang.org/x/tools v0.37.0 // indirect + golang.org/x/tools v0.39.0 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index efe880fb..47968f16 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.1 h1:2P+TKwtB50hogQ2neIPX+7ARNMy7vaDU9bkMGEhOz3k= github.com/cloudscale-ch/cloudscale-go-sdk/v6 v6.0.1/go.mod h1:NLC7XW7HqG0HggDaOBCvmf7WplTDaAqTF9u08yh6k0E= github.com/container-storage-interface/spec v1.12.0 h1:zrFOEqpR5AghNaaDG4qyedwPBqU2fU0dWjLQMP/azK0= @@ -99,16 +101,16 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -120,8 +122,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo= -golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -139,12 +141,12 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -152,8 +154,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -162,8 +164,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 633632679acdfc025f790e743de278495d5f658a Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Fri, 27 Feb 2026 11:35:39 +0100 Subject: [PATCH 62/63] make NEW_VERSION=v4.0.0 bump-version --- CHANGELOG.md | 2 + README.md | 28 +- VERSION | 2 +- charts/csi-cloudscale/Chart.yaml | 2 +- charts/csi-cloudscale/values.yaml | 4 +- .../releases/csi-cloudscale-v4.0.0.yaml | 470 ++++++++++++++++++ 6 files changed, 490 insertions(+), 18 deletions(-) create mode 100644 deploy/kubernetes/releases/csi-cloudscale-v4.0.0.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f747469..30dc5e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## unreleased +## v4.0.0 - 2026.02.27 + ## v3.6.0 - 2026.01.15 ⚠️ **Update 2026.02.04: Breaking Change** diff --git a/README.md b/README.md index 8d56ae5a..998e2ca4 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ secret `my-pvc-luks-key`. ## Releases The cloudscale.ch CSI plugin follows [semantic versioning](https://semver.org/). -The current version is: **`v3.6.0`**. +The current version is: **`v4.0.0`**. * Bug fixes will be released as a `PATCH` update. * New features (such as CSI spec bumps) will be released as a `MINOR` update. @@ -105,14 +105,14 @@ We recommend using the latest cloudscale.ch CSI driver compatible with your Kube | 1.25 | v3.3.0 | v3.5.6 | | 1.26 | v3.3.0 | v3.5.6 | | 1.27 | v3.3.0 | v3.5.6 | -| 1.28 | v3.3.0 | v3.6.0 | -| 1.29 | v3.3.0 | v3.6.0 | -| 1.30 | v3.3.0 | v3.6.0 | -| 1.31 | v3.3.0 | v3.6.0 | -| 1.32 | v3.3.0 | v3.6.0 | -| 1.33 | v3.3.0 | v3.6.0 | -| 1.34 [1] | v3.3.0 | v3.6.0 | -| 1.35 | v3.4.1 | v3.6.0 | +| 1.28 | v3.3.0 | v4.0.0 | +| 1.29 | v3.3.0 | v4.0.0 | +| 1.30 | v3.3.0 | v4.0.0 | +| 1.31 | v3.3.0 | v4.0.0 | +| 1.32 | v3.3.0 | v4.0.0 | +| 1.33 | v3.3.0 | v4.0.0 | +| 1.34 [1] | v3.3.0 | v4.0.0 | +| 1.35 | v3.4.1 | v4.0.0 | [1] Prometheus `kubelet_volume_stats_*` metrics not available in 1.34.0 and 1.34.1 due to a [bug in Kubelet](https://github.com/kubernetes/kubernetes/issues/133847). Fixed in `1.34.2`. @@ -214,10 +214,10 @@ $ helm install -g -n kube-system --set controller.image.tag=dev --set node.image Before you continue, be sure to checkout to a [tagged release](https://github.com/cloudscale-ch/csi-cloudscale/releases). Always use the [latest stable version](https://github.com/cloudscale-ch/csi-cloudscale/releases/latest) -For example, to use the latest stable version (`v3.6.0`) you can execute the following command: +For example, to use the latest stable version (`v4.0.0`) you can execute the following command: ``` -$ kubectl apply -f https://raw.githubusercontent.com/cloudscale-ch/csi-cloudscale/master/deploy/kubernetes/releases/csi-cloudscale-v3.6.0.yaml +$ kubectl apply -f https://raw.githubusercontent.com/cloudscale-ch/csi-cloudscale/master/deploy/kubernetes/releases/csi-cloudscale-v4.0.0.yaml ``` The storage classes `cloudscale-volume-ssd` and `cloudscale-volume-bulk` will be created. The @@ -438,15 +438,15 @@ $ git push origin After it's merged to master, [create a new Github release](https://github.com/cloudscale-ch/csi-cloudscale/releases/new) from -master with the version `v3.6.0` and then publish a new docker build: +master with the version `v4.0.0` and then publish a new docker build: ``` $ git checkout master $ make publish ``` -This will create a binary with version `v3.6.0` and docker image pushed to -`cloudscalech/cloudscale-csi-plugin:v3.6.0` +This will create a binary with version `v4.0.0` and docker image pushed to +`cloudscalech/cloudscale-csi-plugin:v4.0.0` ### Release a pre-release version diff --git a/VERSION b/VERSION index 130165bc..857572fc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v3.6.0 +v4.0.0 diff --git a/charts/csi-cloudscale/Chart.yaml b/charts/csi-cloudscale/Chart.yaml index 519370f1..98224542 100644 --- a/charts/csi-cloudscale/Chart.yaml +++ b/charts/csi-cloudscale/Chart.yaml @@ -3,7 +3,7 @@ name: csi-cloudscale description: A Container Storage Interface Driver for cloudscale.ch volumes. type: application version: 1.4.0 -appVersion: "3.6.0" +appVersion: "4.0.0" home: https://github.com/cloudscale-ch/csi-cloudscale sources: - https://github.com/cloudscale-ch/csi-cloudscale.git diff --git a/charts/csi-cloudscale/values.yaml b/charts/csi-cloudscale/values.yaml index 6e1dd0a5..64a5025f 100644 --- a/charts/csi-cloudscale/values.yaml +++ b/charts/csi-cloudscale/values.yaml @@ -101,7 +101,7 @@ controller: image: registry: quay.io repository: cloudscalech/cloudscale-csi-plugin - tag: v3.6.0 + tag: v4.0.0 pullPolicy: IfNotPresent serviceAccountName: logLevel: info @@ -117,7 +117,7 @@ node: image: registry: quay.io repository: cloudscalech/cloudscale-csi-plugin - tag: v3.6.0 + tag: v4.0.0 pullPolicy: IfNotPresent nodeSelector: {} tolerations: [] diff --git a/deploy/kubernetes/releases/csi-cloudscale-v4.0.0.yaml b/deploy/kubernetes/releases/csi-cloudscale-v4.0.0.yaml new file mode 100644 index 00000000..bcd02df3 --- /dev/null +++ b/deploy/kubernetes/releases/csi-cloudscale-v4.0.0.yaml @@ -0,0 +1,470 @@ +--- +# Source: csi-cloudscale/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: csi-cloudscale-controller-sa + namespace: kube-system +--- +# Source: csi-cloudscale/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: csi-cloudscale-node-sa + namespace: kube-system +--- +# Source: csi-cloudscale/templates/storageclass.yaml +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: cloudscale-volume-ssd + namespace: kube-system + annotations: + storageclass.kubernetes.io/is-default-class: "true" +provisioner: csi.cloudscale.ch +allowVolumeExpansion: true +reclaimPolicy: Delete +volumeBindingMode: Immediate +parameters: + csi.cloudscale.ch/volume-type: ssd +--- +# Source: csi-cloudscale/templates/storageclass.yaml +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: cloudscale-volume-ssd-luks + namespace: kube-system +provisioner: csi.cloudscale.ch +allowVolumeExpansion: true +reclaimPolicy: Delete +volumeBindingMode: Immediate +parameters: + csi.cloudscale.ch/volume-type: ssd + csi.cloudscale.ch/luks-encrypted: "true" + csi.cloudscale.ch/luks-cipher: "aes-xts-plain64" + csi.cloudscale.ch/luks-key-size: "512" + csi.storage.k8s.io/node-stage-secret-namespace: ${pvc.namespace} + csi.storage.k8s.io/node-stage-secret-name: ${pvc.name}-luks-key +--- +# Source: csi-cloudscale/templates/storageclass.yaml +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: cloudscale-volume-bulk + namespace: kube-system +provisioner: csi.cloudscale.ch +allowVolumeExpansion: true +reclaimPolicy: Delete +volumeBindingMode: Immediate +parameters: + csi.cloudscale.ch/volume-type: bulk +--- +# Source: csi-cloudscale/templates/storageclass.yaml +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: cloudscale-volume-bulk-luks + namespace: kube-system +provisioner: csi.cloudscale.ch +allowVolumeExpansion: true +reclaimPolicy: Delete +volumeBindingMode: Immediate +parameters: + csi.cloudscale.ch/volume-type: bulk + csi.cloudscale.ch/luks-encrypted: "true" + csi.cloudscale.ch/luks-cipher: "aes-xts-plain64" + csi.cloudscale.ch/luks-key-size: "512" + csi.storage.k8s.io/node-stage-secret-namespace: ${pvc.namespace} + csi.storage.k8s.io/node-stage-secret-name: ${pvc.name}-luks-key +--- +# Source: csi-cloudscale/templates/rbac.yaml +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: csi-cloudscale-provisioner-role +rules: + - apiGroups: [""] + resources: ["persistentvolumes"] + verbs: ["get", "list", "watch", "create", "patch", "delete"] + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["get", "list", "watch", "update"] + - apiGroups: ["storage.k8s.io"] + resources: ["storageclasses"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["events"] + verbs: ["list", "watch", "create", "update", "patch"] + - apiGroups: ["snapshot.storage.k8s.io"] + resources: ["volumesnapshots"] + verbs: ["get", "list", "watch", "update"] + - apiGroups: ["snapshot.storage.k8s.io"] + resources: ["volumesnapshotcontents"] + verbs: ["get", "list"] + - apiGroups: [ "coordination.k8s.io" ] + resources: [ "leases" ] + verbs: [ "get", "list", "watch", "create", "update", "patch", "delete" ] + - apiGroups: [ "storage.k8s.io" ] + resources: [ "csinodes" ] + verbs: [ "get", "list", "watch" ] + - apiGroups: [ "" ] + resources: [ "nodes" ] + verbs: [ "get", "list", "watch" ] + - apiGroups: ["storage.k8s.io"] + resources: ["volumeattachments"] + verbs: ["get", "list", "watch"] +--- +# Source: csi-cloudscale/templates/rbac.yaml +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: csi-cloudscale-attacher-role +rules: + - apiGroups: [""] + resources: ["persistentvolumes"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["storage.k8s.io"] + resources: ["csinodes"] + verbs: ["get", "list", "watch"] + - apiGroups: ["storage.k8s.io"] + resources: ["volumeattachments"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["storage.k8s.io"] + resources: ["volumeattachments/status"] + verbs: ["patch"] +--- +# Source: csi-cloudscale/templates/rbac.yaml +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: csi-cloudscale-snapshotter-role +rules: + - apiGroups: ["snapshot.storage.k8s.io"] + resources: ["volumesnapshots"] + verbs: [ "get", "list", "watch", "update" ] + - apiGroups: ["snapshot.storage.k8s.io"] + resources: ["volumesnapshotcontents"] + verbs: [ "get", "list", "watch", "update", "patch" ] + - apiGroups: [ "snapshot.storage.k8s.io" ] + resources: [ "volumesnapshotcontents/status" ] + verbs: [ "update", "patch" ] + - apiGroups: [ "snapshot.storage.k8s.io" ] + resources: [ "volumesnapshotclasses" ] + verbs: [ "get", "list", "watch" ] + - apiGroups: [""] + resources: ["events"] + verbs: ["list", "watch", "create", "update", "patch"] +--- +# Source: csi-cloudscale/templates/rbac.yaml +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: csi-cloudscale-resizer-role +rules: + - apiGroups: [""] + resources: ["persistentvolumes"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["persistentvolumeclaims/status"] + verbs: ["update", "patch"] + - apiGroups: [""] + resources: ["events"] + verbs: ["list", "watch", "create", "update", "patch"] + - apiGroups: ["storage.k8s.io"] + resources: ["volumeattributesclasses"] + verbs: ["get", "list", "watch"] +--- +# Source: csi-cloudscale/templates/rbac.yaml +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: csi-cloudscale-node-driver-registrar-role + namespace: kube-system +rules: + - apiGroups: [""] + resources: ["events"] + verbs: ["get", "list", "watch", "create", "update", "patch"] +--- +# Source: csi-cloudscale/templates/rbac.yaml +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: csi-cloudscale-provisioner-binding +subjects: + - kind: ServiceAccount + name: csi-cloudscale-controller-sa + namespace: kube-system +roleRef: + kind: ClusterRole + name: csi-cloudscale-provisioner-role + apiGroup: rbac.authorization.k8s.io +--- +# Source: csi-cloudscale/templates/rbac.yaml +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: csi-cloudscale-snapshotter-binding +subjects: + - kind: ServiceAccount + name: csi-cloudscale-controller-sa + namespace: kube-system +roleRef: + kind: ClusterRole + name: csi-cloudscale-snapshotter-role + apiGroup: rbac.authorization.k8s.io +--- +# Source: csi-cloudscale/templates/rbac.yaml +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: csi-cloudscale-resizer-binding +subjects: + - kind: ServiceAccount + name: csi-cloudscale-controller-sa + namespace: kube-system +roleRef: + kind: ClusterRole + name: csi-cloudscale-resizer-role + apiGroup: rbac.authorization.k8s.io +--- +# Source: csi-cloudscale/templates/rbac.yaml +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: csi-cloudscale-attacher-binding +subjects: + - kind: ServiceAccount + name: csi-cloudscale-controller-sa + namespace: kube-system +roleRef: + kind: ClusterRole + name: csi-cloudscale-attacher-role + apiGroup: rbac.authorization.k8s.io +--- +# Source: csi-cloudscale/templates/rbac.yaml +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: csi-cloudscale-node-driver-registrar-binding +subjects: + - kind: ServiceAccount + name: csi-cloudscale-node-sa + namespace: kube-system +roleRef: + kind: ClusterRole + name: csi-cloudscale-node-driver-registrar-role + apiGroup: rbac.authorization.k8s.io +--- +# Source: csi-cloudscale/templates/daemonset.yaml +kind: DaemonSet +apiVersion: apps/v1 +metadata: + name: csi-cloudscale-node + namespace: kube-system +spec: + selector: + matchLabels: + app: csi-cloudscale-node + template: + metadata: + labels: + app: csi-cloudscale-node + role: csi-cloudscale + spec: + priorityClassName: system-node-critical + serviceAccountName: csi-cloudscale-node-sa + hostNetwork: true + containers: + - name: csi-node-driver-registrar + image: "registry.k8s.io/sig-storage/csi-node-driver-registrar:v2.15.0" + imagePullPolicy: IfNotPresent + args: + - "--v=5" + - "--csi-address=$(ADDRESS)" + - "--kubelet-registration-path=$(DRIVER_REG_SOCK_PATH)" + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "rm -rf /registration/csi.cloudscale.ch /registration/csi.cloudscale.ch-reg.sock"] + env: + - name: ADDRESS + value: /csi/csi.sock + - name: DRIVER_REG_SOCK_PATH + value: /var/lib/kubelet/plugins/csi.cloudscale.ch/csi.sock + - name: KUBE_NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + volumeMounts: + - name: plugin-dir + mountPath: /csi/ + - name: registration-dir + mountPath: /registration/ + - name: csi-cloudscale-plugin + image: "quay.io/cloudscalech/cloudscale-csi-plugin:v4.0.0" + imagePullPolicy: IfNotPresent + args : + - "--endpoint=$(CSI_ENDPOINT)" + - "--url=$(CLOUDSCALE_API_URL)" + - "--log-level=info" + env: + - name: CSI_ENDPOINT + value: unix:///csi/csi.sock + - name: CLOUDSCALE_API_URL + value: https://api.cloudscale.ch/ + - name: CLOUDSCALE_MAX_CSI_VOLUMES_PER_NODE + value: "125" + - name: CLOUDSCALE_ACCESS_TOKEN + valueFrom: + secretKeyRef: + name: cloudscale + key: access-token + securityContext: + privileged: true + capabilities: + add: ["SYS_ADMIN"] + allowPrivilegeEscalation: true + volumeMounts: + - name: plugin-dir + mountPath: /csi + - name: pods-mount-dir + mountPath: /var/lib/kubelet + # needed so that any mounts setup inside this container are + # propagated back to the host machine. + mountPropagation: "Bidirectional" + - name: device-dir + mountPath: /dev + - name: tmpfs + mountPath: /tmp + volumes: + - name: registration-dir + hostPath: + path: /var/lib/kubelet/plugins_registry/ + type: DirectoryOrCreate + - name: plugin-dir + hostPath: + path: /var/lib/kubelet/plugins/csi.cloudscale.ch + type: DirectoryOrCreate + - name: pods-mount-dir + hostPath: + path: /var/lib/kubelet + type: Directory + - name: device-dir + hostPath: + path: /dev + # to make sure temporary stored luks keys never touch a disk + - name: tmpfs + emptyDir: + medium: Memory +--- +# Source: csi-cloudscale/templates/statefulset.yaml +kind: StatefulSet +apiVersion: apps/v1 +metadata: + name: csi-cloudscale-controller + namespace: kube-system +spec: + serviceName: "csi-cloudscale" + selector: + matchLabels: + app: csi-cloudscale-controller + replicas: 1 + template: + metadata: + labels: + app: csi-cloudscale-controller + role: csi-cloudscale + spec: + hostNetwork: true + priorityClassName: system-cluster-critical + serviceAccount: csi-cloudscale-controller-sa + containers: + - name: csi-provisioner + image: "registry.k8s.io/sig-storage/csi-provisioner:v5.3.0" + imagePullPolicy: IfNotPresent + args: + - "--csi-address=$(ADDRESS)" + - "--default-fstype=ext4" + - "--v=5" + - "--feature-gates=Topology=false" + env: + - name: ADDRESS + value: /var/lib/csi/sockets/pluginproxy/csi.sock + volumeMounts: + - name: socket-dir + mountPath: /var/lib/csi/sockets/pluginproxy/ + - name: csi-attacher + image: "registry.k8s.io/sig-storage/csi-attacher:v4.10.0" + imagePullPolicy: IfNotPresent + args: + - "--csi-address=$(ADDRESS)" + - "--v=5" + env: + - name: ADDRESS + value: /var/lib/csi/sockets/pluginproxy/csi.sock + volumeMounts: + - name: socket-dir + mountPath: /var/lib/csi/sockets/pluginproxy/ + - name: csi-resizer + image: "registry.k8s.io/sig-storage/csi-resizer:v2.0.0" + args: + - "--csi-address=$(ADDRESS)" + - "--timeout=30s" + - "--v=5" + - "--handle-volume-inuse-error=false" + env: + - name: ADDRESS + value: /var/lib/csi/sockets/pluginproxy/csi.sock + imagePullPolicy: IfNotPresent + volumeMounts: + - name: socket-dir + mountPath: /var/lib/csi/sockets/pluginproxy/ + - name: csi-snapshotter + image: "registry.k8s.io/sig-storage/csi-snapshotter:v8.4.0" + args: + - "--csi-address=$(CSI_ENDPOINT)" + - "--v=5" + env: + - name: CSI_ENDPOINT + value: unix:///var/lib/csi/sockets/pluginproxy/csi.sock + volumeMounts: + - name: socket-dir + mountPath: /var/lib/csi/sockets/pluginproxy/ + - name: csi-cloudscale-plugin + image: "quay.io/cloudscalech/cloudscale-csi-plugin:v4.0.0" + args : + - "--endpoint=$(CSI_ENDPOINT)" + - "--url=$(CLOUDSCALE_API_URL)" + - "--log-level=info" + env: + - name: CSI_ENDPOINT + value: unix:///var/lib/csi/sockets/pluginproxy/csi.sock + - name: CLOUDSCALE_API_URL + value: https://api.cloudscale.ch/ + - name: CLOUDSCALE_ACCESS_TOKEN + valueFrom: + secretKeyRef: + name: cloudscale + key: access-token + imagePullPolicy: IfNotPresent + volumeMounts: + - name: socket-dir + mountPath: /var/lib/csi/sockets/pluginproxy/ + volumes: + - name: socket-dir + emptyDir: {} +--- +# Source: csi-cloudscale/templates/csi_driver.yaml +apiVersion: storage.k8s.io/v1 +kind: CSIDriver +metadata: + name: csi.cloudscale.ch +spec: + attachRequired: true + podInfoOnMount: true From 40da8effaf5ecbca7d02d47a1db163a385c36eae Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Fri, 27 Feb 2026 11:49:07 +0100 Subject: [PATCH 63/63] make NEW_CHART_VERSION=v1.5.0 bump-chart-version --- charts/csi-cloudscale/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/csi-cloudscale/Chart.yaml b/charts/csi-cloudscale/Chart.yaml index 98224542..9692b058 100644 --- a/charts/csi-cloudscale/Chart.yaml +++ b/charts/csi-cloudscale/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: csi-cloudscale description: A Container Storage Interface Driver for cloudscale.ch volumes. type: application -version: 1.4.0 +version: 1.5.0 appVersion: "4.0.0" home: https://github.com/cloudscale-ch/csi-cloudscale sources: