Merge pull request #134947 from aojea/dra_status_check

Fine-grained Authorization for ResourceClaim Status Updates
This commit is contained in:
Kubernetes Prow Robot
2026-03-26 22:34:18 +05:30
committed by GitHub
23 changed files with 1816 additions and 34 deletions

View File

@@ -65,6 +65,7 @@
| DRAPartitionableDevices | :ballot_box_with_check: 1.36+ | | 1.331.35 | 1.36 | | | DynamicResourceAllocation | [code](https://cs.k8s.io/?q=%5CbDRAPartitionableDevices%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/kubernetes) [KEPs](https://cs.k8s.io/?q=%5CbDRAPartitionableDevices%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/enhancements) |
| DRAPrioritizedList | :ballot_box_with_check: 1.34+ | | 1.33 | 1.341.35 | 1.36 | | DynamicResourceAllocation | [code](https://cs.k8s.io/?q=%5CbDRAPrioritizedList%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/kubernetes) [KEPs](https://cs.k8s.io/?q=%5CbDRAPrioritizedList%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/enhancements) |
| DRAResourceClaimDeviceStatus | :ballot_box_with_check: 1.33+ | | 1.32 | 1.33 | | | | [code](https://cs.k8s.io/?q=%5CbDRAResourceClaimDeviceStatus%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/kubernetes) [KEPs](https://cs.k8s.io/?q=%5CbDRAResourceClaimDeviceStatus%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/enhancements) |
| DRAResourceClaimGranularStatusAuthorization | :ballot_box_with_check:&nbsp;1.36+ | | | 1.36 | | | DRAResourceClaimDeviceStatus<br>DynamicResourceAllocation | [code](https://cs.k8s.io/?q=%5CbDRAResourceClaimGranularStatusAuthorization%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/kubernetes) [KEPs](https://cs.k8s.io/?q=%5CbDRAResourceClaimGranularStatusAuthorization%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/enhancements) |
| DRAResourcePoolStatus | | | 1.36 | | | | DynamicResourceAllocation | [code](https://cs.k8s.io/?q=%5CbDRAResourcePoolStatus%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/kubernetes) [KEPs](https://cs.k8s.io/?q=%5CbDRAResourcePoolStatus%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/enhancements) |
| DRASchedulerFilterTimeout | :ballot_box_with_check:&nbsp;1.34+ | | | 1.34 | | | DynamicResourceAllocation | [code](https://cs.k8s.io/?q=%5CbDRASchedulerFilterTimeout%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/kubernetes) [KEPs](https://cs.k8s.io/?q=%5CbDRASchedulerFilterTimeout%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/enhancements) |
| DRAWorkloadResourceClaims | | | 1.36 | | | | DynamicResourceAllocation<br>GenericWorkload | [code](https://cs.k8s.io/?q=%5CbDRAWorkloadResourceClaims%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/kubernetes) [KEPs](https://cs.k8s.io/?q=%5CbDRAWorkloadResourceClaims%5Cb&i=nope&files=&excludeFiles=CHANGELOG&repos=kubernetes/enhancements) |

View File

@@ -639,6 +639,12 @@
lockToDefault: false
preRelease: Beta
version: "1.33"
- name: DRAResourceClaimGranularStatusAuthorization
versionedSpecs:
- default: true
lockToDefault: false
preRelease: Beta
version: "1.36"
- name: DRAResourcePoolStatus
versionedSpecs:
- default: false

View File

@@ -33,6 +33,7 @@ import (
appsv1 "k8s.io/api/apps/v1"
v1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
resourceapi "k8s.io/api/resource/v1"
resourcealphaapi "k8s.io/api/resource/v1alpha3"
resourcev1beta1 "k8s.io/api/resource/v1beta1"
@@ -44,6 +45,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
applyv1 "k8s.io/client-go/applyconfigurations/core/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/klog/v2"
"k8s.io/kubernetes/pkg/features"
"k8s.io/kubernetes/pkg/kubelet/events"
@@ -3112,7 +3114,7 @@ var _ = framework.SIGDescribe("node")(framework.WithLabel("DRA"), func() {
}).Should(gomega.MatchError(gomega.ContainSubstring("exceeded quota: object-count, requested: count/resourceclaims.resource.k8s.io=1, used: count/resourceclaims.resource.k8s.io=1, limited: count/resourceclaims.resource.k8s.io=1")), "creating second claim not allowed")
})
f.It("must be possible for the driver to update the ResourceClaim.Status.Devices once allocated", f.WithFeatureGate(features.DRAResourceClaimDeviceStatus), func(ctx context.Context) {
f.It("must be impossible for a node ServiceAccount to update the non-node ResourceClaim.Status.Devices once allocated", f.WithFeatureGate(features.DRAResourceClaimDeviceStatus), func(ctx context.Context) {
tCtx := f.TContext(ctx)
claim := b.ExternalClaim()
pod := b.PodExternal(claim.Name)
@@ -3154,17 +3156,163 @@ var _ = framework.SIGDescribe("node")(framework.WithLabel("DRA"), func() {
if !ok {
framework.Failf("pod got scheduled to node %s without a plugin", scheduledPod.Spec.NodeName)
}
updatedResourceClaim, err := plugin.UpdateStatus(ctx, allocatedResourceClaim)
_, err = plugin.UpdateStatus(ctx, allocatedResourceClaim)
gomega.Expect(apierrors.IsInvalid(err)).To(gomega.BeTrueBecause("expected invalid error: %v", err))
gomega.Expect(err.Error()).To(gomega.ContainSubstring("cannot arbitrary-node:update"))
})
f.It("must be possible for a control-plane ServiceAccount to update the ResourceClaim.Status.Devices once allocated", f.WithFeatureGate(features.DRAResourceClaimDeviceStatus), func(ctx context.Context) {
tCtx := f.TContext(ctx)
claim := b.ExternalClaim()
pod := b.PodExternal(claim.Name)
b.Create(tCtx, claim, pod)
// Waits for the ResourceClaim to be allocated and the pod to be scheduled.
b.TestPod(tCtx, pod)
ginkgo.By("Creating a ServiceAccount and Role to update ResourceClaim status")
saName := "claim-status-updater-sa"
roleName := "claim-status-updater-role"
bindingName := "claim-status-updater-binding"
sa := &v1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{Name: saName, Namespace: f.Namespace.Name},
}
_, err := f.ClientSet.CoreV1().ServiceAccounts(f.Namespace.Name).Create(ctx, sa, metav1.CreateOptions{})
framework.ExpectNoError(err)
// Create ClusterRole with 'update' permission on 'resourceclaims/status'
role := &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{Name: roleName, Namespace: f.Namespace.Name},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{resourceapi.SchemeGroupVersion.Group},
Resources: []string{"resourceclaims/status"},
Verbs: []string{"update", "patch"},
},
{
// Also need 'get' to fetch the claim for the update operation
APIGroups: []string{resourceapi.SchemeGroupVersion.Group},
Resources: []string{"resourceclaims"},
Verbs: []string{"get"},
},
},
}
_, err = f.ClientSet.RbacV1().ClusterRoles().Create(ctx, role, metav1.CreateOptions{})
framework.ExpectNoError(err)
ginkgo.DeferCleanup(func(ctx context.Context) {
err := f.ClientSet.RbacV1().ClusterRoles().Delete(ctx, roleName, metav1.DeleteOptions{})
if !apierrors.IsNotFound(err) {
framework.ExpectNoError(err)
}
})
// Create ClusterRoleBinding
binding := &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{Name: bindingName, Namespace: f.Namespace.Name},
Subjects: []rbacv1.Subject{
{
Kind: "ServiceAccount",
Name: saName,
Namespace: f.Namespace.Name,
},
},
RoleRef: rbacv1.RoleRef{
Kind: "ClusterRole",
Name: roleName,
APIGroup: rbacv1.GroupName,
},
}
_, err = f.ClientSet.RbacV1().ClusterRoleBindings().Create(ctx, binding, metav1.CreateOptions{})
framework.ExpectNoError(err)
ginkgo.DeferCleanup(func(ctx context.Context) {
err := f.ClientSet.RbacV1().ClusterRoleBindings().Delete(ctx, bindingName, metav1.DeleteOptions{})
if !apierrors.IsNotFound(err) {
framework.ExpectNoError(err)
}
})
// Create a new clientset impersonating the ServiceAccount
saClientConfig := rest.CopyConfig(f.ClientConfig())
saClientConfig.Impersonate = rest.ImpersonationConfig{
UserName: fmt.Sprintf("system:serviceaccount:%s:%s", f.Namespace.Name, saName),
}
saClient, err := kubernetes.NewForConfig(saClientConfig)
framework.ExpectNoError(err)
// Get the allocated claim using the admin client
allocatedResourceClaim, err := f.ClientSet.ResourceV1().ResourceClaims(f.Namespace.Name).Get(ctx, claim.Name, metav1.GetOptions{})
framework.ExpectNoError(err)
gomega.Expect(allocatedResourceClaim).ToNot(gomega.BeNil())
gomega.Expect(allocatedResourceClaim.Status.Allocation).ToNot(gomega.BeNil())
gomega.Expect(allocatedResourceClaim.Status.Allocation.Devices.Results).To(gomega.HaveLen(1))
ginkgo.By("Setting the device status a first time (via ServiceAccount without proper RBAC) for driver " + b.DriverName())
allocatedResourceClaim.Status.Devices = append(allocatedResourceClaim.Status.Devices,
resourceapi.AllocatedDeviceStatus{
Driver: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Driver,
Pool: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Pool,
Device: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Device,
Conditions: []metav1.Condition{{Type: "a", Status: "True", Message: "c", Reason: "d", LastTransitionTime: metav1.NewTime(time.Now().Truncate(time.Second))}},
Data: &runtime.RawExtension{Raw: []byte(`{"foo":"bar"}`)},
NetworkData: &resourceapi.NetworkDeviceData{
InterfaceName: "inf1",
IPs: []string{"10.9.8.0/24", "2001:db8::/64"},
HardwareAddress: "bc:1c:b6:3e:b8:25",
},
})
// Update the ResourceClaim status using the impersonated client
// Wait for the initial ClusterRoleBinding to propagate AND for the DRA validation to explicitly reject it.
gomega.Eventually(ctx, func(ctx context.Context) error {
_, err := saClient.ResourceV1().ResourceClaims(f.Namespace.Name).UpdateStatus(ctx, allocatedResourceClaim, metav1.UpdateOptions{})
if err == nil {
return fmt.Errorf("expected request to be rejected by DRA validation, but it succeeded")
}
// If we get a pure Forbidden (403), the basic RBAC cache hasn't updated yet. Trigger a retry.
if apierrors.IsForbidden(err) {
return fmt.Errorf("standard RBAC cache not synced yet, got pure 403 Forbidden")
}
if apierrors.IsInvalid(err) && strings.Contains(err.Error(), "is forbidden") {
return nil
}
return fmt.Errorf("unexpected error: %w", err)
}).WithTimeout(15*time.Second).WithPolling(1*time.Second).Should(gomega.Succeed(), "failed waiting for DRA validation to reject the update")
ginkgo.By("Setting the device status a first time (via ServiceAccount with RBAC with specific driver name) for driver " + b.DriverName())
newRole := role.DeepCopy()
newRole.Rules = append(newRole.Rules, rbacv1.PolicyRule{
APIGroups: []string{resourceapi.SchemeGroupVersion.Group},
Resources: []string{"resourceclaims/driver"},
Verbs: []string{"arbitrary-node:update", "arbitrary-node:patch"},
ResourceNames: []string{b.DriverName()}, // allow for the specific drivers
})
_, err = f.ClientSet.RbacV1().ClusterRoles().Update(ctx, newRole, metav1.UpdateOptions{})
framework.ExpectNoError(err)
// Use Eventually to wait for RBAC propagation
var updatedResourceClaim *resourceapi.ResourceClaim
gomega.Eventually(ctx, func(ctx context.Context) error {
var updateErr error
updatedResourceClaim, updateErr = saClient.ResourceV1().ResourceClaims(f.Namespace.Name).UpdateStatus(ctx, allocatedResourceClaim, metav1.UpdateOptions{})
// If it's a forbidden error, the RBAC cache hasn't updated yet, so we return the error to trigger a retry
return updateErr
}).WithTimeout(15*time.Second).WithPolling(1*time.Second).Should(gomega.Succeed(), "failed to update ResourceClaim status after adding RBAC rule")
gomega.Expect(updatedResourceClaim).ToNot(gomega.BeNil())
gomega.Expect(updatedResourceClaim.Status.Devices).To(gomega.Equal(allocatedResourceClaim.Status.Devices))
ginkgo.By("Updating the device status")
ginkgo.By("Updating the device status (via ServiceAccount with RBAC allowing all drivers)")
newRole = role.DeepCopy()
newRole.Rules = append(newRole.Rules, rbacv1.PolicyRule{
APIGroups: []string{resourceapi.SchemeGroupVersion.Group},
Resources: []string{"resourceclaims/driver"},
Verbs: []string{"arbitrary-node:update", "arbitrary-node:patch"},
})
_, err = f.ClientSet.RbacV1().ClusterRoles().Update(ctx, newRole, metav1.UpdateOptions{})
framework.ExpectNoError(err)
updatedResourceClaim.Status.Devices[0] = resourceapi.AllocatedDeviceStatus{
Driver: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Driver,
Pool: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Pool,
Device: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Device,
ShareID: shareID,
Conditions: []metav1.Condition{{Type: "e", Status: "True", Message: "g", Reason: "h", LastTransitionTime: metav1.NewTime(time.Now().Truncate(time.Second))}},
Data: &runtime.RawExtension{Raw: []byte(`{"bar":"foo"}`)},
NetworkData: &resourceapi.NetworkDeviceData{
@@ -3174,11 +3322,16 @@ var _ = framework.SIGDescribe("node")(framework.WithLabel("DRA"), func() {
},
}
updatedResourceClaim2, err := plugin.UpdateStatus(ctx, updatedResourceClaim)
framework.ExpectNoError(err)
var updatedResourceClaim2 *resourceapi.ResourceClaim
gomega.Eventually(ctx, func(ctx context.Context) error {
var updateErr error
updatedResourceClaim2, updateErr = saClient.ResourceV1().ResourceClaims(f.Namespace.Name).UpdateStatus(ctx, updatedResourceClaim, metav1.UpdateOptions{})
return updateErr
}).WithTimeout(15*time.Second).WithPolling(1*time.Second).Should(gomega.Succeed(), "failed to update ResourceClaim status after wildcard RBAC rule")
gomega.Expect(updatedResourceClaim2).ToNot(gomega.BeNil())
gomega.Expect(updatedResourceClaim2.Status.Devices).To(gomega.Equal(updatedResourceClaim.Status.Devices))
ginkgo.By("Verifying the final status with admin client")
getResourceClaim, err := f.ClientSet.ResourceV1().ResourceClaims(f.Namespace.Name).Get(ctx, claim.Name, metav1.GetOptions{})
framework.ExpectNoError(err)
gomega.Expect(getResourceClaim).ToNot(gomega.BeNil())
@@ -3190,6 +3343,7 @@ var _ = framework.SIGDescribe("node")(framework.WithLabel("DRA"), func() {
nodes := drautils.NewNodes(f, 1, 4)
driver := drautils.NewDriver(f, nodes, drautils.DriverResources(1))
driver.WithKubelet = false
b := drautils.NewBuilder(f, driver)
f.It("must apply per-node permission checks", func(ctx context.Context) {
tCtx := f.TContext(ctx)
@@ -3334,6 +3488,79 @@ var _ = framework.SIGDescribe("node")(framework.WithLabel("DRA"), func() {
mustFailToDelete(fictionalNodeClient, "fictional plugin", createdClusterSlice, matchVAPDeniedError(fictionalNodeName, createdClusterSlice))
mustDelete(f.ClientSet, "admin", createdClusterSlice)
})
f.It("must be possible for a node ServiceAccount to update the node ResourceClaim.Status.Devices once allocated", f.WithFeatureGate(features.DRAResourceClaimDeviceStatus), func(ctx context.Context) {
tCtx := f.TContext(ctx)
claim := b.ExternalClaim()
pod := b.PodExternal(claim.Name)
b.Create(tCtx, claim, pod)
// Waits for the ResourceClaim to be allocated and the pod to be scheduled.
b.TestPod(tCtx, pod)
allocatedResourceClaim, err := f.ClientSet.ResourceV1().ResourceClaims(f.Namespace.Name).Get(ctx, claim.Name, metav1.GetOptions{})
framework.ExpectNoError(err)
gomega.Expect(allocatedResourceClaim).ToNot(gomega.BeNil())
gomega.Expect(allocatedResourceClaim.Status.Allocation).ToNot(gomega.BeNil())
gomega.Expect(allocatedResourceClaim.Status.Allocation.Devices.Results).To(gomega.HaveLen(1))
scheduledPod, err := f.ClientSet.CoreV1().Pods(f.Namespace.Name).Get(ctx, pod.Name, metav1.GetOptions{})
framework.ExpectNoError(err)
gomega.Expect(scheduledPod).ToNot(gomega.BeNil())
shareID := (*string)(allocatedResourceClaim.Status.Allocation.Devices.Results[0].ShareID)
ginkgo.By("Setting the device status a first time")
allocatedResourceClaim.Status.Devices = append(allocatedResourceClaim.Status.Devices,
resourceapi.AllocatedDeviceStatus{
Driver: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Driver,
Pool: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Pool,
Device: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Device,
ShareID: shareID,
Conditions: []metav1.Condition{{Type: "a", Status: "True", Message: "c", Reason: "d", LastTransitionTime: metav1.NewTime(time.Now().Truncate(time.Second))}},
Data: &runtime.RawExtension{Raw: []byte(`{"foo":"bar"}`)},
NetworkData: &resourceapi.NetworkDeviceData{
InterfaceName: "inf1",
IPs: []string{"10.9.8.0/24", "2001:db8::/64"},
HardwareAddress: "bc:1c:b6:3e:b8:25",
},
})
// Updates the ResourceClaim from the driver on the same node as the pod.
plugin, ok := driver.Nodes[scheduledPod.Spec.NodeName]
if !ok {
framework.Failf("pod got scheduled to node %s without a plugin", scheduledPod.Spec.NodeName)
}
updatedResourceClaim, err := plugin.UpdateStatus(ctx, allocatedResourceClaim)
framework.ExpectNoError(err)
gomega.Expect(updatedResourceClaim).ToNot(gomega.BeNil())
gomega.Expect(updatedResourceClaim.Status.Devices).To(gomega.Equal(allocatedResourceClaim.Status.Devices))
ginkgo.By("Updating the device status")
updatedResourceClaim.Status.Devices[0] = resourceapi.AllocatedDeviceStatus{
Driver: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Driver,
Pool: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Pool,
Device: allocatedResourceClaim.Status.Allocation.Devices.Results[0].Device,
ShareID: shareID,
Conditions: []metav1.Condition{{Type: "e", Status: "True", Message: "g", Reason: "h", LastTransitionTime: metav1.NewTime(time.Now().Truncate(time.Second))}},
Data: &runtime.RawExtension{Raw: []byte(`{"bar":"foo"}`)},
NetworkData: &resourceapi.NetworkDeviceData{
InterfaceName: "inf2",
IPs: []string{"10.9.8.1/24", "2001:db8::1/64"},
HardwareAddress: "bc:1c:b6:3e:b8:26",
},
}
updatedResourceClaim2, err := plugin.UpdateStatus(ctx, updatedResourceClaim)
framework.ExpectNoError(err)
gomega.Expect(updatedResourceClaim2).ToNot(gomega.BeNil())
gomega.Expect(updatedResourceClaim2.Status.Devices).To(gomega.Equal(updatedResourceClaim.Status.Devices))
getResourceClaim, err := f.ClientSet.ResourceV1().ResourceClaims(f.Namespace.Name).Get(ctx, claim.Name, metav1.GetOptions{})
framework.ExpectNoError(err)
gomega.Expect(getResourceClaim).ToNot(gomega.BeNil())
gomega.Expect(getResourceClaim.Status.Devices).To(gomega.Equal(updatedResourceClaim.Status.Devices))
})
})
multipleDrivers := func(nodeV1beta1, nodeV1 bool) {

View File

@@ -17,7 +17,11 @@ rules:
verbs: ["get"]
- apiGroups: ["resource.k8s.io"]
resources: ["resourceclaims/status"]
verbs: ["update"]
verbs: ["patch", "update"]
- apiGroups: ["resource.k8s.io"]
resources: ["resourceclaims/driver"]
verbs: ["associated-node:patch", "associated-node:update"]
resourceNames: ["dra-kubelet-plugin-driver-name"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get"]

View File

@@ -82,6 +82,11 @@ func (b *Builder) ClassName() string {
return b.namespace + b.Driver.NameSuffix + "-class"
}
// DriverName returns the default device driver name.
func (b *Builder) DriverName() string {
return b.Driver.Name
}
// Class returns the device Class that the builder's other objects
// reference.
func (b *Builder) Class() *DeviceClassWrapper {

View File

@@ -474,7 +474,9 @@ func (d *Driver) SetUp(tCtx ktesting.TContext, kubeletRootDir string, nodes *Nod
// Create service account and corresponding RBAC rules.
d.serviceAccountName = "dra-kubelet-plugin-" + d.Name + d.InstanceSuffix + "-service-account"
content := example.PluginPermissions
content = strings.ReplaceAll(content, "dra-kubelet-plugin-namespace", tCtx.Namespace())
content = strings.ReplaceAll(content, "dra-kubelet-plugin-driver-name", d.Name)
content = strings.ReplaceAll(content, "dra-kubelet-plugin", "dra-kubelet-plugin-"+d.Name+d.InstanceSuffix)
d.createFromYAML(tCtx, []byte(content), tCtx.Namespace())

View File

@@ -0,0 +1,295 @@
/*
Copyright The Kubernetes 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 auth
import (
"context"
"fmt"
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
resourceapi "k8s.io/api/resource/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
utilfeature "k8s.io/apiserver/pkg/util/feature"
clientset "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
featuregatetesting "k8s.io/component-base/featuregate/testing"
kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing"
"k8s.io/kubernetes/pkg/features"
"k8s.io/kubernetes/test/integration/framework"
)
func TestResourceClaimGranularStatusAuthorization(t *testing.T) {
// Enable Feature Gates Globally for the test run
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DynamicResourceAllocation, true)
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAResourceClaimDeviceStatus, true)
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAResourceClaimGranularStatusAuthorization, true)
const (
ns = "dra-authz-test"
saName = "dra-plugin-sa"
claimName = "test-claim"
nodeName = "worker-1"
)
testcases := []struct {
name string
preAllocate bool
impersonateExtra map[string][]string
setupRBAC func(t *testing.T, adminClient clientset.Interface)
updateClaim func(c *resourceapi.ResourceClaim)
verifyErr func(t *testing.T, err error)
}{
{
name: "fails to update status.devices without driver permission",
preAllocate: true,
setupRBAC: func(t *testing.T, adminClient clientset.Interface) {}, // No extra RBAC beyond front-door
updateClaim: func(c *resourceapi.ResourceClaim) {
c.Status.Devices = []resourceapi.AllocatedDeviceStatus{
{Driver: "test-driver", Pool: "pool1", Device: "dev1"},
}
},
verifyErr: func(t *testing.T, err error) {
if err == nil || !apierrors.IsInvalid(err) || !strings.Contains(err.Error(), "Forbidden: changing status.devices requires") {
t.Errorf("Expected Invalid/Forbidden error, got: %v", err)
}
},
},
{
name: "succeeds with associated-node permission for same-node SA",
preAllocate: true,
impersonateExtra: map[string][]string{
"authentication.kubernetes.io/node-name": {nodeName},
},
setupRBAC: func(t *testing.T, adminClient clientset.Interface) {
createRoleAndBinding(t, adminClient, ns, saName, "node-local-driver",
[]string{"resourceclaims/driver"}, []string{"associated-node:update"})
},
updateClaim: func(c *resourceapi.ResourceClaim) {
c.Status.Devices = []resourceapi.AllocatedDeviceStatus{
{Driver: "test-driver", Pool: "pool1", Device: "dev1"},
}
},
verifyErr: func(t *testing.T, err error) {
if err != nil {
t.Errorf("Expected success via associated-node, got: %v", err)
}
},
},
{
name: "fails deallocation without binding permission",
preAllocate: true,
setupRBAC: func(t *testing.T, adminClient clientset.Interface) {},
updateClaim: func(c *resourceapi.ResourceClaim) {
c.Status.Allocation = nil
},
verifyErr: func(t *testing.T, err error) {
if err == nil || !apierrors.IsInvalid(err) || !strings.Contains(err.Error(), "Forbidden: changing status.allocation") {
t.Errorf("Expected Invalid/Forbidden on unbind, got: %v", err)
}
},
},
{
name: "succeeds to update status.reservedFor with binding permission",
preAllocate: true,
setupRBAC: func(t *testing.T, adminClient clientset.Interface) {
createClusterRoleAndBinding(t, adminClient, ns, saName, "cluster-binding-updater-reserved",
[]string{"resourceclaims/binding"}, []string{"update"})
},
updateClaim: func(c *resourceapi.ResourceClaim) {
c.Status.ReservedFor = []resourceapi.ResourceClaimConsumerReference{
{Resource: "pods", Name: "pod-1", UID: "uid-1"},
}
},
verifyErr: func(t *testing.T, err error) {
if err != nil {
t.Errorf("Expected success, got: %v", err)
}
},
},
{
name: "fails when updating both allocation and devices but missing binding permission",
preAllocate: true,
setupRBAC: func(t *testing.T, adminClient clientset.Interface) {
// Has driver permission, but LACKS binding permission
createRoleAndBinding(t, adminClient, ns, saName, "driver-only",
[]string{"resourceclaims/driver"}, []string{"arbitrary-node:update"})
},
updateClaim: func(c *resourceapi.ResourceClaim) {
// Re-allocate to a different node (requires binding)
if c.Status.Allocation != nil && c.Status.Allocation.NodeSelector != nil {
c.Status.Allocation.NodeSelector.NodeSelectorTerms[0].MatchFields[0].Values = []string{"worker-2"}
}
// Change devices (requires driver)
c.Status.Devices = []resourceapi.AllocatedDeviceStatus{
{Driver: "test-driver", Pool: "pool1", Device: "dev2"},
}
},
verifyErr: func(t *testing.T, err error) {
if err == nil || !apierrors.IsInvalid(err) || !strings.Contains(err.Error(), "Forbidden: changing status.allocation") {
t.Errorf("Expected Forbidden on simultaneous update missing binding, got: %v", err)
}
},
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
server := kubeapiservertesting.StartTestServerOrDie(t, nil, []string{
"--runtime-config=api/all=true",
"--authorization-mode=RBAC",
}, framework.SharedEtcd())
t.Cleanup(server.TearDownFn)
adminClient := clientset.NewForConfigOrDie(server.ClientConfig)
// Setup Namespace and Service Account
_, err := adminClient.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}, metav1.CreateOptions{})
if err != nil {
t.Fatal(err)
}
_, err = adminClient.CoreV1().ServiceAccounts(ns).Create(context.TODO(), &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: saName}}, metav1.CreateOptions{})
if err != nil {
t.Fatal(err)
}
// Create the base ResourceClaim
claim := &resourceapi.ResourceClaim{
ObjectMeta: metav1.ObjectMeta{Name: claimName},
Spec: resourceapi.ResourceClaimSpec{
Devices: resourceapi.DeviceClaim{
Requests: []resourceapi.DeviceRequest{{
Name: "req-1",
FirstAvailable: []resourceapi.DeviceSubRequest{{
Name: "subreq-1",
DeviceClassName: "test-class",
}},
}},
},
},
}
_, err = adminClient.ResourceV1().ResourceClaims(ns).Create(context.TODO(), claim, metav1.CreateOptions{})
if err != nil {
t.Fatal(err)
}
// Admin Pre-allocation (if required by test)
if tc.preAllocate {
c, err := adminClient.ResourceV1().ResourceClaims(ns).Get(context.TODO(), claimName, metav1.GetOptions{})
if err != nil {
t.Fatalf("Failed to fetch claim for pre-allocation: %v", err)
}
c.Status.Allocation = &resourceapi.AllocationResult{
NodeSelector: &corev1.NodeSelector{
NodeSelectorTerms: []corev1.NodeSelectorTerm{{
MatchFields: []corev1.NodeSelectorRequirement{{Key: "metadata.name", Operator: corev1.NodeSelectorOpIn, Values: []string{nodeName}}},
}},
},
Devices: resourceapi.DeviceAllocationResult{
Results: []resourceapi.DeviceRequestAllocationResult{
{Request: "req-1", Driver: "test-driver", Pool: "pool1", Device: "dev1"},
},
},
}
_, err = adminClient.ResourceV1().ResourceClaims(ns).UpdateStatus(context.TODO(), c, metav1.UpdateOptions{})
if err != nil {
t.Fatalf("Admin failed to set baseline allocation: %v", err)
}
}
// Setup RBAC
createRoleAndBinding(t, adminClient, ns, saName, "base-status-updater", []string{"resourceclaims/status"}, []string{"update", "patch"})
createRoleAndBinding(t, adminClient, ns, saName, "base-claim-reader", []string{"resourceclaims"}, []string{"get"})
tc.setupRBAC(t, adminClient)
// Build the Impersonated Client
saConfig := rest.CopyConfig(server.ClientConfig)
saConfig.Impersonate = rest.ImpersonationConfig{
UserName: fmt.Sprintf("system:serviceaccount:%s:%s", ns, saName),
Extra: tc.impersonateExtra,
}
saClient := clientset.NewForConfigOrDie(saConfig)
// Execute Test Update
cToUpdate, err := adminClient.ResourceV1().ResourceClaims(ns).Get(context.TODO(), claimName, metav1.GetOptions{})
if err != nil {
t.Fatalf("Failed to fetch claim before test execution: %v", err)
}
tc.updateClaim(cToUpdate)
_, testErr := saClient.ResourceV1().ResourceClaims(ns).UpdateStatus(context.TODO(), cToUpdate, metav1.UpdateOptions{})
// 7. Verify Results
tc.verifyErr(t, testErr)
})
}
}
// createRoleAndBinding is a quick helper to assign namespaced RBAC rules
func createRoleAndBinding(t *testing.T, client clientset.Interface, ns, saName, roleName string, resources, verbs []string) {
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{Name: roleName},
Rules: []rbacv1.PolicyRule{{
APIGroups: []string{"resource.k8s.io"},
Resources: resources,
Verbs: verbs,
}},
}
_, err := client.RbacV1().Roles(ns).Create(context.TODO(), role, metav1.CreateOptions{})
if err != nil && !apierrors.IsAlreadyExists(err) {
t.Fatal(err)
}
binding := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{Name: roleName + "-binding"},
Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: saName, Namespace: ns}},
RoleRef: rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: roleName},
}
_, err = client.RbacV1().RoleBindings(ns).Create(context.TODO(), binding, metav1.CreateOptions{})
if err != nil && !apierrors.IsAlreadyExists(err) {
t.Fatal(err)
}
}
// createClusterRoleAndBinding is a helper for cluster-scoped synthetic checks (like binding)
func createClusterRoleAndBinding(t *testing.T, client clientset.Interface, ns, saName, roleName string, resources, verbs []string) {
role := &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{Name: roleName},
Rules: []rbacv1.PolicyRule{{
APIGroups: []string{"resource.k8s.io"},
Resources: resources,
Verbs: verbs,
}},
}
_, err := client.RbacV1().ClusterRoles().Create(context.TODO(), role, metav1.CreateOptions{})
if err != nil && !apierrors.IsAlreadyExists(err) {
t.Fatal(err)
}
binding := &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{Name: roleName + "-binding"},
Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: saName, Namespace: ns}},
RoleRef: rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "ClusterRole", Name: roleName},
}
_, err = client.RbacV1().ClusterRoleBindings().Create(context.TODO(), binding, metav1.CreateOptions{})
if err != nil && !apierrors.IsAlreadyExists(err) {
t.Fatal(err)
}
}