diff --git a/Makefile b/Makefile index 85dd65d5b..807c5ee33 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ CSI_IMAGE_TAG ?= $(REGISTRY)/$(IMAGE_NAME):$(IMAGE_VERSION) CSI_IMAGE_TAG_LATEST = $(REGISTRY)/$(IMAGE_NAME):latest BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") LDFLAGS ?= "-X ${PKG}/pkg/blob.driverVersion=${IMAGE_VERSION} -X ${PKG}/pkg/blob.gitCommit=${GIT_COMMIT} -X ${PKG}/pkg/blob.buildDate=${BUILD_DATE} -s -w -extldflags '-static'" -E2E_HELM_OPTIONS ?= --set image.blob.pullPolicy=Always --set image.blob.repository=$(REGISTRY)/$(IMAGE_NAME) --set image.blob.tag=$(IMAGE_VERSION) --set driver.userAgentSuffix="e2e-test" --set controller.runOnControlPlane=true +E2E_HELM_OPTIONS ?= --set image.blob.pullPolicy=Always --set image.blob.repository=$(REGISTRY)/$(IMAGE_NAME) --set image.blob.tag=$(IMAGE_VERSION) --set driver.userAgentSuffix="e2e-test" --set controller.runOnControlPlane=true --set feature.serviceAccountTokenInSecrets=true ifdef ENABLE_BLOBFUSE_PROXY E2E_HELM_OPTIONS += --set node.enableBlobfuseProxy=true --set image.blob.pullPolicy=Always --set controller.logLevel=6 --set node.logLevel=6 endif diff --git a/charts/latest/blob-csi-driver-v0.0.0.tgz b/charts/latest/blob-csi-driver-v0.0.0.tgz index 20a8eaba9..678d99509 100644 Binary files a/charts/latest/blob-csi-driver-v0.0.0.tgz and b/charts/latest/blob-csi-driver-v0.0.0.tgz differ diff --git a/charts/latest/blob-csi-driver/templates/csi-blob-driver.yaml b/charts/latest/blob-csi-driver/templates/csi-blob-driver.yaml index edb89dc8f..f515b6ce2 100644 --- a/charts/latest/blob-csi-driver/templates/csi-blob-driver.yaml +++ b/charts/latest/blob-csi-driver/templates/csi-blob-driver.yaml @@ -13,6 +13,9 @@ spec: - Persistent - Ephemeral requiresRepublish: {{ .Values.feature.requiresRepublish }} + {{- if .Values.feature.serviceAccountTokenInSecrets }} + serviceAccountTokenInSecrets: true + {{- end }} tokenRequests: - audience: api://AzureADTokenExchange expirationSeconds: 3600 diff --git a/charts/latest/blob-csi-driver/values.yaml b/charts/latest/blob-csi-driver/values.yaml index 7ae0b6b03..df6272760 100644 --- a/charts/latest/blob-csi-driver/values.yaml +++ b/charts/latest/blob-csi-driver/values.yaml @@ -197,6 +197,7 @@ feature: fsGroupPolicy: ReadWriteOnceWithFSType requiresRepublish: true enableGetVolumeStats: false + serviceAccountTokenInSecrets: false driver: name: blob.csi.azure.com diff --git a/pkg/blob/nodeserver.go b/pkg/blob/nodeserver.go index 285ff7914..7e68cf222 100644 --- a/pkg/blob/nodeserver.go +++ b/pkg/blob/nodeserver.go @@ -77,17 +77,21 @@ func (d *Driver) NodePublishVolume(ctx context.Context, req *csi.NodePublishVolu return nil, status.Error(codes.InvalidArgument, "Target path not provided") } + secrets := req.GetSecrets() + mountPermissions := d.mountPermissions context := req.GetVolumeContext() + serviceAccountTokens := getServiceAccountTokens(secrets, context) if context != nil { // token request - if context[serviceAccountTokenField] != "" && useWorkloadIdentity(context) { + if serviceAccountTokens != "" && useWorkloadIdentity(context) { klog.V(2).Infof("NodePublishVolume: volume(%s) mount on %s with service account token, clientID: %s", volumeID, target, getValueInMap(context, clientIDField)) _, err := d.NodeStageVolume(ctx, &csi.NodeStageVolumeRequest{ StagingTargetPath: target, VolumeContext: context, VolumeCapability: volCap, VolumeId: volumeID, + Secrets: secrets, }) return &csi.NodePublishVolumeResponse{}, err } @@ -292,12 +296,26 @@ func (d *Driver) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolumeRe volumeMountGroup := req.GetVolumeCapability().GetMount().GetVolumeMountGroup() attrib := req.GetVolumeContext() secrets := req.GetSecrets() + serviceAccountTokens := getServiceAccountTokens(secrets, attrib) - if useWorkloadIdentity(attrib) && attrib[serviceAccountTokenField] == "" { + if useWorkloadIdentity(attrib) && serviceAccountTokens == "" { klog.V(2).Infof("Skip NodeStageVolume for volume(%s) since clientID %s is provided but service account token is empty", volumeID, getValueInMap(attrib, clientIDField)) return &csi.NodeStageVolumeResponse{}, nil } + // Kubernetes 1.35+ may deliver the service account token via Secrets + // (CSIDriver.spec.serviceAccountTokenInSecrets) instead of VolumeContext. + // GetAuthEnv reads serviceAccountTokenField from attrib only, so propagate + // the resolved token into a copy of attrib without mutating the request map. + if serviceAccountTokens != "" && getValueInMap(attrib, serviceAccountTokenField) == "" { + attribWithServiceAccountToken := make(map[string]string, len(attrib)+1) + for k, v := range attrib { + attribWithServiceAccountToken[k] = v + } + attribWithServiceAccountToken[serviceAccountTokenField] = serviceAccountTokens + attrib = attribWithServiceAccountToken + } + var serverAddress, storageEndpointSuffix, protocol, ephemeralVolMountOptions, blobStorageAccountType string var ephemeralVol, isHnsEnabled bool @@ -791,3 +809,16 @@ func useWorkloadIdentity(attrib map[string]string) bool { } return false } + +// getServiceAccountTokens retrieves service account tokens from the CSI request. +// It first checks the secrets map (new behavior when driver opts in to +// serviceAccountTokenInSecrets in Kubernetes 1.35+), then falls back to checking +// volumeContext for backward compatibility. +func getServiceAccountTokens(secrets, volumeContext map[string]string) string { + // Check secrets field first (new behavior when driver opts in) + if tokens, ok := secrets[serviceAccountTokenField]; ok { + return tokens + } + // Fallback to volume context for backward compatibility + return getValueInMap(volumeContext, serviceAccountTokenField) +} diff --git a/pkg/blob/nodeserver_test.go b/pkg/blob/nodeserver_test.go index 0538e64e4..289b9c3d3 100644 --- a/pkg/blob/nodeserver_test.go +++ b/pkg/blob/nodeserver_test.go @@ -265,6 +265,33 @@ func TestNodePublishVolume(t *testing.T) { }, expectedErr: nil, }, + { + desc: "Valid request with service account token from secrets and clientID", + setup: func(d *Driver) { + d.cloud.ResourceGroup = "rg" + d.enableBlobMockMount = true + defaultAzureOAuthTokenDir = "./blob.csi.azure.com/" + _ = makeDir(defaultAzureOAuthTokenDir) + }, + cleanup: func(_ *Driver) { + _ = os.RemoveAll(defaultAzureOAuthTokenDir) + }, + req: &csi.NodePublishVolumeRequest{ + VolumeCapability: &csi.VolumeCapability{AccessMode: &volumeCap}, + VolumeId: "vol_1", + TargetPath: targetTest, + StagingTargetPath: sourceTest, + VolumeContext: map[string]string{ + mountWithWITokenField: "true", + clientIDField: "client-id-value", + storageAccountNameField: "test-account", + }, + Secrets: map[string]string{ + serviceAccountTokenField: `{"api://AzureADTokenExchange":{"token":"test-token","expirationTimestamp":"2023-01-01T00:00:00Z"}}`, + }, + }, + expectedErr: nil, + }, { desc: "Valid request with ephemeral volume", setup: func(d *Driver) { @@ -827,6 +854,42 @@ func TestNodeStageVolume(t *testing.T) { } }, }, + { + name: "service account token from secrets is propagated to attrib", + testFunc: func(t *testing.T) { + defaultAzureOAuthTokenDir = "./blob.csi.azure.com/" + _ = makeDir(defaultAzureOAuthTokenDir) + defer func() { _ = os.RemoveAll(defaultAzureOAuthTokenDir) }() + + req := &csi.NodeStageVolumeRequest{ + VolumeId: "rg#acc#cont#ns", + StagingTargetPath: targetTest, + VolumeCapability: &csi.VolumeCapability{AccessMode: &volumeCap}, + VolumeContext: map[string]string{ + mountWithWITokenField: "true", + clientIDField: "client-id-value", + storageAccountNameField: "test-account", + }, + Secrets: map[string]string{ + serviceAccountTokenField: `{"api://AzureADTokenExchange":{"token":"test-token","expirationTimestamp":"2023-01-01T00:00:00Z"}}`, + }, + } + d := NewFakeDriver() + d.cloud.ResourceGroup = "rg" + d.enableBlobMockMount = true + fakeMounter := &fakeMounter{} + fakeExec := &testingexec.FakeExec{} + d.mounter = &mount.SafeFormatAndMount{ + Interface: fakeMounter, + Exec: fakeExec, + } + + _, err := d.NodeStageVolume(context.TODO(), req) + if !reflect.DeepEqual(err, nil) { + t.Errorf("actualErr: (%v), expectedErr: (%v)", err, nil) + } + }, + }, } for _, tc := range testCases { t.Run(tc.name, tc.testFunc) @@ -1370,3 +1433,60 @@ func TestUseWorkloadIdentity(t *testing.T) { }) } } + +func TestGetServiceAccountTokens(t *testing.T) { + tests := []struct { + name string + secrets map[string]string + volumeContext map[string]string + expected string + }{ + { + name: "token from secrets field (new behavior)", + secrets: map[string]string{ + serviceAccountTokenField: "token-from-secrets", + }, + volumeContext: map[string]string{ + serviceAccountTokenField: "token-from-context", + }, + expected: "token-from-secrets", + }, + { + name: "token from volume context (backward compatible)", + secrets: map[string]string{}, + volumeContext: map[string]string{ + serviceAccountTokenField: "token-from-context", + }, + expected: "token-from-context", + }, + { + name: "no token available", + secrets: map[string]string{}, + volumeContext: map[string]string{}, + expected: "", + }, + { + name: "nil secrets falls back to volume context", + secrets: nil, + volumeContext: map[string]string{ + serviceAccountTokenField: "token-from-context", + }, + expected: "token-from-context", + }, + { + name: "nil volume context with secrets", + secrets: map[string]string{ + serviceAccountTokenField: "token-from-secrets", + }, + volumeContext: nil, + expected: "token-from-secrets", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := getServiceAccountTokens(test.secrets, test.volumeContext) + assert.Equal(t, test.expected, result) + }) + } +}