Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions PendingReleaseNotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,10 @@

## Features

### CephFS

* **Namespace isolated subvolumes:** Added an optional StorageClass parameter
(`namespaceIsolated`, default disabled) that allows creating new subvolumes
in a unique, isolated RADOS namespace

## NOTE
1 change: 1 addition & 0 deletions docs/cephfs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ you're running it inside a k8s cluster and find the config itself).
| `encrypted` | no | disabled by default, use `"true"` to enable fscrypt encryption on PVC and `"false"` to disable it. **Do not change for existing storageclasses** |
| `encryptionKMSID` | no | required if encryption is enabled and a kms is used to store passphrases |
| `extraDeploy` | no | array of extra objects to deploy with the release |
| `namespaceIsolated` | no | Boolean value (truthy/falsy string), disabled by default - set to `"true"` in your Kubernetes CephFS StorageClass to create each new subvolume in a unique, isolated RADOS namespace. |

**NOTE:** An accompanying CSI configuration file, needs to be provided to the
running pods. Refer to [Creating CSI configuration](../../examples/README.md#creating-csi-configuration)
Expand Down
8 changes: 8 additions & 0 deletions examples/cephfs/storageclass.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ parameters:
# correlation to configmap entry.
# encryptionKMSID: <kms-config-id>

# (optional) Enable RADOS namespace isolation for new CephFS subvolumes
# Type: Boolean (as String), defaults to "false"
# If enabled, each new subvolume created on the associated Ceph FS/volume
# will get a unique RADOS namespace created for, and permanently assigned,
# to it. This differs from file/directory layouts, as it is immutable. The
# name of the new NS is not configurable, as it is automatically derived
# from the subvolume name with an "fsvolumens_" prefix.
# namespaceIsolated: "true"

reclaimPolicy: Delete
allowVolumeExpansion: true
Expand Down
13 changes: 13 additions & 0 deletions internal/cephfs/controllerserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,9 @@ func buildCreateVolumeResponse(
volumeContext := util.GetVolumeContext(req.GetParameters())
volumeContext["subvolumeName"] = vID.FsSubvolName
volumeContext["subvolumePath"] = volOptions.RootPath
if volOptions.PoolNamespace != "" {
volumeContext["radosNamespace"] = volOptions.PoolNamespace
}
volume := &csi.Volume{
VolumeId: vID.VolumeID,
CapacityBytes: volOptions.Size,
Expand Down Expand Up @@ -470,6 +473,16 @@ func (cs *cephfsControllerServer) CreateVolume(
return nil, status.Error(codes.Internal, err.Error())
}

if volOptions.NamespaceIsolated {
svInfo, err := volClient.GetSubVolumeInfo(ctx)
if err != nil {
log.WarningLog(ctx, "failed to get subvolume info for pool namespace %s: %v",
vID.FsSubvolName, err)
} else {
volOptions.PoolNamespace = svInfo.PoolNamespace
}
}

// Set Metadata on PV Create
err = volClient.SetAllMetadata(metadata)
if err != nil {
Expand Down
70 changes: 70 additions & 0 deletions internal/cephfs/controllerserver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
Copyright 2026 The Ceph-CSI Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package cephfs

import (
"testing"

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

"github.com/ceph/ceph-csi/internal/cephfs/store"
)

func TestBuildCreateVolumeResponse_WithRadosNamespace(t *testing.T) {
t.Parallel()
const ns = "csi-vol-4c742621-090a-484d-a33b"
req := &csi.CreateVolumeRequest{Parameters: map[string]string{}}
volOptions := &store.VolumeOptions{
PoolNamespace: ns,
RootPath: "/volumes/csi/csi-vol-test/uuid",
}
vID := &store.VolumeIdentifier{FsSubvolName: "csi-vol-test", VolumeID: "vol-0001"}

resp := buildCreateVolumeResponse(req, volOptions, vID)

require.Equal(t, ns, resp.GetVolume().GetVolumeContext()["radosNamespace"])
}

func TestBuildCreateVolumeResponse_WithoutRadosNamespace(t *testing.T) {
t.Parallel()
req := &csi.CreateVolumeRequest{Parameters: map[string]string{}}
volOptions := &store.VolumeOptions{
RootPath: "/volumes/csi/csi-vol-test/uuid",
}
vID := &store.VolumeIdentifier{FsSubvolName: "csi-vol-test", VolumeID: "vol-0001"}

resp := buildCreateVolumeResponse(req, volOptions, vID)

_, present := resp.GetVolume().GetVolumeContext()["radosNamespace"]
require.False(t, present, "radosNamespace must be absent when PoolNamespace is empty")
}

func TestBuildCreateVolumeResponse_SubvolNameAndPath(t *testing.T) {
t.Parallel()
req := &csi.CreateVolumeRequest{Parameters: map[string]string{}}
volOptions := &store.VolumeOptions{
RootPath: "/volumes/csi/csi-vol-test/uuid",
}
vID := &store.VolumeIdentifier{FsSubvolName: "csi-vol-test", VolumeID: "vol-0001"}

resp := buildCreateVolumeResponse(req, volOptions, vID)

vc := resp.GetVolume().GetVolumeContext()
require.Equal(t, "csi-vol-test", vc["subvolumeName"])
require.Equal(t, "/volumes/csi/csi-vol-test/uuid", vc["subvolumePath"])
}
39 changes: 30 additions & 9 deletions internal/cephfs/core/volume.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ type Subvolume struct {
BytesQuota int64
Path string
Features []string
// RADOS namespace assigned by Ceph when created with namespace_isolated=true. Empty for standard subvolumes.
PoolNamespace string
}

// SubVolumeClient is the interface that holds the signature of subvolume methods
Expand Down Expand Up @@ -107,6 +109,8 @@ type SubVolume struct {
Pool string // pool name where subvolume will be created.
Features []string // subvolume features.
Size int64 // subvolume size.
// Set to true to create the subvolume with a dedicated RADOS namespace.
NamespaceIsolated bool
}

// NewSubVolume returns a new subvolume client.
Expand Down Expand Up @@ -174,18 +178,25 @@ func (s *subVolumeClient) GetSubVolumeInfo(ctx context.Context) (*Subvolume, err
return nil, err
}

return subvolumeFromInfo(info, s.VolID)
}

// subvolumeFromInfo converts an fsAdmin.SubVolumeInfo to a Subvolume,
// handling the polymorphic BytesQuota field.
func subvolumeFromInfo(info *fsAdmin.SubVolumeInfo, volID string) (*Subvolume, error) {
subvol := Subvolume{
// only set BytesQuota when it is of type ByteCount
Path: info.Path,
Features: make([]string, len(info.Features)),
Path: info.Path,
PoolNamespace: info.PoolNamespace,
Features: make([]string, len(info.Features)),
}
bc, ok := info.BytesQuota.(fsAdmin.ByteCount)
if !ok {
// If info.BytesQuota == Infinite (in case it is not set)
// or nil (in case the subvolume is in snapshot-retained state),
// just continue without returning quota information.
if !(info.BytesQuota == fsAdmin.Infinite || info.State == fsAdmin.StateSnapRetained) {
return nil, fmt.Errorf("subvolume %s has unsupported quota: %v", s.VolID, info.BytesQuota)
return nil, fmt.Errorf("subvolume %s has unsupported quota: %v", volID, info.BytesQuota)
}
} else {
subvol.BytesQuota = int64(bc)
Expand Down Expand Up @@ -221,6 +232,21 @@ func newLocalClusterState(clusterID string) {
}
}

// buildSubVolumeCreateOpts constructs the SubVolumeOptions from the SubVolume fields.
func buildSubVolumeCreateOpts(sv *SubVolume) fsAdmin.SubVolumeOptions {
opts := fsAdmin.SubVolumeOptions{
Size: fsAdmin.ByteCount(sv.Size),
}
if sv.Pool != "" {
opts.PoolLayout = sv.Pool
}
if sv.NamespaceIsolated {
opts.NamespaceIsolated = true
}

return opts
}

// CreateVolume creates a subvolume.
func (s *subVolumeClient) CreateVolume(ctx context.Context) error {
newLocalClusterState(s.clusterID)
Expand All @@ -232,12 +258,7 @@ func (s *subVolumeClient) CreateVolume(ctx context.Context) error {
return err
}

opts := fsAdmin.SubVolumeOptions{
Size: fsAdmin.ByteCount(s.Size),
}
if s.Pool != "" {
opts.PoolLayout = s.Pool
}
opts := buildSubVolumeCreateOpts(s.SubVolume)

// FIXME: check if the right credentials are used ("-n", cephEntityClientPrefix + cr.ID)
err = ca.CreateSubVolume(s.FsName, s.SubvolumeGroup, s.VolID, &opts)
Expand Down
60 changes: 60 additions & 0 deletions internal/cephfs/core/volume_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
Copyright 2026 The Ceph-CSI Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package core

import (
"testing"

fsAdmin "github.com/ceph/go-ceph/cephfs/admin"
"github.com/stretchr/testify/require"
)

func TestCreateVolume_NamespaceIsolated_True(t *testing.T) {
t.Parallel()
sv := &SubVolume{NamespaceIsolated: true}
opts := buildSubVolumeCreateOpts(sv)
require.True(t, opts.NamespaceIsolated)
}

func TestCreateVolume_NamespaceIsolated_False(t *testing.T) {
t.Parallel()
sv := &SubVolume{}
opts := buildSubVolumeCreateOpts(sv)
require.False(t, opts.NamespaceIsolated)
}

func TestGetSubVolumeInfo_PoolNamespace_Populated(t *testing.T) {
t.Parallel()
const ns = "csi-vol-4c742621-090a-484d-a33b"
info := &fsAdmin.SubVolumeInfo{
PoolNamespace: ns,
BytesQuota: fsAdmin.Infinite,
}
sv, err := subvolumeFromInfo(info, "test-vol")
require.NoError(t, err)
require.Equal(t, ns, sv.PoolNamespace)
}

func TestGetSubVolumeInfo_PoolNamespace_Empty(t *testing.T) {
t.Parallel()
info := &fsAdmin.SubVolumeInfo{
BytesQuota: fsAdmin.Infinite,
}
sv, err := subvolumeFromInfo(info, "test-vol")
require.NoError(t, err)
require.Empty(t, sv.PoolNamespace)
}
13 changes: 13 additions & 0 deletions internal/cephfs/store/volumeoptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ type VolumeOptions struct {
NamePrefix string
ClusterID string
MetadataPool string
// RADOS namespace assigned by Ceph for namespace-isolated subvolumes.
PoolNamespace string
// ReservedID represents the ID reserved for a subvolume
ReservedID string
Monitors string `json:"monitors"`
Expand Down Expand Up @@ -170,6 +172,14 @@ func (v *VolumeOptions) DetectMounter(options map[string]string) error {
return extractMounter(&v.Mounter, options)
}

// parseNamespaceIsolated returns true only when options contains
// "namespaceIsolated" with value "true" (case-insensitive).
func parseNamespaceIsolated(options map[string]string) bool {
v, ok := options["namespaceIsolated"]

return ok && strings.EqualFold(v, "true")
}

func extractMounter(dest *string, options map[string]string) error {
if err := extractOptionalOption(dest, "mounter", options); err != nil {
return err
Expand Down Expand Up @@ -305,6 +315,8 @@ func NewVolumeOptions(
return nil, err
}

opts.NamespaceIsolated = parseNamespaceIsolated(volOptions)

if err = extractOptionalOption(&backingSnapshotBool, "backingSnapshot", volOptions); err != nil {
return nil, err
}
Expand Down Expand Up @@ -537,6 +549,7 @@ func (vo *VolumeOptions) populateVolumeOptionsFromSubvolume(
vo.RootPath = info.Path
vo.Features = info.Features
vo.Size = info.BytesQuota
vo.PoolNamespace = info.PoolNamespace
}

if errors.Is(err, cerrors.ErrInvalidCommand) {
Expand Down
25 changes: 25 additions & 0 deletions internal/cephfs/store/volumeoptions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"testing"

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

func TestIsVolumeCreateRO(t *testing.T) {
Expand Down Expand Up @@ -218,3 +219,27 @@ func TestIsShallowVolumeSupported(t *testing.T) {
})
}
}

func TestNewVolumeOptions_NamespaceIsolated_True(t *testing.T) {
t.Parallel()
vo := map[string]string{"namespaceIsolated": "true"}
require.True(t, parseNamespaceIsolated(vo))
}

func TestNewVolumeOptions_NamespaceIsolated_False(t *testing.T) {
t.Parallel()
vo := map[string]string{"namespaceIsolated": "false"}
require.False(t, parseNamespaceIsolated(vo))
}

func TestNewVolumeOptions_NamespaceIsolated_Absent(t *testing.T) {
t.Parallel()
vo := map[string]string{}
require.False(t, parseNamespaceIsolated(vo))
}

func TestNewVolumeOptions_NamespaceIsolated_Invalid(t *testing.T) {
t.Parallel()
vo := map[string]string{"namespaceIsolated": "yes"}
require.False(t, parseNamespaceIsolated(vo))
}