mirror of
https://github.com/k3s-io/kubernetes.git
synced 2026-07-18 04:54:54 +00:00
Merge pull request #134947 from aojea/dra_status_check
Fine-grained Authorization for ResourceClaim Status Updates
This commit is contained in:
@@ -432,7 +432,10 @@ func (c CompletedConfig) StorageProviders(client *kubernetes.Clientset) ([]contr
|
||||
appsrest.StorageProvider{},
|
||||
admissionregistrationrest.RESTStorageProvider{Authorizer: c.ControlPlane.Generic.Authorization.Authorizer, DiscoveryClient: client.Discovery()},
|
||||
eventsrest.RESTStorageProvider{TTL: c.ControlPlane.EventTTL},
|
||||
resourcerest.RESTStorageProvider{NamespaceClient: client.CoreV1().Namespaces()},
|
||||
resourcerest.RESTStorageProvider{
|
||||
NamespaceClient: client.CoreV1().Namespaces(),
|
||||
Authorizer: c.ControlPlane.Generic.Authorization.Authorizer,
|
||||
},
|
||||
}
|
||||
|
||||
if AdditionalStorageProvidersForTests != nil {
|
||||
|
||||
@@ -270,6 +270,16 @@ const (
|
||||
// status from DRA drivers.
|
||||
DRAResourceClaimDeviceStatus featuregate.Feature = "DRAResourceClaimDeviceStatus"
|
||||
|
||||
// owner: @aojea
|
||||
// kep: http://kep.k8s.io/4817
|
||||
//
|
||||
// Enables fine-grained authorization checks for ResourceClaim status updates.
|
||||
// Requires separate permission on resourceclaims/binding to update
|
||||
// status.allocation and status.reservedFor, and per-driver permission on
|
||||
// resourceclaims/driver using associated-node / arbitrary-node verb prefixes
|
||||
// to update status.devices.
|
||||
DRAResourceClaimGranularStatusAuthorization featuregate.Feature = "DRAResourceClaimGranularStatusAuthorization"
|
||||
|
||||
// owner: @nmn3m
|
||||
// kep: http://kep.k8s.io/5677
|
||||
//
|
||||
@@ -1390,6 +1400,10 @@ var defaultVersionedKubernetesFeatureGates = map[featuregate.Feature]featuregate
|
||||
{Version: version.MustParse("1.33"), Default: true, PreRelease: featuregate.Beta},
|
||||
},
|
||||
|
||||
DRAResourceClaimGranularStatusAuthorization: {
|
||||
{Version: version.MustParse("1.36"), Default: true, PreRelease: featuregate.Beta},
|
||||
},
|
||||
|
||||
DRAResourcePoolStatus: {
|
||||
{Version: version.MustParse("1.36"), Default: false, PreRelease: featuregate.Alpha},
|
||||
},
|
||||
@@ -2438,6 +2452,8 @@ var defaultKubernetesFeatureGateDependencies = map[featuregate.Feature][]feature
|
||||
|
||||
DRAResourceClaimDeviceStatus: {}, // Soft dependency on DynamicResourceAllocation due to on/off-by-default conflict.
|
||||
|
||||
DRAResourceClaimGranularStatusAuthorization: {DynamicResourceAllocation, DRAResourceClaimDeviceStatus},
|
||||
|
||||
DRAResourcePoolStatus: {DynamicResourceAllocation},
|
||||
|
||||
DRASchedulerFilterTimeout: {DynamicResourceAllocation},
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
apitesting "k8s.io/kubernetes/pkg/api/testing"
|
||||
@@ -57,7 +58,7 @@ func testDeclarativeValidate(t *testing.T, apiVersion string) {
|
||||
})
|
||||
fakeClient := fake.NewClientset()
|
||||
mockNSClient := fakeClient.CoreV1().Namespaces()
|
||||
Strategy := NewStrategy(mockNSClient)
|
||||
Strategy := NewStrategy(mockNSClient, nil)
|
||||
|
||||
opaqueDriverPath := field.NewPath("spec", "devices", "config").Index(0).Child("opaque", "driver")
|
||||
|
||||
@@ -717,7 +718,7 @@ func testDeclarativeValidateUpdate(t *testing.T, apiVersion string) {
|
||||
})
|
||||
fakeClient := fake.NewClientset()
|
||||
mockNSClient := fakeClient.CoreV1().Namespaces()
|
||||
Strategy := NewStrategy(mockNSClient)
|
||||
Strategy := NewStrategy(mockNSClient, nil)
|
||||
validClaim := mkValidResourceClaim()
|
||||
// TODO: As we accumulate more and more test cases, consider breaking this
|
||||
// up into smaller tests for maintainability.
|
||||
@@ -835,7 +836,7 @@ func TestValidateStatusUpdateForDeclarative(t *testing.T) {
|
||||
func testValidateStatusUpdateForDeclarative(t *testing.T, apiVersion string) {
|
||||
fakeClient := fake.NewClientset()
|
||||
mockNSClient := fakeClient.CoreV1().Namespaces()
|
||||
Strategy := NewStrategy(mockNSClient)
|
||||
Strategy := NewStrategy(mockNSClient, &fakeAuthorizer{true})
|
||||
strategy := NewStatusStrategy(Strategy)
|
||||
|
||||
ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{
|
||||
@@ -844,6 +845,8 @@ func testValidateStatusUpdateForDeclarative(t *testing.T, apiVersion string) {
|
||||
Resource: "resourceclaims",
|
||||
Subresource: "status",
|
||||
})
|
||||
ctx = genericapirequest.WithUser(ctx, &user.DefaultInfo{Name: "test-user"})
|
||||
|
||||
poolPath := field.NewPath("status", "allocation", "devices", "results").Index(0).Child("pool")
|
||||
configSourcePath := field.NewPath("status", "allocation", "devices", "config").Index(0).Child("source")
|
||||
driverPath := field.NewPath("status", "allocation", "devices", "results").Index(0).Child("driver")
|
||||
|
||||
@@ -22,10 +22,11 @@ import (
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/client-go/kubernetes/typed/core/v1"
|
||||
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
|
||||
"k8s.io/kubernetes/pkg/apis/resource"
|
||||
"k8s.io/kubernetes/pkg/printers"
|
||||
printersinternal "k8s.io/kubernetes/pkg/printers/internalversion"
|
||||
@@ -40,11 +41,15 @@ type REST struct {
|
||||
}
|
||||
|
||||
// NewREST returns a RESTStorage object that will work against ResourceClaims.
|
||||
func NewREST(optsGetter generic.RESTOptionsGetter, nsClient v1.NamespaceInterface) (*REST, *StatusREST, error) {
|
||||
func NewREST(optsGetter generic.RESTOptionsGetter, nsClient v1.NamespaceInterface, authorizer authorizer.Authorizer) (*REST, *StatusREST, error) {
|
||||
if nsClient == nil {
|
||||
return nil, nil, fmt.Errorf("namespace client is required")
|
||||
}
|
||||
strategy := resourceclaim.NewStrategy(nsClient)
|
||||
if authorizer == nil {
|
||||
return nil, nil, fmt.Errorf("authorizer is required")
|
||||
}
|
||||
|
||||
strategy := resourceclaim.NewStrategy(nsClient, authorizer)
|
||||
store := &genericregistry.Store{
|
||||
NewFunc: func() runtime.Object { return &resource.ResourceClaim{} },
|
||||
NewListFunc: func() runtime.Object { return &resource.ResourceClaimList{} },
|
||||
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -25,6 +26,8 @@ import (
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing"
|
||||
@@ -36,6 +39,13 @@ import (
|
||||
"k8s.io/kubernetes/pkg/registry/registrytest"
|
||||
)
|
||||
|
||||
type fakeAuthorizer struct {
|
||||
}
|
||||
|
||||
func (f *fakeAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
return authorizer.DecisionAllow, "ok", nil
|
||||
}
|
||||
|
||||
func newStorage(t *testing.T) (*REST, *StatusREST, *etcd3testing.EtcdTestServer) {
|
||||
etcdStorage, server := registrytest.NewEtcdStorage(t, resource.GroupName)
|
||||
restOptions := generic.RESTOptions{
|
||||
@@ -46,7 +56,7 @@ func newStorage(t *testing.T) (*REST, *StatusREST, *etcd3testing.EtcdTestServer)
|
||||
}
|
||||
fakeClient := fake.NewSimpleClientset()
|
||||
mockNSClient := fakeClient.CoreV1().Namespaces()
|
||||
resourceClaimStorage, statusStorage, err := NewREST(restOptions, mockNSClient)
|
||||
resourceClaimStorage, statusStorage, err := NewREST(restOptions, mockNSClient, &fakeAuthorizer{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error from REST storage: %v", err)
|
||||
}
|
||||
@@ -159,6 +169,17 @@ func TestUpdateStatus(t *testing.T) {
|
||||
defer server.Terminate(t)
|
||||
defer storage.Store.DestroyFunc()
|
||||
ctx := genericapirequest.NewDefaultContext()
|
||||
ctx = genericapirequest.WithUser(ctx, &user.DefaultInfo{Name: "system:serviceaccount:kube-system:test"})
|
||||
ctx = genericapirequest.WithRequestInfo(ctx, &genericapirequest.RequestInfo{
|
||||
IsResourceRequest: true,
|
||||
Verb: "update",
|
||||
APIGroup: "resource.k8s.io",
|
||||
APIVersion: "v1",
|
||||
Resource: "resourceclaims",
|
||||
Subresource: "status",
|
||||
Namespace: metav1.NamespaceDefault,
|
||||
Name: "foo",
|
||||
})
|
||||
|
||||
key, _ := storage.KeyFunc(ctx, "foo")
|
||||
claimStart := validNewClaim("foo", metav1.NamespaceDefault)
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/apiserver/pkg/storage"
|
||||
@@ -50,15 +51,17 @@ import (
|
||||
type resourceclaimStrategy struct {
|
||||
runtime.ObjectTyper
|
||||
names.NameGenerator
|
||||
nsClient v1.NamespaceInterface
|
||||
nsClient v1.NamespaceInterface
|
||||
authorizer authorizer.Authorizer
|
||||
}
|
||||
|
||||
// NewStrategy is the default logic that applies when creating and updating ResourceClaim objects.
|
||||
func NewStrategy(nsClient v1.NamespaceInterface) *resourceclaimStrategy {
|
||||
func NewStrategy(nsClient v1.NamespaceInterface, authorizer authorizer.Authorizer) *resourceclaimStrategy {
|
||||
return &resourceclaimStrategy{
|
||||
legacyscheme.Scheme,
|
||||
names.SimpleNameGenerator,
|
||||
nsClient,
|
||||
authorizer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +197,17 @@ func (r *resourceclaimStatusStrategy) ValidateUpdate(ctx context.Context, obj, o
|
||||
oldAllocationResult = oldClaim.Status.Allocation.Devices.Results
|
||||
}
|
||||
errs := resourceutils.AuthorizedForAdminStatus(ctx, newAllocationResult, oldAllocationResult, newClaim.Namespace, r.nsClient)
|
||||
|
||||
if utilfeature.DefaultFeatureGate.Enabled(features.DRAResourceClaimGranularStatusAuthorization) {
|
||||
errs = append(errs, resourceutils.AuthorizedForBinding(ctx, field.NewPath("status", "allocation"), r.authorizer, newClaim.Status, oldClaim.Status)...)
|
||||
|
||||
// Only authorize driver device status if the claim is actually allocated since
|
||||
// we drop all the device status when the claim is deallocated.
|
||||
if utilfeature.DefaultFeatureGate.Enabled(features.DRAResourceClaimDeviceStatus) &&
|
||||
newClaim.Status.Allocation != nil {
|
||||
errs = append(errs, resourceutils.AuthorizedForDeviceStatus(ctx, field.NewPath("status", "devices"), r.authorizer, newClaim.Status, oldClaim.Status)...)
|
||||
}
|
||||
}
|
||||
errs = append(errs, validation.ValidateResourceClaimStatusUpdate(newClaim, oldClaim)...)
|
||||
return rest.ValidateDeclarativelyWithMigrationChecks(ctx, legacyscheme.Scheme, newClaim, oldClaim, errs, operation.Update)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
package resourceclaim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -26,12 +27,15 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/util/version"
|
||||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
testclient "k8s.io/client-go/testing"
|
||||
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
||||
"k8s.io/klog/v2"
|
||||
"k8s.io/kubernetes/pkg/apis/core"
|
||||
"k8s.io/kubernetes/pkg/apis/resource"
|
||||
"k8s.io/kubernetes/pkg/features"
|
||||
"k8s.io/utils/ptr"
|
||||
@@ -374,6 +378,9 @@ var fieldImmutableError = "field is immutable"
|
||||
var metadataError = "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters"
|
||||
var deviceRequestError = "exactly one of `exactly` or `firstAvailable` is required"
|
||||
var constraintError = "matchAttribute: Required value"
|
||||
var bindingUpdateError = `User "test-user" cannot update resource "resourceclaims/binding" in API group "resource.k8s.io" at the cluster scope: denied`
|
||||
var deviceAssociatedNodeUpdateError = `User "system:serviceaccount:kube-system:dra-driver" cannot associated-node:update resource "resourceclaims/driver" in API group "resource.k8s.io" in the namespace "default": denied`
|
||||
var deviceArbitraryNodeUpdateError = `User "test-user" cannot arbitrary-node:update resource "resourceclaims/driver" in API group "resource.k8s.io" in the namespace "default": denied`
|
||||
|
||||
const (
|
||||
req0 = "req-0"
|
||||
@@ -384,6 +391,7 @@ const (
|
||||
testDriver = "test-driver"
|
||||
testPool = "test-pool"
|
||||
testDevice = "test-device"
|
||||
testUser = "test-user"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -397,7 +405,7 @@ var testCapacity = map[resource.QualifiedName]apiresource.Quantity{
|
||||
func TestStrategy(t *testing.T) {
|
||||
fakeClient := fake.NewSimpleClientset()
|
||||
mockNSClient := fakeClient.CoreV1().Namespaces()
|
||||
strategy := NewStrategy(mockNSClient)
|
||||
strategy := NewStrategy(mockNSClient, nil)
|
||||
if !strategy.NamespaceScoped() {
|
||||
t.Errorf("ResourceClaim must be namespace scoped")
|
||||
}
|
||||
@@ -629,7 +637,7 @@ func TestStrategyCreate(t *testing.T) {
|
||||
features.DRAPrioritizedList: tc.prioritizedList,
|
||||
features.DRAConsumableCapacity: tc.consumableCapacity,
|
||||
})
|
||||
strategy := NewStrategy(mockNSClient)
|
||||
strategy := NewStrategy(mockNSClient, nil)
|
||||
|
||||
obj := tc.obj.DeepCopy()
|
||||
strategy.PrepareForCreate(ctx, obj)
|
||||
@@ -964,7 +972,7 @@ func TestStrategyUpdate(t *testing.T) {
|
||||
features.DRAConsumableCapacity: tc.consumableCapacity,
|
||||
})
|
||||
|
||||
strategy := NewStrategy(mockNSClient)
|
||||
strategy := NewStrategy(mockNSClient, nil)
|
||||
|
||||
oldObj := tc.oldObj.DeepCopy()
|
||||
newObj := tc.newObj.DeepCopy()
|
||||
@@ -996,15 +1004,30 @@ func TestStrategyUpdate(t *testing.T) {
|
||||
|
||||
func TestStatusStrategyUpdate(t *testing.T) {
|
||||
ctx := genericapirequest.NewDefaultContext()
|
||||
ctx = genericapirequest.WithUser(ctx, &user.DefaultInfo{
|
||||
Name: testUser,
|
||||
Groups: []string{"system:authenticated"},
|
||||
})
|
||||
ctx = genericapirequest.WithRequestInfo(ctx, &genericapirequest.RequestInfo{
|
||||
IsResourceRequest: true,
|
||||
Verb: "update",
|
||||
APIGroup: "resource.k8s.io",
|
||||
APIVersion: "v1",
|
||||
Resource: "resourceclaims",
|
||||
Subresource: "status",
|
||||
Namespace: metav1.NamespaceDefault,
|
||||
})
|
||||
testcases := map[string]struct {
|
||||
oldObj *resource.ResourceClaim
|
||||
newObj *resource.ResourceClaim
|
||||
authz authorizer.Authorizer
|
||||
ctxOverride func(context.Context) context.Context // if set, transforms the default ctx
|
||||
adminAccess bool
|
||||
deviceStatusFeatureGate bool
|
||||
consumableCapacityFeatureGate bool
|
||||
prioritizedListFeatureGate bool
|
||||
bindingConditions bool
|
||||
expectValidationError string
|
||||
expectValidationErrors []string
|
||||
expectObj *resource.ResourceClaim
|
||||
verify func(*testing.T, []testclient.Action)
|
||||
}{
|
||||
@@ -1025,7 +1048,7 @@ func TestStatusStrategyUpdate(t *testing.T) {
|
||||
obj.Name += "-2"
|
||||
return obj
|
||||
}(),
|
||||
expectValidationError: fieldImmutableError,
|
||||
expectValidationErrors: []string{fieldImmutableError},
|
||||
verify: func(t *testing.T, as []testclient.Action) {
|
||||
if len(as) != 0 {
|
||||
t.Errorf("expected no action to be taken")
|
||||
@@ -1082,10 +1105,10 @@ func TestStatusStrategyUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
"keep-fields-admin-access-NonAdminNamespace": {
|
||||
oldObj: objInNonAdminNamespace,
|
||||
newObj: objWithAdminAccessStatusInNonAdminNamespace,
|
||||
adminAccess: true,
|
||||
expectValidationError: adminAccessError,
|
||||
oldObj: objInNonAdminNamespace,
|
||||
newObj: objWithAdminAccessStatusInNonAdminNamespace,
|
||||
adminAccess: true,
|
||||
expectValidationErrors: []string{adminAccessError},
|
||||
verify: func(t *testing.T, as []testclient.Action) {
|
||||
if len(as) != 1 {
|
||||
t.Errorf("expected one action but got %d", len(as))
|
||||
@@ -1245,6 +1268,123 @@ func TestStatusStrategyUpdate(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
"fail-update-fields-devices-status-without-permissions": {
|
||||
oldObj: func() *resource.ResourceClaim {
|
||||
obj := obj.DeepCopy()
|
||||
addSpecDevicesRequest(obj, testRequest)
|
||||
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
|
||||
return obj
|
||||
}(),
|
||||
newObj: func() *resource.ResourceClaim { // Status is added
|
||||
obj := obj.DeepCopy()
|
||||
addSpecDevicesRequest(obj, testRequest)
|
||||
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
|
||||
addStatusDevices(obj, testDriver, testPool, testDevice, nil)
|
||||
return obj
|
||||
}(),
|
||||
adminAccess: true, // Keep emulation version at 1.36 so DRAResourceClaimGranularStatusAuthorization is active
|
||||
deviceStatusFeatureGate: true,
|
||||
expectValidationErrors: []string{deviceArbitraryNodeUpdateError},
|
||||
expectObj: func() *resource.ResourceClaim { // Status is not updated
|
||||
obj := obj.DeepCopy()
|
||||
addSpecDevicesRequest(obj, testRequest)
|
||||
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
|
||||
return obj
|
||||
}(),
|
||||
authz: &fakeAuthorizer{false},
|
||||
verify: func(t *testing.T, as []testclient.Action) {
|
||||
if len(as) != 0 {
|
||||
t.Errorf("expected no action to be taken")
|
||||
}
|
||||
},
|
||||
},
|
||||
"fail-update-fields-devices-status-associated-node-without-permissions": {
|
||||
oldObj: func() *resource.ResourceClaim {
|
||||
obj := obj.DeepCopy()
|
||||
addSpecDevicesRequest(obj, testRequest)
|
||||
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
|
||||
obj.Status.Allocation.NodeSelector = &core.NodeSelector{
|
||||
NodeSelectorTerms: []core.NodeSelectorTerm{{
|
||||
MatchFields: []core.NodeSelectorRequirement{{
|
||||
Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"test-node"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
return obj
|
||||
}(),
|
||||
newObj: func() *resource.ResourceClaim {
|
||||
obj := obj.DeepCopy()
|
||||
addSpecDevicesRequest(obj, testRequest)
|
||||
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
|
||||
obj.Status.Allocation.NodeSelector = &core.NodeSelector{
|
||||
NodeSelectorTerms: []core.NodeSelectorTerm{{
|
||||
MatchFields: []core.NodeSelectorRequirement{{
|
||||
Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"test-node"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
addStatusDevices(obj, testDriver, testPool, testDevice, nil)
|
||||
return obj
|
||||
}(),
|
||||
ctxOverride: func(ctx context.Context) context.Context {
|
||||
return genericapirequest.WithUser(ctx, &user.DefaultInfo{
|
||||
Name: "system:serviceaccount:kube-system:dra-driver",
|
||||
Groups: []string{"system:authenticated"},
|
||||
Extra: map[string][]string{"authentication.kubernetes.io/node-name": {"test-node"}},
|
||||
})
|
||||
},
|
||||
adminAccess: true,
|
||||
deviceStatusFeatureGate: true,
|
||||
authz: &fakeAuthorizer{false},
|
||||
expectValidationErrors: []string{deviceAssociatedNodeUpdateError},
|
||||
expectObj: func() *resource.ResourceClaim {
|
||||
obj := obj.DeepCopy()
|
||||
addSpecDevicesRequest(obj, testRequest)
|
||||
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
|
||||
obj.Status.Allocation.NodeSelector = &core.NodeSelector{
|
||||
NodeSelectorTerms: []core.NodeSelectorTerm{{
|
||||
MatchFields: []core.NodeSelectorRequirement{{
|
||||
Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"test-node"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
return obj
|
||||
}(),
|
||||
verify: func(t *testing.T, as []testclient.Action) {
|
||||
if len(as) != 0 {
|
||||
t.Errorf("expected no action to be taken")
|
||||
}
|
||||
},
|
||||
},
|
||||
"fail-drop-status-deallocated-device-without-permissions": {
|
||||
oldObj: func() *resource.ResourceClaim {
|
||||
obj := obj.DeepCopy()
|
||||
addSpecDevicesRequest(obj, testRequest)
|
||||
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
|
||||
addStatusDevices(obj, testDriver, testPool, testDevice, nil)
|
||||
return obj
|
||||
}(),
|
||||
newObj: func() *resource.ResourceClaim { // device is deallocated
|
||||
obj := obj.DeepCopy()
|
||||
addSpecDevicesRequest(obj, testRequest)
|
||||
addStatusDevices(obj, testDriver, testPool, testDevice, nil)
|
||||
return obj
|
||||
}(),
|
||||
adminAccess: true, // Keep emulation version at 1.36 so DRAResourceClaimGranularStatusAuthorization is active
|
||||
authz: &fakeAuthorizer{false},
|
||||
deviceStatusFeatureGate: true,
|
||||
expectValidationErrors: []string{bindingUpdateError},
|
||||
expectObj: func() *resource.ResourceClaim { // Status is no longer there
|
||||
obj := obj.DeepCopy()
|
||||
addSpecDevicesRequest(obj, testRequest)
|
||||
return obj
|
||||
}(),
|
||||
verify: func(t *testing.T, as []testclient.Action) {
|
||||
if len(as) != 0 {
|
||||
t.Errorf("expected no action to be taken")
|
||||
}
|
||||
},
|
||||
},
|
||||
"drop-status-deallocated-device-disable-feature-gate": {
|
||||
oldObj: func() *resource.ResourceClaim {
|
||||
obj := obj.DeepCopy()
|
||||
@@ -1562,7 +1702,11 @@ func TestStatusStrategyUpdate(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fakeClient := fake.NewSimpleClientset(ns1, ns2)
|
||||
mockNSClient := fakeClient.CoreV1().Namespaces()
|
||||
strategy := NewStrategy(mockNSClient)
|
||||
authz := tc.authz
|
||||
if tc.authz == nil {
|
||||
authz = &fakeAuthorizer{true}
|
||||
}
|
||||
strategy := NewStrategy(mockNSClient, authz)
|
||||
|
||||
if !tc.adminAccess {
|
||||
featuregatetesting.SetFeatureGateEmulationVersionDuringTest(t, utilfeature.DefaultFeatureGate, version.MustParse("1.35"))
|
||||
@@ -1580,20 +1724,27 @@ func TestStatusStrategyUpdate(t *testing.T) {
|
||||
})
|
||||
statusStrategy := NewStatusStrategy(strategy)
|
||||
|
||||
ctx := ctx
|
||||
if tc.ctxOverride != nil {
|
||||
ctx = tc.ctxOverride(ctx)
|
||||
}
|
||||
|
||||
oldObj := tc.oldObj.DeepCopy()
|
||||
newObj := tc.newObj.DeepCopy()
|
||||
newObj.ResourceVersion = "4"
|
||||
|
||||
statusStrategy.PrepareForUpdate(ctx, newObj, oldObj)
|
||||
if errs := statusStrategy.ValidateUpdate(ctx, newObj, oldObj); len(errs) != 0 {
|
||||
if tc.expectValidationError == "" {
|
||||
if len(tc.expectValidationErrors) == 0 {
|
||||
t.Fatalf("unexpected error(s): %v", errs)
|
||||
}
|
||||
assert.Len(t, errs, 1, "exactly one error expected")
|
||||
assert.ErrorContains(t, errs[0], tc.expectValidationError, "the error message should have contained the expected error message")
|
||||
assert.Len(t, errs, len(tc.expectValidationErrors), "wrong number of validation errors")
|
||||
for i, expectValidationError := range tc.expectValidationErrors {
|
||||
assert.ErrorContains(t, errs[i], expectValidationError, "the error message should have contained the expected error message")
|
||||
}
|
||||
return
|
||||
}
|
||||
if tc.expectValidationError != "" {
|
||||
if len(tc.expectValidationErrors) != 0 {
|
||||
t.Fatal("expected validation error(s), got none")
|
||||
}
|
||||
if warnings := statusStrategy.WarningsOnUpdate(ctx, newObj, oldObj); len(warnings) != 0 {
|
||||
@@ -1661,3 +1812,14 @@ func addStatusDevices(resourceClaim *resource.ResourceClaim, driver string, pool
|
||||
ShareID: (*string)(shareID),
|
||||
})
|
||||
}
|
||||
|
||||
type fakeAuthorizer struct {
|
||||
verdict bool
|
||||
}
|
||||
|
||||
func (f *fakeAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
if !f.verdict {
|
||||
return authorizer.DecisionDeny, "denied", nil
|
||||
}
|
||||
return authorizer.DecisionAllow, "default accept", nil
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
resourcev1alpha3 "k8s.io/api/resource/v1alpha3"
|
||||
resourcev1beta1 "k8s.io/api/resource/v1beta1"
|
||||
resourcev1beta2 "k8s.io/api/resource/v1beta2"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
@@ -42,6 +43,7 @@ import (
|
||||
|
||||
type RESTStorageProvider struct {
|
||||
NamespaceClient v1.NamespaceInterface
|
||||
Authorizer authorizer.Authorizer
|
||||
}
|
||||
|
||||
func (p RESTStorageProvider) NewRESTStorage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (genericapiserver.APIGroupInfo, error) {
|
||||
@@ -88,7 +90,7 @@ func (p RESTStorageProvider) v1Storage(apiResourceConfigSource serverstorage.API
|
||||
}
|
||||
|
||||
if resource := "resourceclaims"; apiResourceConfigSource.ResourceEnabled(resourcev1.SchemeGroupVersion.WithResource(resource)) {
|
||||
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient)
|
||||
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient, p.Authorizer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -151,7 +153,7 @@ func (p RESTStorageProvider) v1beta1Storage(apiResourceConfigSource serverstorag
|
||||
}
|
||||
|
||||
if resource := "resourceclaims"; apiResourceConfigSource.ResourceEnabled(resourcev1beta1.SchemeGroupVersion.WithResource(resource)) {
|
||||
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient)
|
||||
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient, p.Authorizer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -199,7 +201,7 @@ func (p RESTStorageProvider) v1beta2Storage(apiResourceConfigSource serverstorag
|
||||
}
|
||||
|
||||
if resource := "resourceclaims"; apiResourceConfigSource.ResourceEnabled(resourcev1beta2.SchemeGroupVersion.WithResource(resource)) {
|
||||
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient)
|
||||
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient, p.Authorizer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,9 +20,19 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
resourcev1 "k8s.io/api/resource/v1"
|
||||
"k8s.io/apimachinery/pkg/api/equality"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"k8s.io/apiserver/pkg/authentication/serviceaccount"
|
||||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/endpoints/filters"
|
||||
"k8s.io/apiserver/pkg/endpoints/handlers/responsewriters"
|
||||
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
|
||||
"k8s.io/kubernetes/pkg/apis/core"
|
||||
"k8s.io/kubernetes/pkg/apis/core/validation"
|
||||
"k8s.io/kubernetes/pkg/apis/resource"
|
||||
)
|
||||
|
||||
@@ -102,3 +112,221 @@ func adminRequested(deviceRequestResults []resource.DeviceRequestAllocationResul
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// AuthorizedForBinding checks if the caller is authorized to update
|
||||
// status.allocation and status.reservedFor by verifying permission on the
|
||||
// synthetic resourceclaims/binding subresource.
|
||||
func AuthorizedForBinding(ctx context.Context, fieldPath *field.Path, authz authorizer.Authorizer, newStatus, oldStatus resource.ResourceClaimStatus) field.ErrorList {
|
||||
var allErrs field.ErrorList
|
||||
|
||||
if equality.Semantic.DeepEqual(newStatus.Allocation, oldStatus.Allocation) &&
|
||||
equality.Semantic.DeepEqual(newStatus.ReservedFor, oldStatus.ReservedFor) {
|
||||
return allErrs
|
||||
}
|
||||
|
||||
baseAttrs, err := filters.GetAuthorizerAttributes(ctx)
|
||||
if err != nil {
|
||||
return append(allErrs, field.InternalError(fieldPath, fmt.Errorf("cannot build authorizer attributes: %w", err)))
|
||||
}
|
||||
|
||||
attrs := &syntheticSubresourceAttrs{
|
||||
Attributes: baseAttrs,
|
||||
verb: baseAttrs.GetVerb(), // verb is unchanged but must be specified
|
||||
subresource: resourcev1.SubresourceBinding, // the scheduler calls this "bind claim"
|
||||
namespace: "", // cluster-wide
|
||||
name: "", // all names
|
||||
}
|
||||
|
||||
if err := checkAuthorization(ctx, authz, attrs); err != nil {
|
||||
return append(allErrs, field.Forbidden(fieldPath, fmt.Sprintf(`changing status.allocation or status.reservedFor requires resource="resourceclaims/binding", verb="%s" permission: %s`, attrs.verb, err)))
|
||||
}
|
||||
return allErrs
|
||||
}
|
||||
|
||||
// AuthorizedForDeviceStatus checks if the caller is authorized to update
|
||||
// status.devices by performing per-driver authorization checks using the
|
||||
// associated-node / arbitrary-node verb prefix pattern on the synthetic
|
||||
// resourceclaims/driver subresource.
|
||||
func AuthorizedForDeviceStatus(ctx context.Context, fieldPath *field.Path, a authorizer.Authorizer, newStatus, oldStatus resource.ResourceClaimStatus) field.ErrorList {
|
||||
var allErrs field.ErrorList
|
||||
|
||||
driversToAuthz := getModifiedDrivers(newStatus, oldStatus)
|
||||
if len(driversToAuthz) == 0 {
|
||||
return allErrs
|
||||
}
|
||||
|
||||
baseAttrs, err := filters.GetAuthorizerAttributes(ctx)
|
||||
if err != nil {
|
||||
return append(allErrs, field.InternalError(fieldPath, fmt.Errorf("cannot build authorizer attributes: %w", err)))
|
||||
}
|
||||
|
||||
// if service account is on the same node as the claim, check associated-node verb first, fall back to arbitrary-node
|
||||
// Otherwise, only try arbitrary-node
|
||||
requestVerb := baseAttrs.GetVerb()
|
||||
var verbs []string
|
||||
if saAssociatedWithAllocatedNode(baseAttrs.GetUser(), nodeNameFromAllocation(newStatus.Allocation)) {
|
||||
verbs = []string{resourcev1.VerbPrefixAssociatedNode + requestVerb, resourcev1.VerbPrefixArbitraryNode + requestVerb}
|
||||
} else {
|
||||
verbs = []string{resourcev1.VerbPrefixArbitraryNode + requestVerb}
|
||||
}
|
||||
|
||||
for _, driverName := range sets.List(driversToAuthz) {
|
||||
if err := checkDriverAuthorization(ctx, baseAttrs, verbs, driverName, a); err != nil {
|
||||
allErrs = append(allErrs, field.Forbidden(fieldPath, fmt.Sprintf(`changing status.devices requires resource="resourceclaims/driver", verb="%s" permission: %s`, verbs, err)))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func checkDriverAuthorization(ctx context.Context, baseAttrs authorizer.Attributes, verbs []string, driverName string, a authorizer.Authorizer) error {
|
||||
if len(verbs) == 0 {
|
||||
return fmt.Errorf("no verbs set for driver %s", driverName) // impossible for all inputs today
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, verb := range verbs { // verbs are OR'd with each other
|
||||
attrs := &syntheticSubresourceAttrs{
|
||||
Attributes: baseAttrs,
|
||||
verb: verb,
|
||||
subresource: resourcev1.SubresourceDriver,
|
||||
namespace: baseAttrs.GetNamespace(),
|
||||
name: driverName,
|
||||
}
|
||||
err := checkAuthorization(ctx, a, attrs)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// getModifiedDrivers identifies all drivers whose status entries were added,
|
||||
// removed, or changed between the old and new ResourceClaim objects.
|
||||
func getModifiedDrivers(newAllocatedDeviceStatus, oldAllocatedDeviceStatus resource.ResourceClaimStatus) sets.Set[string] {
|
||||
driversToAuthz := sets.Set[string]{}
|
||||
|
||||
oldDevices := make(map[deviceKey]resource.AllocatedDeviceStatus)
|
||||
for _, d := range oldAllocatedDeviceStatus.Devices {
|
||||
oldDevices[makeDeviceKey(d)] = d
|
||||
}
|
||||
|
||||
// Check for new or modified device entries
|
||||
for _, d := range newAllocatedDeviceStatus.Devices {
|
||||
key := makeDeviceKey(d)
|
||||
oldDevice, ok := oldDevices[key]
|
||||
delete(oldDevices, key) // Remove from map to track processed devices
|
||||
|
||||
// If entry is new or changed, we need to authorize this driver.
|
||||
if !ok || !equality.Semantic.DeepEqual(oldDevice, d) {
|
||||
driversToAuthz.Insert(d.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for removed device entries
|
||||
for _, d := range oldDevices {
|
||||
// Any remaining device in oldDevices was removed in rcNew.
|
||||
driversToAuthz.Insert(d.Driver)
|
||||
}
|
||||
|
||||
return driversToAuthz
|
||||
}
|
||||
|
||||
type deviceKey struct {
|
||||
driver string
|
||||
pool string
|
||||
device string
|
||||
shareID string
|
||||
}
|
||||
|
||||
func makeDeviceKey(d resource.AllocatedDeviceStatus) deviceKey {
|
||||
key := deviceKey{
|
||||
driver: d.Driver,
|
||||
pool: d.Pool,
|
||||
device: d.Device,
|
||||
}
|
||||
if d.ShareID != nil {
|
||||
key.shareID = *d.ShareID
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func nodeNameFromAllocation(allocation *resource.AllocationResult) string {
|
||||
if allocation == nil || allocation.NodeSelector == nil {
|
||||
return ""
|
||||
}
|
||||
ns := allocation.NodeSelector
|
||||
if len(ns.NodeSelectorTerms) != 1 {
|
||||
return ""
|
||||
}
|
||||
term := ns.NodeSelectorTerms[0]
|
||||
if len(term.MatchExpressions) != 0 || len(term.MatchFields) != 1 {
|
||||
return ""
|
||||
}
|
||||
f := term.MatchFields[0]
|
||||
if f.Key != "metadata.name" || f.Operator != core.NodeSelectorOpIn || len(f.Values) != 1 {
|
||||
return ""
|
||||
}
|
||||
return f.Values[0]
|
||||
}
|
||||
|
||||
func saAssociatedWithAllocatedNode(u user.Info, allocatedNodeName string) bool {
|
||||
if len(allocatedNodeName) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must be a ServiceAccount
|
||||
if _, _, err := serviceaccount.SplitUsername(u.GetName()); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have exactly one node-name extra attribute
|
||||
nodeNames := u.GetExtra()[serviceaccount.NodeNameKey]
|
||||
if len(nodeNames) != 1 {
|
||||
return false
|
||||
}
|
||||
nodeName := nodeNames[0]
|
||||
|
||||
// Must be a valid node name format
|
||||
if len(validation.ValidateNodeName(nodeName, false)) != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return nodeName == allocatedNodeName
|
||||
}
|
||||
|
||||
func checkAuthorization(ctx context.Context, a authorizer.Authorizer, attributes authorizer.Attributes) error {
|
||||
authorized, reason, err := a.Authorize(ctx, attributes)
|
||||
|
||||
// an authorizer like RBAC could encounter evaluation errors and still allow the request, so authorizer decision is checked before error here.
|
||||
if authorized == authorizer.DecisionAllow {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := reason
|
||||
switch {
|
||||
case err != nil && len(reason) > 0:
|
||||
msg = fmt.Sprintf("%v: %s", err, reason)
|
||||
case err != nil:
|
||||
msg = err.Error()
|
||||
}
|
||||
|
||||
return responsewriters.ForbiddenStatusError(attributes, msg)
|
||||
}
|
||||
|
||||
type syntheticSubresourceAttrs struct {
|
||||
authorizer.Attributes
|
||||
verb string
|
||||
subresource string
|
||||
namespace string
|
||||
name string
|
||||
}
|
||||
|
||||
func (a *syntheticSubresourceAttrs) GetVerb() string { return a.verb }
|
||||
func (a *syntheticSubresourceAttrs) GetSubresource() string { return a.subresource }
|
||||
func (a *syntheticSubresourceAttrs) GetNamespace() string { return a.namespace }
|
||||
func (a *syntheticSubresourceAttrs) GetName() string { return a.name }
|
||||
|
||||
740
pkg/registry/resource/utils_test.go
Normal file
740
pkg/registry/resource/utils_test.go
Normal file
@@ -0,0 +1,740 @@
|
||||
/*
|
||||
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 resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"k8s.io/apiserver/pkg/authentication/serviceaccount"
|
||||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/kubernetes/pkg/apis/core"
|
||||
"k8s.io/kubernetes/pkg/apis/resource"
|
||||
)
|
||||
|
||||
// TestGetModifiedDrivers contains the unit tests for the getModifiedDrivers function.
|
||||
func TestGetModifiedDrivers(t *testing.T) {
|
||||
// Helper to create AllocatedDeviceStatus
|
||||
devStatus := func(driver, pool, device string, network *resource.NetworkDeviceData) resource.AllocatedDeviceStatus {
|
||||
return resource.AllocatedDeviceStatus{
|
||||
Driver: driver,
|
||||
Pool: pool,
|
||||
Device: device,
|
||||
NetworkData: network,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create ResourceClaimStatus
|
||||
claimStatus := func(devices ...resource.AllocatedDeviceStatus) resource.ResourceClaimStatus {
|
||||
return resource.ResourceClaimStatus{
|
||||
Devices: devices,
|
||||
}
|
||||
}
|
||||
|
||||
testCases := map[string]struct {
|
||||
newStatus resource.ResourceClaimStatus
|
||||
oldStatus resource.ResourceClaimStatus
|
||||
expected sets.Set[string]
|
||||
}{
|
||||
"no changes": {
|
||||
newStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
devStatus("driver-b", "pool-1", "dev-2", nil),
|
||||
),
|
||||
oldStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
devStatus("driver-b", "pool-1", "dev-2", nil),
|
||||
),
|
||||
expected: sets.Set[string]{},
|
||||
},
|
||||
"add one device": {
|
||||
newStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
devStatus("driver-b", "pool-1", "dev-2", nil), // New
|
||||
),
|
||||
oldStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
),
|
||||
expected: sets.New[string]("driver-b"),
|
||||
},
|
||||
"add device for existing driver": {
|
||||
newStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
devStatus("driver-a", "pool-1", "dev-2", nil), // New
|
||||
),
|
||||
oldStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
),
|
||||
expected: sets.New[string]("driver-a"),
|
||||
},
|
||||
"remove one device": {
|
||||
newStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
),
|
||||
oldStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
devStatus("driver-b", "pool-1", "dev-2", nil), // Removed
|
||||
),
|
||||
expected: sets.New[string]("driver-b"),
|
||||
},
|
||||
"remove device for driver that still has other devices": {
|
||||
newStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
),
|
||||
oldStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
devStatus("driver-a", "pool-1", "dev-2", nil), // Removed
|
||||
),
|
||||
expected: sets.New[string]("driver-a"),
|
||||
},
|
||||
"modify one device": {
|
||||
newStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0", IPs: []string{"192.168.7.1/24"}}), // Modified
|
||||
),
|
||||
oldStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0"}),
|
||||
),
|
||||
expected: sets.New[string]("driver-a"),
|
||||
},
|
||||
"modify device for driver, no change for other driver": {
|
||||
newStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0", IPs: []string{"192.168.7.1/24"}}), // Modified
|
||||
devStatus("driver-b", "pool-1", "dev-2", nil),
|
||||
),
|
||||
oldStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0"}),
|
||||
devStatus("driver-b", "pool-1", "dev-2", nil),
|
||||
),
|
||||
expected: sets.New[string]("driver-a"),
|
||||
},
|
||||
"complex change (add, remove, modify)": {
|
||||
newStatus: claimStatus(
|
||||
// driver-a: dev-1 modified
|
||||
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0", IPs: []string{"192.168.7.1/24"}}), // Modified
|
||||
// driver-b: dev-2 unchanged
|
||||
devStatus("driver-b", "pool-1", "dev-2", nil),
|
||||
// driver-c: dev-3 added
|
||||
devStatus("driver-c", "pool-1", "dev-3", nil),
|
||||
),
|
||||
oldStatus: claimStatus(
|
||||
// driver-a: dev-1 old state
|
||||
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0"}),
|
||||
// driver-b: dev-2 unchanged
|
||||
devStatus("driver-b", "pool-1", "dev-2", nil),
|
||||
// driver-d: dev-4 removed
|
||||
devStatus("driver-d", "pool-1", "dev-4", nil),
|
||||
),
|
||||
expected: sets.New[string]("driver-a", "driver-c", "driver-d"),
|
||||
},
|
||||
"empty to empty": {
|
||||
newStatus: claimStatus(),
|
||||
oldStatus: claimStatus(),
|
||||
expected: sets.Set[string]{},
|
||||
},
|
||||
"empty to one device": {
|
||||
newStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
),
|
||||
oldStatus: claimStatus(),
|
||||
expected: sets.New[string]("driver-a"),
|
||||
},
|
||||
"one device to empty": {
|
||||
newStatus: claimStatus(),
|
||||
oldStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", nil),
|
||||
),
|
||||
expected: sets.New[string]("driver-a"),
|
||||
},
|
||||
"replace device with same key but different content": {
|
||||
newStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0", IPs: []string{"192.168.7.1/24"}}),
|
||||
),
|
||||
oldStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0"}),
|
||||
),
|
||||
expected: sets.New[string]("driver-a"),
|
||||
},
|
||||
"replace device with different key for same driver": {
|
||||
newStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-NEW", nil),
|
||||
),
|
||||
oldStatus: claimStatus(
|
||||
devStatus("driver-a", "pool-1", "dev-OLD", nil),
|
||||
),
|
||||
expected: sets.New[string]("driver-a"),
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
result := getModifiedDrivers(tc.newStatus, tc.oldStatus)
|
||||
if !reflect.DeepEqual(result, tc.expected) {
|
||||
t.Errorf("Expected driver set %v, but got %v", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAuthorizer records authorization calls and returns preconfigured decisions.
|
||||
// The key format is "verb/resource/subresource/name".
|
||||
type fakeAuthorizer struct {
|
||||
rules map[string]authorizer.Decision
|
||||
err error
|
||||
callCounts map[string]int
|
||||
}
|
||||
|
||||
func (f *fakeAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
if f.callCounts == nil {
|
||||
f.callCounts = make(map[string]int)
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s/%s/%s/%s", a.GetVerb(), a.GetResource(), a.GetSubresource(), a.GetName())
|
||||
f.callCounts[key]++
|
||||
|
||||
if f.err != nil {
|
||||
return authorizer.DecisionDeny, "forced error", f.err
|
||||
}
|
||||
if decision, ok := f.rules[key]; ok {
|
||||
return decision, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "no rule matched", nil
|
||||
}
|
||||
|
||||
// withRequestContext builds a context with user info and request info set,
|
||||
// simulating what GetAuthorizerAttributes expects.
|
||||
func withRequestContext(ctx context.Context, u user.Info, verb string) context.Context {
|
||||
ctx = genericapirequest.WithUser(ctx, u)
|
||||
ctx = genericapirequest.WithRequestInfo(ctx, &genericapirequest.RequestInfo{
|
||||
IsResourceRequest: true,
|
||||
Verb: verb,
|
||||
APIGroup: "resource.k8s.io",
|
||||
APIVersion: "v1",
|
||||
Resource: "resourceclaims",
|
||||
Subresource: "status",
|
||||
Namespace: "default",
|
||||
Name: "test-claim",
|
||||
})
|
||||
// GetAuthorizerAttributes also needs an http.Request in context for audit, but
|
||||
// the function doesn't fail without it — we simulate by using a dummy request.
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPut, "/", nil)
|
||||
ctx = req.Context()
|
||||
return ctx
|
||||
}
|
||||
|
||||
func singleNodeAllocation(nodeName string) *resource.AllocationResult {
|
||||
return &resource.AllocationResult{
|
||||
NodeSelector: &core.NodeSelector{
|
||||
NodeSelectorTerms: []core.NodeSelectorTerm{
|
||||
{
|
||||
MatchFields: []core.NodeSelectorRequirement{
|
||||
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{nodeName}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedForBinding(t *testing.T) {
|
||||
saName := "system:serviceaccount:kube-system:scheduler"
|
||||
testUser := &user.DefaultInfo{Name: saName}
|
||||
fp := field.NewPath("status")
|
||||
|
||||
testcases := []struct {
|
||||
name string
|
||||
newStatus resource.ResourceClaimStatus
|
||||
oldStatus resource.ResourceClaimStatus
|
||||
authz *fakeAuthorizer
|
||||
expectErrs int
|
||||
}{
|
||||
{
|
||||
name: "no allocation or reservedFor change, no check needed",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: "d", Pool: "p", Device: "dev"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
|
||||
expectErrs: 0,
|
||||
},
|
||||
{
|
||||
name: "allocation changed, authorized",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation("node-1"),
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
|
||||
"update/resourceclaims/binding/": authorizer.DecisionAllow,
|
||||
}},
|
||||
expectErrs: 0,
|
||||
},
|
||||
{
|
||||
name: "allocation changed, not authorized",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation("node-1"),
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
|
||||
expectErrs: 1,
|
||||
},
|
||||
{
|
||||
name: "reservedFor changed, authorized",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
ReservedFor: []resource.ResourceClaimConsumerReference{{Resource: "pods", Name: "pod-1", UID: "uid-1"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
|
||||
"update/resourceclaims/binding/": authorizer.DecisionAllow,
|
||||
}},
|
||||
expectErrs: 0,
|
||||
},
|
||||
{
|
||||
name: "reservedFor changed, not authorized",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
ReservedFor: []resource.ResourceClaimConsumerReference{{Resource: "pods", Name: "pod-1", UID: "uid-1"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
|
||||
expectErrs: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := withRequestContext(context.Background(), testUser, "update")
|
||||
errs := AuthorizedForBinding(ctx, fp, tc.authz, tc.newStatus, tc.oldStatus)
|
||||
if len(errs) != tc.expectErrs {
|
||||
t.Errorf("expected %d errors, got %d: %v", tc.expectErrs, len(errs), errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedForDeviceStatus(t *testing.T) {
|
||||
saName := "system:serviceaccount:kube-system:dra-driver"
|
||||
nodeName := "test-node"
|
||||
driverName := "test-driver"
|
||||
fp := field.NewPath("status", "devices")
|
||||
|
||||
testcases := []struct {
|
||||
name string
|
||||
newStatus resource.ResourceClaimStatus
|
||||
oldStatus resource.ResourceClaimStatus
|
||||
user user.Info
|
||||
authz *fakeAuthorizer
|
||||
verb string
|
||||
expectErrs int
|
||||
}{
|
||||
{
|
||||
name: "no drivers modified",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Devices: []resource.AllocatedDeviceStatus{
|
||||
{Driver: driverName, Pool: "pool", Device: "device"},
|
||||
},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{
|
||||
Devices: []resource.AllocatedDeviceStatus{
|
||||
{Driver: driverName, Pool: "pool", Device: "device"},
|
||||
},
|
||||
},
|
||||
user: &user.DefaultInfo{Name: saName},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
|
||||
verb: "update",
|
||||
expectErrs: 0,
|
||||
},
|
||||
{
|
||||
name: "associated-node: SA on same node, allowed by associated-node verb",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation(nodeName),
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation(nodeName),
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
|
||||
},
|
||||
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
|
||||
fmt.Sprintf("associated-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
|
||||
}},
|
||||
verb: "update",
|
||||
expectErrs: 0,
|
||||
},
|
||||
{
|
||||
name: "associated-node: SA on same node, allowed by arbitrary-node fallback",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation(nodeName),
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation(nodeName),
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
|
||||
},
|
||||
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
|
||||
fmt.Sprintf("arbitrary-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
|
||||
}},
|
||||
verb: "update",
|
||||
expectErrs: 0,
|
||||
},
|
||||
{
|
||||
name: "associated-node: SA on same node, neither verb allowed",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation(nodeName),
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation(nodeName),
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
|
||||
},
|
||||
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
|
||||
verb: "update",
|
||||
expectErrs: 1,
|
||||
},
|
||||
{
|
||||
name: "SA on different node, only arbitrary-node checked",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation("other-node"),
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
|
||||
},
|
||||
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
|
||||
fmt.Sprintf("arbitrary-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
|
||||
}},
|
||||
verb: "update",
|
||||
expectErrs: 0,
|
||||
},
|
||||
{
|
||||
name: "SA on different node, associated-node not checked, denied",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation("other-node"),
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
|
||||
},
|
||||
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
|
||||
// Only grant associated-node, which should NOT be checked since nodes differ
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
|
||||
fmt.Sprintf("associated-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
|
||||
}},
|
||||
verb: "update",
|
||||
expectErrs: 1,
|
||||
},
|
||||
{
|
||||
name: "no node association (controller), only arbitrary-node checked, allowed",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation(nodeName),
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
|
||||
},
|
||||
user: &user.DefaultInfo{Name: saName}, // no node-name extra
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
|
||||
fmt.Sprintf("arbitrary-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
|
||||
}},
|
||||
verb: "update",
|
||||
expectErrs: 0,
|
||||
},
|
||||
{
|
||||
name: "no node association (controller), denied",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation(nodeName),
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
|
||||
},
|
||||
user: &user.DefaultInfo{Name: saName},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
|
||||
verb: "update",
|
||||
expectErrs: 1,
|
||||
},
|
||||
{
|
||||
name: "multi-node claim (no single node in selector), only arbitrary-node",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Allocation: &resource.AllocationResult{
|
||||
NodeSelector: &core.NodeSelector{
|
||||
NodeSelectorTerms: []core.NodeSelectorTerm{
|
||||
{MatchFields: []core.NodeSelectorRequirement{
|
||||
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-a", "node-b"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
|
||||
},
|
||||
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {"node-a"}}},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
|
||||
fmt.Sprintf("arbitrary-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
|
||||
}},
|
||||
verb: "update",
|
||||
expectErrs: 0,
|
||||
},
|
||||
{
|
||||
name: "patch verb propagated",
|
||||
newStatus: resource.ResourceClaimStatus{
|
||||
Allocation: singleNodeAllocation(nodeName),
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
|
||||
},
|
||||
oldStatus: resource.ResourceClaimStatus{
|
||||
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
|
||||
},
|
||||
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
|
||||
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
|
||||
fmt.Sprintf("associated-node:patch/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
|
||||
}},
|
||||
verb: "patch",
|
||||
expectErrs: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := withRequestContext(context.Background(), tc.user, tc.verb)
|
||||
errs := AuthorizedForDeviceStatus(ctx, fp, tc.authz, tc.newStatus, tc.oldStatus)
|
||||
if len(errs) != tc.expectErrs {
|
||||
t.Errorf("expected %d errors, got %d: %v", tc.expectErrs, len(errs), errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeNameFromAllocation(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
allocation *resource.AllocationResult
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "nil allocation",
|
||||
allocation: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "nil node selector",
|
||||
allocation: &resource.AllocationResult{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "exact single-node match",
|
||||
allocation: singleNodeAllocation("worker-1"),
|
||||
expected: "worker-1",
|
||||
},
|
||||
{
|
||||
name: "multiple values",
|
||||
allocation: &resource.AllocationResult{
|
||||
NodeSelector: &core.NodeSelector{
|
||||
NodeSelectorTerms: []core.NodeSelectorTerm{
|
||||
{MatchFields: []core.NodeSelectorRequirement{
|
||||
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-a", "node-b"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "match expressions instead of match fields",
|
||||
allocation: &resource.AllocationResult{
|
||||
NodeSelector: &core.NodeSelector{
|
||||
NodeSelectorTerms: []core.NodeSelectorTerm{
|
||||
{MatchExpressions: []core.NodeSelectorRequirement{
|
||||
{Key: "kubernetes.io/hostname", Operator: core.NodeSelectorOpIn, Values: []string{"node-1"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "multiple terms",
|
||||
allocation: &resource.AllocationResult{
|
||||
NodeSelector: &core.NodeSelector{
|
||||
NodeSelectorTerms: []core.NodeSelectorTerm{
|
||||
{MatchFields: []core.NodeSelectorRequirement{
|
||||
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-1"}},
|
||||
}},
|
||||
{MatchFields: []core.NodeSelectorRequirement{
|
||||
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-2"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "wrong key",
|
||||
allocation: &resource.AllocationResult{
|
||||
NodeSelector: &core.NodeSelector{
|
||||
NodeSelectorTerms: []core.NodeSelectorTerm{
|
||||
{MatchFields: []core.NodeSelectorRequirement{
|
||||
{Key: "metadata.namespace", Operator: core.NodeSelectorOpIn, Values: []string{"node-1"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "wrong operator",
|
||||
allocation: &resource.AllocationResult{
|
||||
NodeSelector: &core.NodeSelector{
|
||||
NodeSelectorTerms: []core.NodeSelectorTerm{
|
||||
{MatchFields: []core.NodeSelectorRequirement{
|
||||
{Key: "metadata.name", Operator: core.NodeSelectorOpNotIn, Values: []string{"node-1"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "extra match fields",
|
||||
allocation: &resource.AllocationResult{
|
||||
NodeSelector: &core.NodeSelector{
|
||||
NodeSelectorTerms: []core.NodeSelectorTerm{
|
||||
{MatchFields: []core.NodeSelectorRequirement{
|
||||
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-1"}},
|
||||
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-1"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := nodeNameFromAllocation(tc.allocation)
|
||||
if result != tc.expected {
|
||||
t.Errorf("expected %q, got %q", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSAAssociatedWithAllocatedNode(t *testing.T) {
|
||||
validSA := &user.DefaultInfo{
|
||||
Name: "system:serviceaccount:default:dra-driver-sa",
|
||||
Extra: map[string][]string{
|
||||
serviceaccount.NodeNameKey: {"worker-node-1"},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
userInfo user.Info
|
||||
allocatedNodeName string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "SA on matching node",
|
||||
userInfo: validSA,
|
||||
allocatedNodeName: "worker-node-1",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "SA on different node",
|
||||
userInfo: validSA,
|
||||
allocatedNodeName: "worker-node-2",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty allocated node name",
|
||||
userInfo: validSA,
|
||||
allocatedNodeName: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not a service account (kubelet identity)",
|
||||
userInfo: &user.DefaultInfo{
|
||||
Name: "system:node:worker-node-1",
|
||||
Extra: map[string][]string{
|
||||
serviceaccount.NodeNameKey: {"worker-node-1"},
|
||||
},
|
||||
},
|
||||
allocatedNodeName: "worker-node-1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not a service account (regular user)",
|
||||
userInfo: &user.DefaultInfo{
|
||||
Name: "jane-doe",
|
||||
Extra: map[string][]string{
|
||||
serviceaccount.NodeNameKey: {"worker-node-1"},
|
||||
},
|
||||
},
|
||||
allocatedNodeName: "worker-node-1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "service account missing node name extra attribute",
|
||||
userInfo: &user.DefaultInfo{
|
||||
Name: "system:serviceaccount:default:dra-driver-sa",
|
||||
Extra: map[string][]string{},
|
||||
},
|
||||
allocatedNodeName: "worker-node-1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "service account with multiple node names in extra attribute",
|
||||
userInfo: &user.DefaultInfo{
|
||||
Name: "system:serviceaccount:default:dra-driver-sa",
|
||||
Extra: map[string][]string{
|
||||
serviceaccount.NodeNameKey: {"worker-node-1", "worker-node-2"},
|
||||
},
|
||||
},
|
||||
allocatedNodeName: "worker-node-1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "service account with invalid node name format",
|
||||
userInfo: &user.DefaultInfo{
|
||||
Name: "system:serviceaccount:default:dra-driver-sa",
|
||||
Extra: map[string][]string{
|
||||
serviceaccount.NodeNameKey: {"invalid_node_name!"},
|
||||
},
|
||||
},
|
||||
allocatedNodeName: "invalid_node_name!",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := saAssociatedWithAllocatedNode(tc.userInfo, tc.allocatedNodeName)
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected %v, got %v", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user