Skip to content
Merged
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
andyzhangx marked this conversation as resolved.
ifdef ENABLE_BLOBFUSE_PROXY
Comment thread
andyzhangx marked this conversation as resolved.
E2E_HELM_OPTIONS += --set node.enableBlobfuseProxy=true --set image.blob.pullPolicy=Always --set controller.logLevel=6 --set node.logLevel=6
endif
Expand Down
Binary file modified charts/latest/blob-csi-driver-v0.0.0.tgz
Binary file not shown.
3 changes: 3 additions & 0 deletions charts/latest/blob-csi-driver/templates/csi-blob-driver.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ spec:
- Persistent
- Ephemeral
requiresRepublish: {{ .Values.feature.requiresRepublish }}
{{- if .Values.feature.serviceAccountTokenInSecrets }}
serviceAccountTokenInSecrets: true
{{- end }}
tokenRequests:
- audience: api://AzureADTokenExchange
expirationSeconds: 3600
1 change: 1 addition & 0 deletions charts/latest/blob-csi-driver/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ feature:
fsGroupPolicy: ReadWriteOnceWithFSType
requiresRepublish: true
enableGetVolumeStats: false
serviceAccountTokenInSecrets: false

driver:
name: blob.csi.azure.com
Expand Down
1 change: 1 addition & 0 deletions deploy/csi-blob-driver.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ spec:
- Persistent
- Ephemeral
requiresRepublish: true
serviceAccountTokenInSecrets: true
Comment thread
andyzhangx marked this conversation as resolved.
Outdated
Comment thread
andyzhangx marked this conversation as resolved.
Outdated
tokenRequests:
- audience: api://AzureADTokenExchange
expirationSeconds: 3600
Comment thread
andyzhangx marked this conversation as resolved.
Outdated
35 changes: 33 additions & 2 deletions pkg/blob/nodeserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
andyzhangx marked this conversation as resolved.
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
}
Expand Down Expand Up @@ -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)
Comment thread
andyzhangx marked this conversation as resolved.

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

Expand Down Expand Up @@ -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 {
Comment thread
andyzhangx marked this conversation as resolved.
return tokens
}
// Fallback to volume context for backward compatibility
return getValueInMap(volumeContext, serviceAccountTokenField)
Comment thread
andyzhangx marked this conversation as resolved.
Comment thread
andyzhangx marked this conversation as resolved.
}
120 changes: 120 additions & 0 deletions pkg/blob/nodeserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Comment thread
andyzhangx marked this conversation as resolved.
},
},
}
for _, tc := range testCases {
t.Run(tc.name, tc.testFunc)
Expand Down Expand Up @@ -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
Comment thread
andyzhangx marked this conversation as resolved.
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)
})
}
}
Loading