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
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
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
tokenRequests:
- audience: api://AzureADTokenExchange
expirationSeconds: 3600
Comment thread
andyzhangx marked this conversation as resolved.
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.
}
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)
}
},
},
}
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