DRA: AdminAccess validate based on namespace label

Signed-off-by: Rita Zhang <rita.z.zhang@gmail.com>
This commit is contained in:
Rita Zhang 2024-12-30 15:56:45 -08:00
parent f007012f5f
commit 0301e5a9f8
No known key found for this signature in database
GPG Key ID: 3ADE11B31515DF8C
18 changed files with 927 additions and 178 deletions

View File

@ -385,6 +385,15 @@ const (
DeviceConfigMaxSize = 32 DeviceConfigMaxSize = 32
) )
// DRAAdminNamespaceLabelKey is a label key used to grant administrative access
// to certain resource.k8s.io API types within a namespace. When this label is
// set on a namespace with the value "true" (case-sensitive), it allows the use
// of adminAccess: true in any namespaced resource.k8s.io API types. Currently,
// this permission applies to ResourceClaim and ResourceClaimTemplate objects.
const (
DRAAdminNamespaceLabelKey = "resource.k8s.io/admin-access"
)
// DeviceRequest is a request for devices required for a claim. // DeviceRequest is a request for devices required for a claim.
// This is typically a request for a single resource like a device, but can // This is typically a request for a single resource like a device, but can
// also ask for several identical devices. // also ask for several identical devices.

View File

@ -40,6 +40,7 @@ import (
"k8s.io/kubernetes/pkg/controller" "k8s.io/kubernetes/pkg/controller"
"k8s.io/kubernetes/pkg/controller/resourceclaim/metrics" "k8s.io/kubernetes/pkg/controller/resourceclaim/metrics"
"k8s.io/kubernetes/test/utils/ktesting" "k8s.io/kubernetes/test/utils/ktesting"
"k8s.io/utils/ptr"
) )
var ( var (
@ -61,13 +62,15 @@ var (
testClaimReserved = reserveClaim(testClaimAllocated, testPodWithResource) testClaimReserved = reserveClaim(testClaimAllocated, testPodWithResource)
testClaimReservedTwice = reserveClaim(testClaimReserved, otherTestPod) testClaimReservedTwice = reserveClaim(testClaimReserved, otherTestPod)
generatedTestClaim = makeGeneratedClaim(podResourceClaimName, testPodName+"-"+podResourceClaimName+"-", testNamespace, className, 1, makeOwnerReference(testPodWithResource, true)) generatedTestClaim = makeGeneratedClaim(podResourceClaimName, testPodName+"-"+podResourceClaimName+"-", testNamespace, className, 1, makeOwnerReference(testPodWithResource, true), nil)
generatedTestClaimWithAdmin = makeGeneratedClaim(podResourceClaimName, testPodName+"-"+podResourceClaimName+"-", testNamespace, className, 1, makeOwnerReference(testPodWithResource, true), ptr.To(true))
generatedTestClaimAllocated = allocateClaim(generatedTestClaim) generatedTestClaimAllocated = allocateClaim(generatedTestClaim)
generatedTestClaimReserved = reserveClaim(generatedTestClaimAllocated, testPodWithResource) generatedTestClaimReserved = reserveClaim(generatedTestClaimAllocated, testPodWithResource)
conflictingClaim = makeClaim(testPodName+"-"+podResourceClaimName, testNamespace, className, nil) conflictingClaim = makeClaim(testPodName+"-"+podResourceClaimName, testNamespace, className, nil)
otherNamespaceClaim = makeClaim(testPodName+"-"+podResourceClaimName, otherNamespace, className, nil) otherNamespaceClaim = makeClaim(testPodName+"-"+podResourceClaimName, otherNamespace, className, nil)
template = makeTemplate(templateName, testNamespace, className) template = makeTemplate(templateName, testNamespace, className, nil)
templateWithAdminAccess = makeTemplate(templateName, testNamespace, className, ptr.To(true))
testPodWithNodeName = func() *v1.Pod { testPodWithNodeName = func() *v1.Pod {
pod := testPodWithResource.DeepCopy() pod := testPodWithResource.DeepCopy()
@ -78,6 +81,7 @@ var (
}) })
return pod return pod
}() }()
adminAccessFeatureOffError = "admin access is requested, but the feature is disabled"
) )
func TestSyncHandler(t *testing.T) { func TestSyncHandler(t *testing.T) {
@ -93,7 +97,7 @@ func TestSyncHandler(t *testing.T) {
templates []*resourceapi.ResourceClaimTemplate templates []*resourceapi.ResourceClaimTemplate
expectedClaims []resourceapi.ResourceClaim expectedClaims []resourceapi.ResourceClaim
expectedStatuses map[string][]v1.PodResourceClaimStatus expectedStatuses map[string][]v1.PodResourceClaimStatus
expectedError bool expectedError string
expectedMetrics expectedMetrics expectedMetrics expectedMetrics
}{ }{
{ {
@ -109,6 +113,27 @@ func TestSyncHandler(t *testing.T) {
}, },
expectedMetrics: expectedMetrics{1, 0}, expectedMetrics: expectedMetrics{1, 0},
}, },
{
name: "create with admin and feature gate off",
pods: []*v1.Pod{testPodWithResource},
templates: []*resourceapi.ResourceClaimTemplate{templateWithAdminAccess},
key: podKey(testPodWithResource),
expectedError: adminAccessFeatureOffError,
},
{
name: "create with admin and feature gate on",
pods: []*v1.Pod{testPodWithResource},
templates: []*resourceapi.ResourceClaimTemplate{templateWithAdminAccess},
key: podKey(testPodWithResource),
expectedClaims: []resourceapi.ResourceClaim{*generatedTestClaimWithAdmin},
expectedStatuses: map[string][]v1.PodResourceClaimStatus{
testPodWithResource.Name: {
{Name: testPodWithResource.Spec.ResourceClaims[0].Name, ResourceClaimName: &generatedTestClaimWithAdmin.Name},
},
},
adminAccessEnabled: true,
expectedMetrics: expectedMetrics{1, 0},
},
{ {
name: "nop", name: "nop",
pods: []*v1.Pod{func() *v1.Pod { pods: []*v1.Pod{func() *v1.Pod {
@ -153,7 +178,7 @@ func TestSyncHandler(t *testing.T) {
pods: []*v1.Pod{testPodWithResource}, pods: []*v1.Pod{testPodWithResource},
templates: nil, templates: nil,
key: podKey(testPodWithResource), key: podKey(testPodWithResource),
expectedError: true, expectedError: "resource claim template \"my-template\": resourceclaimtemplate.resource.k8s.io \"my-template\" not found",
}, },
{ {
name: "find-existing-claim-by-label", name: "find-existing-claim-by-label",
@ -219,7 +244,7 @@ func TestSyncHandler(t *testing.T) {
key: podKey(testPodWithResource), key: podKey(testPodWithResource),
claims: []*resourceapi.ResourceClaim{conflictingClaim}, claims: []*resourceapi.ResourceClaim{conflictingClaim},
expectedClaims: []resourceapi.ResourceClaim{*conflictingClaim}, expectedClaims: []resourceapi.ResourceClaim{*conflictingClaim},
expectedError: true, expectedError: "resource claim template \"my-template\": resourceclaimtemplate.resource.k8s.io \"my-template\" not found",
}, },
{ {
name: "create-conflict", name: "create-conflict",
@ -227,7 +252,7 @@ func TestSyncHandler(t *testing.T) {
templates: []*resourceapi.ResourceClaimTemplate{template}, templates: []*resourceapi.ResourceClaimTemplate{template},
key: podKey(testPodWithResource), key: podKey(testPodWithResource),
expectedMetrics: expectedMetrics{1, 1}, expectedMetrics: expectedMetrics{1, 1},
expectedError: true, expectedError: "create ResourceClaim : Operation cannot be fulfilled on resourceclaims.resource.k8s.io \"fake name\": fake conflict",
}, },
{ {
name: "stay-reserved-seen", name: "stay-reserved-seen",
@ -424,11 +449,12 @@ func TestSyncHandler(t *testing.T) {
} }
err = ec.syncHandler(tCtx, tc.key) err = ec.syncHandler(tCtx, tc.key)
if err != nil && !tc.expectedError { if err != nil {
t.Fatalf("unexpected error while running handler: %v", err) assert.ErrorContains(t, err, tc.expectedError, "the error message should have contained the expected error message")
return
} }
if err == nil && tc.expectedError { if tc.expectedError != "" {
t.Fatalf("unexpected success") t.Fatalf("expected error, got none")
} }
claims, err := fakeKubeClient.ResourceV1beta1().ResourceClaims("").List(tCtx, metav1.ListOptions{}) claims, err := fakeKubeClient.ResourceV1beta1().ResourceClaims("").List(tCtx, metav1.ListOptions{})
@ -558,7 +584,7 @@ func makeClaim(name, namespace, classname string, owner *metav1.OwnerReference)
return claim return claim
} }
func makeGeneratedClaim(podClaimName, generateName, namespace, classname string, createCounter int, owner *metav1.OwnerReference) *resourceapi.ResourceClaim { func makeGeneratedClaim(podClaimName, generateName, namespace, classname string, createCounter int, owner *metav1.OwnerReference, adminAccess *bool) *resourceapi.ResourceClaim {
claim := &resourceapi.ResourceClaim{ claim := &resourceapi.ResourceClaim{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-%d", generateName, createCounter), Name: fmt.Sprintf("%s-%d", generateName, createCounter),
@ -570,6 +596,19 @@ func makeGeneratedClaim(podClaimName, generateName, namespace, classname string,
if owner != nil { if owner != nil {
claim.OwnerReferences = []metav1.OwnerReference{*owner} claim.OwnerReferences = []metav1.OwnerReference{*owner}
} }
if adminAccess != nil {
claim.Spec = resourceapi.ResourceClaimSpec{
Devices: resourceapi.DeviceClaim{
Requests: []resourceapi.DeviceRequest{
{
Name: "req-0",
DeviceClassName: "class",
AdminAccess: adminAccess,
},
},
},
}
}
return claim return claim
} }
@ -618,10 +657,25 @@ func makePod(name, namespace string, uid types.UID, podClaims ...v1.PodResourceC
return pod return pod
} }
func makeTemplate(name, namespace, classname string) *resourceapi.ResourceClaimTemplate { func makeTemplate(name, namespace, classname string, adminAccess *bool) *resourceapi.ResourceClaimTemplate {
template := &resourceapi.ResourceClaimTemplate{ template := &resourceapi.ResourceClaimTemplate{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
} }
if adminAccess != nil {
template.Spec = resourceapi.ResourceClaimTemplateSpec{
Spec: resourceapi.ResourceClaimSpec{
Devices: resourceapi.DeviceClaim{
Requests: []resourceapi.DeviceRequest{
{
Name: "req-0",
DeviceClassName: "class",
AdminAccess: adminAccess,
},
},
},
},
}
}
return template return template
} }

View File

@ -63,7 +63,6 @@ import (
genericapiserver "k8s.io/apiserver/pkg/server" genericapiserver "k8s.io/apiserver/pkg/server"
serverstorage "k8s.io/apiserver/pkg/server/storage" serverstorage "k8s.io/apiserver/pkg/server/storage"
utilfeature "k8s.io/apiserver/pkg/util/feature" utilfeature "k8s.io/apiserver/pkg/util/feature"
clientdiscovery "k8s.io/client-go/discovery"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1" corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
discoveryclient "k8s.io/client-go/kubernetes/typed/discovery/v1" discoveryclient "k8s.io/client-go/kubernetes/typed/discovery/v1"
@ -328,7 +327,7 @@ func (c CompletedConfig) New(delegationTarget genericapiserver.DelegationTarget)
return nil, err return nil, err
} }
restStorageProviders, err := c.StorageProviders(client.Discovery()) restStorageProviders, err := c.StorageProviders(client)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -379,7 +378,7 @@ func (c CompletedConfig) New(delegationTarget genericapiserver.DelegationTarget)
} }
func (c CompletedConfig) StorageProviders(discovery clientdiscovery.DiscoveryInterface) ([]controlplaneapiserver.RESTStorageProvider, error) { func (c CompletedConfig) StorageProviders(client *kubernetes.Clientset) ([]controlplaneapiserver.RESTStorageProvider, error) {
legacyRESTStorageProvider, err := corerest.New(corerest.Config{ legacyRESTStorageProvider, err := corerest.New(corerest.Config{
GenericConfig: *c.ControlPlane.NewCoreGenericConfig(), GenericConfig: *c.ControlPlane.NewCoreGenericConfig(),
Proxy: corerest.ProxyConfig{ Proxy: corerest.ProxyConfig{
@ -425,9 +424,9 @@ func (c CompletedConfig) StorageProviders(discovery clientdiscovery.DiscoveryInt
// keep apps after extensions so legacy clients resolve the extensions versions of shared resource names. // keep apps after extensions so legacy clients resolve the extensions versions of shared resource names.
// See https://github.com/kubernetes/kubernetes/issues/42392 // See https://github.com/kubernetes/kubernetes/issues/42392
appsrest.StorageProvider{}, appsrest.StorageProvider{},
admissionregistrationrest.RESTStorageProvider{Authorizer: c.ControlPlane.Generic.Authorization.Authorizer, DiscoveryClient: discovery}, admissionregistrationrest.RESTStorageProvider{Authorizer: c.ControlPlane.Generic.Authorization.Authorizer, DiscoveryClient: client.Discovery()},
eventsrest.RESTStorageProvider{TTL: c.ControlPlane.EventTTL}, eventsrest.RESTStorageProvider{TTL: c.ControlPlane.EventTTL},
resourcerest.RESTStorageProvider{}, resourcerest.RESTStorageProvider{NamespaceClient: client.CoreV1().Namespaces()},
}, nil }, nil
} }

View File

@ -490,7 +490,7 @@ func TestGenericStorageProviders(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
kube, err := completed.StorageProviders(client.Discovery()) kube, err := completed.StorageProviders(client)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -18,12 +18,14 @@ package storage
import ( import (
"context" "context"
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/generic"
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
"k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/registry/rest"
"k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/kubernetes/pkg/apis/resource" "k8s.io/kubernetes/pkg/apis/resource"
"k8s.io/kubernetes/pkg/printers" "k8s.io/kubernetes/pkg/printers"
printersinternal "k8s.io/kubernetes/pkg/printers/internalversion" printersinternal "k8s.io/kubernetes/pkg/printers/internalversion"
@ -38,7 +40,11 @@ type REST struct {
} }
// NewREST returns a RESTStorage object that will work against ResourceClaims. // NewREST returns a RESTStorage object that will work against ResourceClaims.
func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, *StatusREST, error) { func NewREST(optsGetter generic.RESTOptionsGetter, nsClient v1.NamespaceInterface) (*REST, *StatusREST, error) {
if nsClient == nil {
return nil, nil, fmt.Errorf("namespace client is required")
}
strategy := resourceclaim.NewStrategy(nsClient)
store := &genericregistry.Store{ store := &genericregistry.Store{
NewFunc: func() runtime.Object { return &resource.ResourceClaim{} }, NewFunc: func() runtime.Object { return &resource.ResourceClaim{} },
NewListFunc: func() runtime.Object { return &resource.ResourceClaimList{} }, NewListFunc: func() runtime.Object { return &resource.ResourceClaimList{} },
@ -46,11 +52,11 @@ func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, *StatusREST, error) {
DefaultQualifiedResource: resource.Resource("resourceclaims"), DefaultQualifiedResource: resource.Resource("resourceclaims"),
SingularQualifiedResource: resource.Resource("resourceclaim"), SingularQualifiedResource: resource.Resource("resourceclaim"),
CreateStrategy: resourceclaim.Strategy, CreateStrategy: strategy,
UpdateStrategy: resourceclaim.Strategy, UpdateStrategy: strategy,
DeleteStrategy: resourceclaim.Strategy, DeleteStrategy: strategy,
ReturnDeletedObject: true, ReturnDeletedObject: true,
ResetFieldsStrategy: resourceclaim.Strategy, ResetFieldsStrategy: strategy,
TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)}, TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)},
} }
@ -60,8 +66,9 @@ func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, *StatusREST, error) {
} }
statusStore := *store statusStore := *store
statusStore.UpdateStrategy = resourceclaim.StatusStrategy statusStrategy := resourceclaim.NewStatusStrategy(strategy)
statusStore.ResetFieldsStrategy = resourceclaim.StatusStrategy statusStore.UpdateStrategy = statusStrategy
statusStore.ResetFieldsStrategy = statusStrategy
rest := &REST{store} rest := &REST{store}

View File

@ -30,6 +30,7 @@ import (
genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing" genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing"
"k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/registry/rest"
etcd3testing "k8s.io/apiserver/pkg/storage/etcd3/testing" etcd3testing "k8s.io/apiserver/pkg/storage/etcd3/testing"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/kubernetes/pkg/apis/resource" "k8s.io/kubernetes/pkg/apis/resource"
_ "k8s.io/kubernetes/pkg/apis/resource/install" _ "k8s.io/kubernetes/pkg/apis/resource/install"
"k8s.io/kubernetes/pkg/registry/registrytest" "k8s.io/kubernetes/pkg/registry/registrytest"
@ -43,7 +44,9 @@ func newStorage(t *testing.T) (*REST, *StatusREST, *etcd3testing.EtcdTestServer)
DeleteCollectionWorkers: 1, DeleteCollectionWorkers: 1,
ResourcePrefix: "resourceclaims", ResourcePrefix: "resourceclaims",
} }
resourceClaimStorage, statusStorage, err := NewREST(restOptions) fakeClient := fake.NewSimpleClientset()
mockNSClient := fakeClient.CoreV1().Namespaces()
resourceClaimStorage, statusStorage, err := NewREST(restOptions, mockNSClient)
if err != nil { if err != nil {
t.Fatalf("unexpected error from REST storage: %v", err) t.Fatalf("unexpected error from REST storage: %v", err)
} }

View File

@ -30,11 +30,13 @@ import (
"k8s.io/apiserver/pkg/storage" "k8s.io/apiserver/pkg/storage"
"k8s.io/apiserver/pkg/storage/names" "k8s.io/apiserver/pkg/storage/names"
utilfeature "k8s.io/apiserver/pkg/util/feature" utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/dynamic-resource-allocation/structured" "k8s.io/dynamic-resource-allocation/structured"
"k8s.io/kubernetes/pkg/api/legacyscheme" "k8s.io/kubernetes/pkg/api/legacyscheme"
"k8s.io/kubernetes/pkg/apis/resource" "k8s.io/kubernetes/pkg/apis/resource"
"k8s.io/kubernetes/pkg/apis/resource/validation" "k8s.io/kubernetes/pkg/apis/resource/validation"
"k8s.io/kubernetes/pkg/features" "k8s.io/kubernetes/pkg/features"
resourceutils "k8s.io/kubernetes/pkg/registry/resource"
"sigs.k8s.io/structured-merge-diff/v4/fieldpath" "sigs.k8s.io/structured-merge-diff/v4/fieldpath"
) )
@ -42,20 +44,26 @@ import (
type resourceclaimStrategy struct { type resourceclaimStrategy struct {
runtime.ObjectTyper runtime.ObjectTyper
names.NameGenerator names.NameGenerator
nsClient v1.NamespaceInterface
} }
// Strategy is the default logic that applies when creating and updating // NewStrategy is the default logic that applies when creating and updating ResourceClaim objects.
// ResourceClaim objects via the REST API. func NewStrategy(nsClient v1.NamespaceInterface) *resourceclaimStrategy {
var Strategy = resourceclaimStrategy{legacyscheme.Scheme, names.SimpleNameGenerator} return &resourceclaimStrategy{
legacyscheme.Scheme,
names.SimpleNameGenerator,
nsClient,
}
}
func (resourceclaimStrategy) NamespaceScoped() bool { func (*resourceclaimStrategy) NamespaceScoped() bool {
return true return true
} }
// GetResetFields returns the set of fields that get reset by the strategy and // GetResetFields returns the set of fields that get reset by the strategy and
// should not be modified by the user. For a new ResourceClaim that is the // should not be modified by the user. For a new ResourceClaim that is the
// status. // status.
func (resourceclaimStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { func (*resourceclaimStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set {
fields := map[fieldpath.APIVersion]*fieldpath.Set{ fields := map[fieldpath.APIVersion]*fieldpath.Set{
"resource.k8s.io/v1alpha3": fieldpath.NewSet( "resource.k8s.io/v1alpha3": fieldpath.NewSet(
fieldpath.MakePathOrDie("status"), fieldpath.MakePathOrDie("status"),
@ -68,7 +76,7 @@ func (resourceclaimStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpat
return fields return fields
} }
func (resourceclaimStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) { func (*resourceclaimStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
claim := obj.(*resource.ResourceClaim) claim := obj.(*resource.ResourceClaim)
// Status must not be set by user on create. // Status must not be set by user on create.
claim.Status = resource.ResourceClaimStatus{} claim.Status = resource.ResourceClaimStatus{}
@ -76,23 +84,25 @@ func (resourceclaimStrategy) PrepareForCreate(ctx context.Context, obj runtime.O
dropDisabledFields(claim, nil) dropDisabledFields(claim, nil)
} }
func (resourceclaimStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList { func (s *resourceclaimStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList {
claim := obj.(*resource.ResourceClaim) claim := obj.(*resource.ResourceClaim)
return validation.ValidateResourceClaim(claim)
allErrs := resourceutils.AuthorizedForAdmin(ctx, claim.Spec.Devices.Requests, claim.Namespace, s.nsClient)
return append(allErrs, validation.ValidateResourceClaim(claim)...)
} }
func (resourceclaimStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string { func (*resourceclaimStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string {
return nil return nil
} }
func (resourceclaimStrategy) Canonicalize(obj runtime.Object) { func (*resourceclaimStrategy) Canonicalize(obj runtime.Object) {
} }
func (resourceclaimStrategy) AllowCreateOnUpdate() bool { func (*resourceclaimStrategy) AllowCreateOnUpdate() bool {
return false return false
} }
func (resourceclaimStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { func (*resourceclaimStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) {
newClaim := obj.(*resource.ResourceClaim) newClaim := obj.(*resource.ResourceClaim)
oldClaim := old.(*resource.ResourceClaim) oldClaim := old.(*resource.ResourceClaim)
newClaim.Status = oldClaim.Status newClaim.Status = oldClaim.Status
@ -100,30 +110,34 @@ func (resourceclaimStrategy) PrepareForUpdate(ctx context.Context, obj, old runt
dropDisabledFields(newClaim, oldClaim) dropDisabledFields(newClaim, oldClaim)
} }
func (resourceclaimStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { func (s *resourceclaimStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList {
newClaim := obj.(*resource.ResourceClaim) newClaim := obj.(*resource.ResourceClaim)
oldClaim := old.(*resource.ResourceClaim) oldClaim := old.(*resource.ResourceClaim)
// AuthorizedForAdmin isn't needed here because the spec is immutable.
errorList := validation.ValidateResourceClaim(newClaim) errorList := validation.ValidateResourceClaim(newClaim)
return append(errorList, validation.ValidateResourceClaimUpdate(newClaim, oldClaim)...) return append(errorList, validation.ValidateResourceClaimUpdate(newClaim, oldClaim)...)
} }
func (resourceclaimStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { func (*resourceclaimStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string {
return nil return nil
} }
func (resourceclaimStrategy) AllowUnconditionalUpdate() bool { func (*resourceclaimStrategy) AllowUnconditionalUpdate() bool {
return true return true
} }
type resourceclaimStatusStrategy struct { type resourceclaimStatusStrategy struct {
resourceclaimStrategy *resourceclaimStrategy
} }
var StatusStrategy = resourceclaimStatusStrategy{Strategy} // NewStatusStrategy creates a strategy for operating the status object.
func NewStatusStrategy(resourceclaimStrategy *resourceclaimStrategy) *resourceclaimStatusStrategy {
return &resourceclaimStatusStrategy{resourceclaimStrategy}
}
// GetResetFields returns the set of fields that get reset by the strategy and // GetResetFields returns the set of fields that get reset by the strategy and
// should not be modified by the user. For a status update that is the spec. // should not be modified by the user. For a status update that is the spec.
func (resourceclaimStatusStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { func (*resourceclaimStatusStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set {
fields := map[fieldpath.APIVersion]*fieldpath.Set{ fields := map[fieldpath.APIVersion]*fieldpath.Set{
"resource.k8s.io/v1alpha3": fieldpath.NewSet( "resource.k8s.io/v1alpha3": fieldpath.NewSet(
fieldpath.MakePathOrDie("spec"), fieldpath.MakePathOrDie("spec"),
@ -136,7 +150,7 @@ func (resourceclaimStatusStrategy) GetResetFields() map[fieldpath.APIVersion]*fi
return fields return fields
} }
func (resourceclaimStatusStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { func (*resourceclaimStatusStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) {
newClaim := obj.(*resource.ResourceClaim) newClaim := obj.(*resource.ResourceClaim)
oldClaim := old.(*resource.ResourceClaim) oldClaim := old.(*resource.ResourceClaim)
newClaim.Spec = oldClaim.Spec newClaim.Spec = oldClaim.Spec
@ -146,14 +160,22 @@ func (resourceclaimStatusStrategy) PrepareForUpdate(ctx context.Context, obj, ol
dropDisabledFields(newClaim, oldClaim) dropDisabledFields(newClaim, oldClaim)
} }
func (resourceclaimStatusStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { func (r *resourceclaimStatusStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList {
newClaim := obj.(*resource.ResourceClaim) newClaim := obj.(*resource.ResourceClaim)
oldClaim := old.(*resource.ResourceClaim) oldClaim := old.(*resource.ResourceClaim)
return validation.ValidateResourceClaimStatusUpdate(newClaim, oldClaim) var newAllocationResult, oldAllocationResult []resource.DeviceRequestAllocationResult
if newClaim.Status.Allocation != nil {
newAllocationResult = newClaim.Status.Allocation.Devices.Results
}
if oldClaim.Status.Allocation != nil {
oldAllocationResult = oldClaim.Status.Allocation.Devices.Results
}
allErrs := resourceutils.AuthorizedForAdminStatus(ctx, newAllocationResult, oldAllocationResult, newClaim.Namespace, r.nsClient)
return append(allErrs, validation.ValidateResourceClaimStatusUpdate(newClaim, oldClaim)...)
} }
// WarningsOnUpdate returns warnings for the given update. // WarningsOnUpdate returns warnings for the given update.
func (resourceclaimStatusStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { func (*resourceclaimStatusStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string {
return nil return nil
} }

View File

@ -21,9 +21,12 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request" genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
utilfeature "k8s.io/apiserver/pkg/util/feature" 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" featuregatetesting "k8s.io/component-base/featuregate/testing"
"k8s.io/kubernetes/pkg/apis/resource" "k8s.io/kubernetes/pkg/apis/resource"
"k8s.io/kubernetes/pkg/features" "k8s.io/kubernetes/pkg/features"
@ -33,7 +36,7 @@ import (
var obj = &resource.ResourceClaim{ var obj = &resource.ResourceClaim{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "valid-claim", Name: "valid-claim",
Namespace: "default", Namespace: "kube-system",
}, },
Spec: resource.ResourceClaimSpec{ Spec: resource.ResourceClaimSpec{
Devices: resource.DeviceClaim{ Devices: resource.DeviceClaim{
@ -51,7 +54,7 @@ var obj = &resource.ResourceClaim{
var objWithStatus = &resource.ResourceClaim{ var objWithStatus = &resource.ResourceClaim{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "valid-claim", Name: "valid-claim",
Namespace: "default", Namespace: "kube-system",
}, },
Spec: resource.ResourceClaimSpec{ Spec: resource.ResourceClaimSpec{
Devices: resource.DeviceClaim{ Devices: resource.DeviceClaim{
@ -81,6 +84,43 @@ var objWithStatus = &resource.ResourceClaim{
} }
var objWithAdminAccess = &resource.ResourceClaim{ var objWithAdminAccess = &resource.ResourceClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "valid-claim",
Namespace: "kube-system",
},
Spec: resource.ResourceClaimSpec{
Devices: resource.DeviceClaim{
Requests: []resource.DeviceRequest{
{
Name: "req-0",
DeviceClassName: "class",
AllocationMode: resource.DeviceAllocationModeAll,
AdminAccess: ptr.To(true),
},
},
},
},
}
var objInNonAdminNamespace = &resource.ResourceClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "valid-claim",
Namespace: "default",
},
Spec: resource.ResourceClaimSpec{
Devices: resource.DeviceClaim{
Requests: []resource.DeviceRequest{
{
Name: "req-0",
DeviceClassName: "class",
AllocationMode: resource.DeviceAllocationModeAll,
},
},
},
},
}
var objWithAdminAccessInNonAdminNamespace = &resource.ResourceClaim{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "valid-claim", Name: "valid-claim",
Namespace: "default", Namespace: "default",
@ -99,7 +139,38 @@ var objWithAdminAccess = &resource.ResourceClaim{
}, },
} }
var objWithAdminAccessStatus = &resource.ResourceClaim{ var objStatusInNonAdminNamespace = &resource.ResourceClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "valid-claim",
Namespace: "default",
},
Spec: resource.ResourceClaimSpec{
Devices: resource.DeviceClaim{
Requests: []resource.DeviceRequest{
{
Name: "req-0",
DeviceClassName: "class",
AllocationMode: resource.DeviceAllocationModeAll,
},
},
},
},
Status: resource.ResourceClaimStatus{
Allocation: &resource.AllocationResult{
Devices: resource.DeviceAllocationResult{
Results: []resource.DeviceRequestAllocationResult{
{
Request: "req-0",
Driver: "dra.example.com",
Pool: "pool-0",
Device: "device-0",
},
},
},
},
},
}
var objWithAdminAccessStatusInNonAdminNamespace = &resource.ResourceClaim{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "valid-claim", Name: "valid-claim",
Namespace: "default", Namespace: "default",
@ -111,7 +182,6 @@ var objWithAdminAccessStatus = &resource.ResourceClaim{
Name: "req-0", Name: "req-0",
DeviceClassName: "class", DeviceClassName: "class",
AllocationMode: resource.DeviceAllocationModeAll, AllocationMode: resource.DeviceAllocationModeAll,
AdminAccess: ptr.To(true),
}, },
}, },
}, },
@ -157,6 +227,58 @@ var objWithPrioritizedList = &resource.ResourceClaim{
}, },
} }
var objWithAdminAccessStatus = &resource.ResourceClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "valid-claim",
Namespace: "kube-system",
},
Spec: resource.ResourceClaimSpec{
Devices: resource.DeviceClaim{
Requests: []resource.DeviceRequest{
{
Name: "req-0",
DeviceClassName: "class",
AllocationMode: resource.DeviceAllocationModeAll,
AdminAccess: ptr.To(true),
},
},
},
},
Status: resource.ResourceClaimStatus{
Allocation: &resource.AllocationResult{
Devices: resource.DeviceAllocationResult{
Results: []resource.DeviceRequestAllocationResult{
{
Request: "req-0",
Driver: "dra.example.com",
Pool: "pool-0",
Device: "device-0",
AdminAccess: ptr.To(true),
},
},
},
},
},
}
var ns1 = &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
Labels: map[string]string{"key": "value"},
},
}
var ns2 = &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "kube-system",
Labels: map[string]string{resource.DRAAdminNamespaceLabelKey: "true"},
},
}
var adminAccessError = "Forbidden: admin access to devices requires the `resource.k8s.io/admin-access: true` label"
var fieldImmutableError = "field is immutable"
var metadataError = "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters"
var deviceRequestError = "exactly one of `deviceClassName` or `firstAvailable` must be specified"
const ( const (
testRequest = "test-request" testRequest = "test-request"
testDriver = "test-driver" testDriver = "test-driver"
@ -165,27 +287,35 @@ const (
) )
func TestStrategy(t *testing.T) { func TestStrategy(t *testing.T) {
if !Strategy.NamespaceScoped() { fakeClient := fake.NewSimpleClientset()
mockNSClient := fakeClient.CoreV1().Namespaces()
strategy := NewStrategy(mockNSClient)
if !strategy.NamespaceScoped() {
t.Errorf("ResourceClaim must be namespace scoped") t.Errorf("ResourceClaim must be namespace scoped")
} }
if Strategy.AllowCreateOnUpdate() { if strategy.AllowCreateOnUpdate() {
t.Errorf("ResourceClaim should not allow create on update") t.Errorf("ResourceClaim should not allow create on update")
} }
} }
func TestStrategyCreate(t *testing.T) { func TestStrategyCreate(t *testing.T) {
ctx := genericapirequest.NewDefaultContext() ctx := genericapirequest.NewDefaultContext()
testcases := map[string]struct { testcases := map[string]struct {
obj *resource.ResourceClaim obj *resource.ResourceClaim
adminAccess bool adminAccess bool
prioritizedList bool prioritizedList bool
expectValidationError bool expectValidationError string
expectObj *resource.ResourceClaim expectObj *resource.ResourceClaim
verify func(*testing.T, []testclient.Action)
}{ }{
"simple": { "simple": {
obj: obj, obj: obj,
expectObj: obj, expectObj: obj,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"validation-error": { "validation-error": {
obj: func() *resource.ResourceClaim { obj: func() *resource.ResourceClaim {
@ -193,69 +323,139 @@ func TestStrategyCreate(t *testing.T) {
obj.Name = "%#@$%$" obj.Name = "%#@$%$"
return obj return obj
}(), }(),
expectValidationError: true, expectValidationError: metadataError,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"drop-fields-admin-access": { "drop-fields-admin-access": {
obj: objWithAdminAccess, obj: objWithAdminAccess,
adminAccess: false, adminAccess: false,
expectObj: obj, expectObj: obj,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-fields-admin-access": { "keep-fields-admin-access": {
obj: objWithAdminAccess, obj: objWithAdminAccess,
adminAccess: true, adminAccess: true,
expectObj: objWithAdminAccess, expectObj: objWithAdminAccess,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 1 {
t.Errorf("expected one action but got %d", len(as))
return
}
ns := as[0].(testclient.GetAction).GetName()
if ns != "kube-system" {
t.Errorf("expected to get the kube-system namespace but got '%s'", ns)
}
},
}, },
"drop-fields-prioritized-list": { "drop-fields-prioritized-list": {
obj: objWithPrioritizedList, obj: objWithPrioritizedList,
prioritizedList: false, prioritizedList: false,
expectValidationError: true, expectValidationError: deviceRequestError,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-fields-prioritized-list": { "keep-fields-prioritized-list": {
obj: objWithPrioritizedList, obj: objWithPrioritizedList,
prioritizedList: true, prioritizedList: true,
expectObj: objWithPrioritizedList, expectObj: objWithPrioritizedList,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
},
"admin-access-admin-namespace": {
obj: objWithAdminAccess,
adminAccess: true,
expectObj: objWithAdminAccess,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 1 {
t.Errorf("expected one action but got %d", len(as))
return
}
ns := as[0].(testclient.GetAction).GetName()
if ns != "kube-system" {
t.Errorf("expected to get the kube-system namespace but got '%s'", ns)
}
},
},
"admin-access-non-admin-namespace": {
obj: objWithAdminAccessInNonAdminNamespace,
adminAccess: true,
expectObj: objWithAdminAccessInNonAdminNamespace,
expectValidationError: adminAccessError,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 1 {
t.Errorf("expected one action but got %d", len(as))
return
}
ns := as[0].(testclient.GetAction).GetName()
if ns != "default" {
t.Errorf("expected to get the default namespace but got '%s'", ns)
}
},
}, },
} }
for name, tc := range testcases { for name, tc := range testcases {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
fakeClient := fake.NewSimpleClientset(ns1, ns2)
mockNSClient := fakeClient.CoreV1().Namespaces()
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAAdminAccess, tc.adminAccess) featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAAdminAccess, tc.adminAccess)
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAPrioritizedList, tc.prioritizedList) featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAPrioritizedList, tc.prioritizedList)
strategy := NewStrategy(mockNSClient)
obj := tc.obj.DeepCopy() obj := tc.obj.DeepCopy()
Strategy.PrepareForCreate(ctx, obj) strategy.PrepareForCreate(ctx, obj)
if errs := Strategy.Validate(ctx, obj); len(errs) != 0 { if errs := strategy.Validate(ctx, obj); len(errs) != 0 {
if !tc.expectValidationError { assert.ErrorContains(t, errs[0], tc.expectValidationError, "the error message should have contained the expected error message")
t.Fatalf("unexpected validation errors: %q", errs)
}
return return
} else if tc.expectValidationError { }
if tc.expectValidationError != "" {
t.Fatal("expected validation error(s), got none") t.Fatal("expected validation error(s), got none")
} }
if warnings := Strategy.WarningsOnCreate(ctx, obj); len(warnings) != 0 { if warnings := strategy.WarningsOnCreate(ctx, obj); len(warnings) != 0 {
t.Fatalf("unexpected warnings: %q", warnings) t.Fatalf("unexpected warnings: %q", warnings)
} }
Strategy.Canonicalize(obj) strategy.Canonicalize(obj)
assert.Equal(t, tc.expectObj, obj) assert.Equal(t, tc.expectObj, obj)
tc.verify(t, fakeClient.Actions())
}) })
} }
} }
func TestStrategyUpdate(t *testing.T) { func TestStrategyUpdate(t *testing.T) {
ctx := genericapirequest.NewDefaultContext() ctx := genericapirequest.NewDefaultContext()
testcases := map[string]struct { testcases := map[string]struct {
oldObj *resource.ResourceClaim oldObj *resource.ResourceClaim
newObj *resource.ResourceClaim newObj *resource.ResourceClaim
adminAccess bool adminAccess bool
expectValidationError string
prioritizedList bool prioritizedList bool
expectValidationError bool
expectObj *resource.ResourceClaim expectObj *resource.ResourceClaim
verify func(*testing.T, []testclient.Action)
}{ }{
"no-changes-okay": { "no-changes-okay": {
oldObj: obj, oldObj: obj,
newObj: obj, newObj: obj,
expectObj: obj, expectObj: obj,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"name-change-not-allowed": { "name-change-not-allowed": {
oldObj: obj, oldObj: obj,
@ -264,97 +464,167 @@ func TestStrategyUpdate(t *testing.T) {
obj.Name += "-2" obj.Name += "-2"
return obj return obj
}(), }(),
expectValidationError: true, expectValidationError: fieldImmutableError,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"drop-fields-admin-access": { "drop-fields-admin-access": {
oldObj: obj, oldObj: obj,
newObj: objWithAdminAccess, newObj: objWithAdminAccess,
adminAccess: false, adminAccess: false,
expectObj: obj, expectObj: obj,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-fields-admin-access": { "keep-fields-admin-access": {
oldObj: obj, oldObj: obj,
newObj: objWithAdminAccess, newObj: objWithAdminAccess,
adminAccess: true, adminAccess: true,
expectValidationError: true, // Spec is immutable. expectValidationError: fieldImmutableError, // Spec is immutable.
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-existing-fields-admin-access": { "keep-existing-fields-admin-access": {
oldObj: objWithAdminAccess, oldObj: objWithAdminAccess,
newObj: objWithAdminAccess, newObj: objWithAdminAccess,
adminAccess: true, adminAccess: true,
expectObj: objWithAdminAccess, expectObj: objWithAdminAccess,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
},
"admin-access-admin-namespace": {
oldObj: objWithAdminAccess,
newObj: objWithAdminAccess,
adminAccess: true,
expectObj: objWithAdminAccess,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
},
"admin-access-non-admin-namespace": {
oldObj: objInNonAdminNamespace,
newObj: objWithAdminAccessInNonAdminNamespace,
adminAccess: true,
expectValidationError: fieldImmutableError,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"drop-fields-prioritized-list": { "drop-fields-prioritized-list": {
oldObj: obj, oldObj: obj,
newObj: objWithPrioritizedList, newObj: objWithPrioritizedList,
prioritizedList: false, prioritizedList: false,
expectValidationError: true, expectValidationError: deviceRequestError,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-fields-prioritized-list": { "keep-fields-prioritized-list": {
oldObj: obj, oldObj: obj,
newObj: objWithPrioritizedList, newObj: objWithPrioritizedList,
prioritizedList: true, prioritizedList: true,
expectValidationError: true, // Spec is immutable. expectValidationError: fieldImmutableError, // Spec is immutable.
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-existing-fields-prioritized-list": { "keep-existing-fields-prioritized-list": {
oldObj: objWithPrioritizedList, oldObj: objWithPrioritizedList,
newObj: objWithPrioritizedList, newObj: objWithPrioritizedList,
prioritizedList: true, prioritizedList: true,
expectObj: objWithPrioritizedList, expectObj: objWithPrioritizedList,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-existing-fields-prioritized-list-disabled-feature": { "keep-existing-fields-prioritized-list-disabled-feature": {
oldObj: objWithPrioritizedList, oldObj: objWithPrioritizedList,
newObj: objWithPrioritizedList, newObj: objWithPrioritizedList,
prioritizedList: false, prioritizedList: false,
expectObj: objWithPrioritizedList, expectObj: objWithPrioritizedList,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
} }
for name, tc := range testcases { for name, tc := range testcases {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
fakeClient := fake.NewSimpleClientset(ns1, ns2)
mockNSClient := fakeClient.CoreV1().Namespaces()
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAAdminAccess, tc.adminAccess) featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAAdminAccess, tc.adminAccess)
strategy := NewStrategy(mockNSClient)
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAPrioritizedList, tc.prioritizedList) featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAPrioritizedList, tc.prioritizedList)
oldObj := tc.oldObj.DeepCopy() oldObj := tc.oldObj.DeepCopy()
newObj := tc.newObj.DeepCopy() newObj := tc.newObj.DeepCopy()
newObj.ResourceVersion = "4" newObj.ResourceVersion = "4"
Strategy.PrepareForUpdate(ctx, newObj, oldObj) strategy.PrepareForUpdate(ctx, newObj, oldObj)
if errs := Strategy.ValidateUpdate(ctx, newObj, oldObj); len(errs) != 0 { if errs := strategy.ValidateUpdate(ctx, newObj, oldObj); len(errs) != 0 {
if !tc.expectValidationError { assert.ErrorContains(t, errs[0], tc.expectValidationError, "the error message should have contained the expected error message")
t.Fatalf("unexpected validation errors: %q", errs)
}
return return
} else if tc.expectValidationError { }
if tc.expectValidationError != "" {
t.Fatal("expected validation error(s), got none") t.Fatal("expected validation error(s), got none")
} }
if warnings := Strategy.WarningsOnUpdate(ctx, newObj, oldObj); len(warnings) != 0 { if warnings := strategy.WarningsOnUpdate(ctx, newObj, oldObj); len(warnings) != 0 {
t.Fatalf("unexpected warnings: %q", warnings) t.Fatalf("unexpected warnings: %q", warnings)
} }
Strategy.Canonicalize(newObj) strategy.Canonicalize(newObj)
expectObj := tc.expectObj.DeepCopy() expectObj := tc.expectObj.DeepCopy()
expectObj.ResourceVersion = "4" expectObj.ResourceVersion = "4"
assert.Equal(t, expectObj, newObj) assert.Equal(t, expectObj, newObj)
tc.verify(t, fakeClient.Actions())
}) })
} }
} }
func TestStatusStrategyUpdate(t *testing.T) { func TestStatusStrategyUpdate(t *testing.T) {
ctx := genericapirequest.NewDefaultContext() ctx := genericapirequest.NewDefaultContext()
testcases := map[string]struct { testcases := map[string]struct {
oldObj *resource.ResourceClaim oldObj *resource.ResourceClaim
newObj *resource.ResourceClaim newObj *resource.ResourceClaim
adminAccess bool adminAccess bool
deviceStatusFeatureGate bool deviceStatusFeatureGate bool
expectValidationError bool expectValidationError string
expectObj *resource.ResourceClaim expectObj *resource.ResourceClaim
verify func(*testing.T, []testclient.Action)
}{ }{
"no-changes-okay": { "no-changes-okay": {
oldObj: obj, oldObj: obj,
newObj: obj, newObj: obj,
expectObj: obj, expectObj: obj,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"name-change-not-allowed": { "name-change-not-allowed": {
oldObj: obj, oldObj: obj,
@ -363,7 +633,12 @@ func TestStatusStrategyUpdate(t *testing.T) {
obj.Name += "-2" obj.Name += "-2"
return obj return obj
}(), }(),
expectValidationError: true, expectValidationError: fieldImmutableError,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
// Cannot add finalizers, annotations and labels during status update. // Cannot add finalizers, annotations and labels during status update.
"drop-meta-changes": { "drop-meta-changes": {
@ -376,12 +651,22 @@ func TestStatusStrategyUpdate(t *testing.T) {
return obj return obj
}(), }(),
expectObj: obj, expectObj: obj,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"drop-fields-admin-access": { "drop-fields-admin-access": {
oldObj: obj, oldObj: obj,
newObj: objWithAdminAccessStatus, newObj: objWithAdminAccessStatus,
adminAccess: false, adminAccess: false,
expectObj: objWithStatus, expectObj: objWithStatus,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-fields-admin-access": { "keep-fields-admin-access": {
oldObj: obj, oldObj: obj,
@ -393,12 +678,48 @@ func TestStatusStrategyUpdate(t *testing.T) {
expectObj.Spec = obj.Spec expectObj.Spec = obj.Spec
return expectObj return expectObj
}(), }(),
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 1 {
t.Errorf("expected one action but got %d", len(as))
return
}
ns := as[0].(testclient.GetAction).GetName()
if ns != "kube-system" {
t.Errorf("expected to get the kube-system namespace but got '%s'", ns)
}
},
},
"keep-fields-admin-access-NonAdminNamespace": {
oldObj: objStatusInNonAdminNamespace,
newObj: objWithAdminAccessStatusInNonAdminNamespace,
adminAccess: true,
expectValidationError: adminAccessError,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 1 {
t.Errorf("expected one action but got %d", len(as))
return
}
ns := as[0].(testclient.GetAction).GetName()
if ns != "default" {
t.Errorf("expected to get the default namespace but got '%s'", ns)
}
},
}, },
"keep-fields-admin-access-because-of-spec": { "keep-fields-admin-access-because-of-spec": {
oldObj: objWithAdminAccess, oldObj: objWithAdminAccess,
newObj: objWithAdminAccessStatus, newObj: objWithAdminAccessStatus,
adminAccess: false, adminAccess: false,
expectObj: objWithAdminAccessStatus, expectObj: objWithAdminAccessStatus,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 1 {
t.Errorf("expected one action but got %d", len(as))
return
}
ns := as[0].(testclient.GetAction).GetName()
if ns != "kube-system" {
t.Errorf("expected to get the kube-system namespace but got '%s'", ns)
}
},
}, },
// Normally a claim without admin access in the spec shouldn't // Normally a claim without admin access in the spec shouldn't
// have one in the status either, but it's not invalid and thus // have one in the status either, but it's not invalid and thus
@ -416,6 +737,11 @@ func TestStatusStrategyUpdate(t *testing.T) {
oldObj.Spec.Devices.Requests[0].AdminAccess = ptr.To(false) oldObj.Spec.Devices.Requests[0].AdminAccess = ptr.To(false)
return oldObj return oldObj
}(), }(),
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"drop-fields-devices-status": { "drop-fields-devices-status": {
oldObj: func() *resource.ResourceClaim { oldObj: func() *resource.ResourceClaim {
@ -438,6 +764,11 @@ func TestStatusStrategyUpdate(t *testing.T) {
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest) addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest)
return obj return obj
}(), }(),
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-fields-devices-status-disable-feature-gate": { "keep-fields-devices-status-disable-feature-gate": {
oldObj: func() *resource.ResourceClaim { oldObj: func() *resource.ResourceClaim {
@ -462,6 +793,11 @@ func TestStatusStrategyUpdate(t *testing.T) {
addStatusDevices(obj, testDriver, testPool, testDevice) addStatusDevices(obj, testDriver, testPool, testDevice)
return obj return obj
}(), }(),
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-fields-devices-status": { "keep-fields-devices-status": {
oldObj: func() *resource.ResourceClaim { oldObj: func() *resource.ResourceClaim {
@ -485,6 +821,11 @@ func TestStatusStrategyUpdate(t *testing.T) {
addStatusDevices(obj, testDriver, testPool, testDevice) addStatusDevices(obj, testDriver, testPool, testDevice)
return obj 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": { "drop-status-deallocated-device": {
oldObj: func() *resource.ResourceClaim { oldObj: func() *resource.ResourceClaim {
@ -506,6 +847,11 @@ func TestStatusStrategyUpdate(t *testing.T) {
addSpecDevicesRequest(obj, testRequest) addSpecDevicesRequest(obj, testRequest)
return obj 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": { "drop-status-deallocated-device-disable-feature-gate": {
oldObj: func() *resource.ResourceClaim { oldObj: func() *resource.ResourceClaim {
@ -527,35 +873,45 @@ func TestStatusStrategyUpdate(t *testing.T) {
addSpecDevicesRequest(obj, testRequest) addSpecDevicesRequest(obj, testRequest)
return obj return obj
}(), }(),
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
} }
for name, tc := range testcases { for name, tc := range testcases {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
fakeClient := fake.NewSimpleClientset(ns1, ns2)
mockNSClient := fakeClient.CoreV1().Namespaces()
strategy := NewStrategy(mockNSClient)
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAAdminAccess, tc.adminAccess) featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAAdminAccess, tc.adminAccess)
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAResourceClaimDeviceStatus, tc.deviceStatusFeatureGate) featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAResourceClaimDeviceStatus, tc.deviceStatusFeatureGate)
statusStrategy := NewStatusStrategy(strategy)
oldObj := tc.oldObj.DeepCopy() oldObj := tc.oldObj.DeepCopy()
newObj := tc.newObj.DeepCopy() newObj := tc.newObj.DeepCopy()
newObj.ResourceVersion = "4" newObj.ResourceVersion = "4"
StatusStrategy.PrepareForUpdate(ctx, newObj, oldObj) statusStrategy.PrepareForUpdate(ctx, newObj, oldObj)
if errs := StatusStrategy.ValidateUpdate(ctx, newObj, oldObj); len(errs) != 0 { if errs := statusStrategy.ValidateUpdate(ctx, newObj, oldObj); len(errs) != 0 {
if !tc.expectValidationError { assert.ErrorContains(t, errs[0], tc.expectValidationError, "the error message should have contained the expected error message")
t.Fatalf("unexpected validation errors: %q", errs)
}
return return
} else if tc.expectValidationError { }
if tc.expectValidationError != "" {
t.Fatal("expected validation error(s), got none") t.Fatal("expected validation error(s), got none")
} }
if warnings := StatusStrategy.WarningsOnUpdate(ctx, newObj, oldObj); len(warnings) != 0 { if warnings := statusStrategy.WarningsOnUpdate(ctx, newObj, oldObj); len(warnings) != 0 {
t.Fatalf("unexpected warnings: %q", warnings) t.Fatalf("unexpected warnings: %q", warnings)
} }
StatusStrategy.Canonicalize(newObj) statusStrategy.Canonicalize(newObj)
expectObj := tc.expectObj.DeepCopy() expectObj := tc.expectObj.DeepCopy()
expectObj.ResourceVersion = "4" expectObj.ResourceVersion = "4"
assert.Equal(t, expectObj, newObj) assert.Equal(t, expectObj, newObj)
tc.verify(t, fakeClient.Actions())
}) })
} }
} }

View File

@ -17,9 +17,12 @@ limitations under the License.
package storage package storage
import ( import (
"fmt"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/generic"
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
"k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/kubernetes/pkg/apis/resource" "k8s.io/kubernetes/pkg/apis/resource"
"k8s.io/kubernetes/pkg/printers" "k8s.io/kubernetes/pkg/printers"
printersinternal "k8s.io/kubernetes/pkg/printers/internalversion" printersinternal "k8s.io/kubernetes/pkg/printers/internalversion"
@ -33,16 +36,20 @@ type REST struct {
} }
// NewREST returns a RESTStorage object that will work against ResourceClass. // NewREST returns a RESTStorage object that will work against ResourceClass.
func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, error) { func NewREST(optsGetter generic.RESTOptionsGetter, nsClient v1.NamespaceInterface) (*REST, error) {
if nsClient == nil {
return nil, fmt.Errorf("namespace client is required")
}
strategy := resourceclaimtemplate.NewStrategy(nsClient)
store := &genericregistry.Store{ store := &genericregistry.Store{
NewFunc: func() runtime.Object { return &resource.ResourceClaimTemplate{} }, NewFunc: func() runtime.Object { return &resource.ResourceClaimTemplate{} },
NewListFunc: func() runtime.Object { return &resource.ResourceClaimTemplateList{} }, NewListFunc: func() runtime.Object { return &resource.ResourceClaimTemplateList{} },
DefaultQualifiedResource: resource.Resource("resourceclaimtemplates"), DefaultQualifiedResource: resource.Resource("resourceclaimtemplates"),
SingularQualifiedResource: resource.Resource("resourceclaimtemplate"), SingularQualifiedResource: resource.Resource("resourceclaimtemplate"),
CreateStrategy: resourceclaimtemplate.Strategy, CreateStrategy: strategy,
UpdateStrategy: resourceclaimtemplate.Strategy, UpdateStrategy: strategy,
DeleteStrategy: resourceclaimtemplate.Strategy, DeleteStrategy: strategy,
ReturnDeletedObject: true, ReturnDeletedObject: true,
TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)}, TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)},

View File

@ -26,6 +26,7 @@ import (
"k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/generic"
genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing" genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing"
etcd3testing "k8s.io/apiserver/pkg/storage/etcd3/testing" etcd3testing "k8s.io/apiserver/pkg/storage/etcd3/testing"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/kubernetes/pkg/apis/resource" "k8s.io/kubernetes/pkg/apis/resource"
_ "k8s.io/kubernetes/pkg/apis/resource/install" _ "k8s.io/kubernetes/pkg/apis/resource/install"
"k8s.io/kubernetes/pkg/registry/registrytest" "k8s.io/kubernetes/pkg/registry/registrytest"
@ -39,7 +40,9 @@ func newStorage(t *testing.T) (*REST, *etcd3testing.EtcdTestServer) {
DeleteCollectionWorkers: 1, DeleteCollectionWorkers: 1,
ResourcePrefix: "resourceclaimtemplates", ResourcePrefix: "resourceclaimtemplates",
} }
resourceClaimTemplateStorage, err := NewREST(restOptions) fakeClient := fake.NewSimpleClientset()
mockNSClient := fakeClient.CoreV1().Namespaces()
resourceClaimTemplateStorage, err := NewREST(restOptions, mockNSClient)
if err != nil { if err != nil {
t.Fatalf("unexpected error from REST storage: %v", err) t.Fatalf("unexpected error from REST storage: %v", err)
} }

View File

@ -27,60 +27,72 @@ import (
"k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/generic"
"k8s.io/apiserver/pkg/storage/names" "k8s.io/apiserver/pkg/storage/names"
utilfeature "k8s.io/apiserver/pkg/util/feature" utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/kubernetes/pkg/api/legacyscheme" "k8s.io/kubernetes/pkg/api/legacyscheme"
"k8s.io/kubernetes/pkg/apis/resource" "k8s.io/kubernetes/pkg/apis/resource"
"k8s.io/kubernetes/pkg/apis/resource/validation" "k8s.io/kubernetes/pkg/apis/resource/validation"
"k8s.io/kubernetes/pkg/features" "k8s.io/kubernetes/pkg/features"
resourceutils "k8s.io/kubernetes/pkg/registry/resource"
) )
// resourceClaimTemplateStrategy implements behavior for ResourceClaimTemplate objects // resourceClaimTemplateStrategy implements behavior for ResourceClaimTemplate objects
type resourceClaimTemplateStrategy struct { type resourceClaimTemplateStrategy struct {
runtime.ObjectTyper runtime.ObjectTyper
names.NameGenerator names.NameGenerator
nsClient v1.NamespaceInterface
} }
var Strategy = resourceClaimTemplateStrategy{legacyscheme.Scheme, names.SimpleNameGenerator} // NewStrategy is the default logic that applies when creating and updating ResourceClaimTemplate objects.
func NewStrategy(nsClient v1.NamespaceInterface) *resourceClaimTemplateStrategy {
return &resourceClaimTemplateStrategy{
legacyscheme.Scheme,
names.SimpleNameGenerator,
nsClient,
}
}
func (resourceClaimTemplateStrategy) NamespaceScoped() bool { func (*resourceClaimTemplateStrategy) NamespaceScoped() bool {
return true return true
} }
func (resourceClaimTemplateStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) { func (*resourceClaimTemplateStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
claimTemplate := obj.(*resource.ResourceClaimTemplate) claimTemplate := obj.(*resource.ResourceClaimTemplate)
dropDisabledFields(claimTemplate, nil) dropDisabledFields(claimTemplate, nil)
} }
func (resourceClaimTemplateStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList { func (s *resourceClaimTemplateStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList {
resourceClaimTemplate := obj.(*resource.ResourceClaimTemplate) resourceClaimTemplate := obj.(*resource.ResourceClaimTemplate)
return validation.ValidateResourceClaimTemplate(resourceClaimTemplate) allErrs := resourceutils.AuthorizedForAdmin(ctx, resourceClaimTemplate.Spec.Spec.Devices.Requests, resourceClaimTemplate.Namespace, s.nsClient)
return append(allErrs, validation.ValidateResourceClaimTemplate(resourceClaimTemplate)...)
} }
func (resourceClaimTemplateStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string { func (*resourceClaimTemplateStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string {
return nil return nil
} }
func (resourceClaimTemplateStrategy) Canonicalize(obj runtime.Object) { func (*resourceClaimTemplateStrategy) Canonicalize(obj runtime.Object) {
} }
func (resourceClaimTemplateStrategy) AllowCreateOnUpdate() bool { func (*resourceClaimTemplateStrategy) AllowCreateOnUpdate() bool {
return false return false
} }
func (resourceClaimTemplateStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) { func (*resourceClaimTemplateStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) {
claimTemplate, oldClaimTemplate := obj.(*resource.ResourceClaimTemplate), old.(*resource.ResourceClaimTemplate) claimTemplate, oldClaimTemplate := obj.(*resource.ResourceClaimTemplate), old.(*resource.ResourceClaimTemplate)
dropDisabledFields(claimTemplate, oldClaimTemplate) dropDisabledFields(claimTemplate, oldClaimTemplate)
} }
func (resourceClaimTemplateStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList { func (s *resourceClaimTemplateStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList {
// AuthorizedForAdmin isn't needed here because the spec is immutable.
errorList := validation.ValidateResourceClaimTemplate(obj.(*resource.ResourceClaimTemplate)) errorList := validation.ValidateResourceClaimTemplate(obj.(*resource.ResourceClaimTemplate))
return append(errorList, validation.ValidateResourceClaimTemplateUpdate(obj.(*resource.ResourceClaimTemplate), old.(*resource.ResourceClaimTemplate))...) return append(errorList, validation.ValidateResourceClaimTemplateUpdate(obj.(*resource.ResourceClaimTemplate), old.(*resource.ResourceClaimTemplate))...)
} }
func (resourceClaimTemplateStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string { func (*resourceClaimTemplateStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string {
return nil return nil
} }
func (resourceClaimTemplateStrategy) AllowUnconditionalUpdate() bool { func (*resourceClaimTemplateStrategy) AllowUnconditionalUpdate() bool {
return true return true
} }

View File

@ -21,9 +21,12 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request" genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
utilfeature "k8s.io/apiserver/pkg/util/feature" 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" featuregatetesting "k8s.io/component-base/featuregate/testing"
"k8s.io/kubernetes/pkg/apis/resource" "k8s.io/kubernetes/pkg/apis/resource"
"k8s.io/kubernetes/pkg/features" "k8s.io/kubernetes/pkg/features"
@ -33,7 +36,7 @@ import (
var obj = &resource.ResourceClaimTemplate{ var obj = &resource.ResourceClaimTemplate{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "valid-claim-template", Name: "valid-claim-template",
Namespace: "default", Namespace: "kube-system",
}, },
Spec: resource.ResourceClaimTemplateSpec{ Spec: resource.ResourceClaimTemplateSpec{
Spec: resource.ResourceClaimSpec{ Spec: resource.ResourceClaimSpec{
@ -51,6 +54,27 @@ var obj = &resource.ResourceClaimTemplate{
} }
var objWithAdminAccess = &resource.ResourceClaimTemplate{ var objWithAdminAccess = &resource.ResourceClaimTemplate{
ObjectMeta: metav1.ObjectMeta{
Name: "valid-claim-template",
Namespace: "kube-system",
},
Spec: resource.ResourceClaimTemplateSpec{
Spec: resource.ResourceClaimSpec{
Devices: resource.DeviceClaim{
Requests: []resource.DeviceRequest{
{
Name: "req-0",
DeviceClassName: "class",
AllocationMode: resource.DeviceAllocationModeAll,
AdminAccess: ptr.To(true),
},
},
},
},
},
}
var objWithAdminAccessInNonAdminNamespace = &resource.ResourceClaimTemplate{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "valid-claim-template", Name: "valid-claim-template",
Namespace: "default", Namespace: "default",
@ -97,11 +121,32 @@ var objWithPrioritizedList = &resource.ResourceClaimTemplate{
}, },
} }
var ns1 = &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
Labels: map[string]string{"key": "value"},
},
}
var ns2 = &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "kube-system",
Labels: map[string]string{resource.DRAAdminNamespaceLabelKey: "true"},
},
}
var adminAccessError = "Forbidden: admin access to devices requires the `resource.k8s.io/admin-access: true` label on the containing namespace"
var fieldImmutableError = "field is immutable"
var metadataError = "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters"
var deviceRequestError = "exactly one of `deviceClassName` or `firstAvailable` must be specified"
func TestClaimTemplateStrategy(t *testing.T) { func TestClaimTemplateStrategy(t *testing.T) {
if !Strategy.NamespaceScoped() { fakeClient := fake.NewSimpleClientset()
mockNSClient := fakeClient.CoreV1().Namespaces()
strategy := NewStrategy(mockNSClient)
if !strategy.NamespaceScoped() {
t.Errorf("ResourceClaimTemplate must be namespace scoped") t.Errorf("ResourceClaimTemplate must be namespace scoped")
} }
if Strategy.AllowCreateOnUpdate() { if strategy.AllowCreateOnUpdate() {
t.Errorf("ResourceClaimTemplate should not allow create on update") t.Errorf("ResourceClaimTemplate should not allow create on update")
} }
} }
@ -112,13 +157,19 @@ func TestClaimTemplateStrategyCreate(t *testing.T) {
testcases := map[string]struct { testcases := map[string]struct {
obj *resource.ResourceClaimTemplate obj *resource.ResourceClaimTemplate
adminAccess bool adminAccess bool
expectValidationError string
prioritizedList bool prioritizedList bool
expectValidationError bool
expectObj *resource.ResourceClaimTemplate expectObj *resource.ResourceClaimTemplate
verify func(*testing.T, []testclient.Action)
}{ }{
"simple": { "simple": {
obj: obj, obj: obj,
expectObj: obj, expectObj: obj,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"validation-error": { "validation-error": {
obj: func() *resource.ResourceClaimTemplate { obj: func() *resource.ResourceClaimTemplate {
@ -126,50 +177,114 @@ func TestClaimTemplateStrategyCreate(t *testing.T) {
obj.Name = "%#@$%$" obj.Name = "%#@$%$"
return obj return obj
}(), }(),
expectValidationError: true, expectValidationError: metadataError,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"drop-fields-admin-access": { "drop-fields-admin-access": {
obj: objWithAdminAccess, obj: objWithAdminAccess,
adminAccess: false, adminAccess: false,
expectObj: obj, expectObj: obj,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-fields-admin-access": { "keep-fields-admin-access": {
obj: objWithAdminAccess, obj: objWithAdminAccess,
adminAccess: true, adminAccess: true,
expectObj: objWithAdminAccess, expectObj: objWithAdminAccess,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 1 {
t.Errorf("expected one action but got %d", len(as))
return
}
ns := as[0].(testclient.GetAction).GetName()
if ns != "kube-system" {
t.Errorf("expected to get the kube-system namespace but got '%s'", ns)
}
},
}, },
"drop-fields-prioritized-list": { "drop-fields-prioritized-list": {
obj: objWithPrioritizedList, obj: objWithPrioritizedList,
prioritizedList: false, prioritizedList: false,
expectValidationError: true, expectValidationError: deviceRequestError,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
}, },
"keep-fields-prioritized-list": { "keep-fields-prioritized-list": {
obj: objWithPrioritizedList, obj: objWithPrioritizedList,
prioritizedList: true, prioritizedList: true,
expectObj: objWithPrioritizedList, expectObj: objWithPrioritizedList,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
},
"admin-access-admin-namespace": {
obj: objWithAdminAccess,
adminAccess: true,
expectObj: objWithAdminAccess,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 1 {
t.Errorf("expected one action but got %d", len(as))
return
}
ns := as[0].(testclient.GetAction).GetName()
if ns != "kube-system" {
t.Errorf("expected to get the kube-system namespace but got '%s'", ns)
}
},
},
"admin-access-non-admin-namespace": {
obj: objWithAdminAccessInNonAdminNamespace,
adminAccess: true,
expectObj: objWithAdminAccessInNonAdminNamespace,
expectValidationError: adminAccessError,
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 1 {
t.Errorf("expected one action but got %d", len(as))
return
}
ns := as[0].(testclient.GetAction).GetName()
if ns != "default" {
t.Errorf("expected to get the default namespace but got '%s'", ns)
}
},
}, },
} }
for name, tc := range testcases { for name, tc := range testcases {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
fakeClient := fake.NewSimpleClientset(ns1, ns2)
mockNSClient := fakeClient.CoreV1().Namespaces()
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAAdminAccess, tc.adminAccess) featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAAdminAccess, tc.adminAccess)
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAPrioritizedList, tc.prioritizedList) featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAPrioritizedList, tc.prioritizedList)
strategy := NewStrategy(mockNSClient)
obj := tc.obj.DeepCopy() obj := tc.obj.DeepCopy()
Strategy.PrepareForCreate(ctx, obj) strategy.PrepareForCreate(ctx, obj)
if errs := Strategy.Validate(ctx, obj); len(errs) != 0 { if errs := strategy.Validate(ctx, obj); len(errs) != 0 {
if !tc.expectValidationError { assert.ErrorContains(t, errs[0], tc.expectValidationError, "the error message should have contained the expected error message")
t.Fatalf("unexpected validation errors: %q", errs)
}
return return
} else if tc.expectValidationError { }
if tc.expectValidationError != "" {
t.Fatal("expected validation error(s), got none") t.Fatal("expected validation error(s), got none")
} }
if warnings := Strategy.WarningsOnCreate(ctx, obj); len(warnings) != 0 { if warnings := strategy.WarningsOnCreate(ctx, obj); len(warnings) != 0 {
t.Fatalf("unexpected warnings: %q", warnings) t.Fatalf("unexpected warnings: %q", warnings)
} }
Strategy.Canonicalize(obj) strategy.Canonicalize(obj)
assert.Equal(t, tc.expectObj, obj) assert.Equal(t, tc.expectObj, obj)
tc.verify(t, fakeClient.Actions())
}) })
} }
} }
@ -177,28 +292,66 @@ func TestClaimTemplateStrategyCreate(t *testing.T) {
func TestClaimTemplateStrategyUpdate(t *testing.T) { func TestClaimTemplateStrategyUpdate(t *testing.T) {
t.Run("no-changes-okay", func(t *testing.T) { t.Run("no-changes-okay", func(t *testing.T) {
ctx := genericapirequest.NewDefaultContext() ctx := genericapirequest.NewDefaultContext()
fakeClient := fake.NewSimpleClientset(ns1, ns2)
mockNSClient := fakeClient.CoreV1().Namespaces()
strategy := NewStrategy(mockNSClient)
resourceClaimTemplate := obj.DeepCopy() resourceClaimTemplate := obj.DeepCopy()
newClaimTemplate := resourceClaimTemplate.DeepCopy() newClaimTemplate := resourceClaimTemplate.DeepCopy()
newClaimTemplate.ResourceVersion = "4" newClaimTemplate.ResourceVersion = "4"
Strategy.PrepareForUpdate(ctx, newClaimTemplate, resourceClaimTemplate) strategy.PrepareForUpdate(ctx, newClaimTemplate, resourceClaimTemplate)
errs := Strategy.ValidateUpdate(ctx, newClaimTemplate, resourceClaimTemplate) errs := strategy.ValidateUpdate(ctx, newClaimTemplate, resourceClaimTemplate)
if len(errs) != 0 { if len(errs) != 0 {
t.Errorf("unexpected validation errors: %v", errs) t.Errorf("unexpected validation errors: %v", errs)
} }
if len(fakeClient.Actions()) != 0 {
t.Errorf("expected no action to be taken")
}
}) })
t.Run("name-change-not-allowed", func(t *testing.T) { t.Run("name-change-not-allowed", func(t *testing.T) {
ctx := genericapirequest.NewDefaultContext() ctx := genericapirequest.NewDefaultContext()
fakeClient := fake.NewSimpleClientset(ns1, ns2)
mockNSClient := fakeClient.CoreV1().Namespaces()
strategy := NewStrategy(mockNSClient)
resourceClaimTemplate := obj.DeepCopy() resourceClaimTemplate := obj.DeepCopy()
newClaimTemplate := resourceClaimTemplate.DeepCopy() newClaimTemplate := resourceClaimTemplate.DeepCopy()
newClaimTemplate.Name = "valid-class-2" newClaimTemplate.Name = "valid-class-2"
newClaimTemplate.ResourceVersion = "4" newClaimTemplate.ResourceVersion = "4"
Strategy.PrepareForUpdate(ctx, newClaimTemplate, resourceClaimTemplate) strategy.PrepareForUpdate(ctx, newClaimTemplate, resourceClaimTemplate)
errs := Strategy.ValidateUpdate(ctx, newClaimTemplate, resourceClaimTemplate) errs := strategy.ValidateUpdate(ctx, newClaimTemplate, resourceClaimTemplate)
if len(errs) == 0 { if len(errs) == 0 {
t.Errorf("expected a validation error") t.Errorf("expected a validation error")
} }
if len(fakeClient.Actions()) != 0 {
t.Errorf("expected no action to be taken")
}
})
t.Run("adminaccess-update-not-allowed", func(t *testing.T) {
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DRAAdminAccess, true)
ctx := genericapirequest.NewDefaultContext()
fakeClient := fake.NewSimpleClientset(ns1, ns2)
mockNSClient := fakeClient.CoreV1().Namespaces()
strategy := NewStrategy(mockNSClient)
resourceClaimTemplate := obj.DeepCopy()
newClaimTemplate := resourceClaimTemplate.DeepCopy()
newClaimTemplate.ResourceVersion = "4"
newClaimTemplate.Spec.Spec.Devices.Requests[0].AdminAccess = ptr.To(true)
strategy.PrepareForUpdate(ctx, newClaimTemplate, resourceClaimTemplate)
errs := strategy.ValidateUpdate(ctx, newClaimTemplate, resourceClaimTemplate)
if len(errs) != 0 {
assert.ErrorContains(t, errs[0], fieldImmutableError, "the error message should have contained the expected error message")
return
}
if len(errs) == 0 {
t.Errorf("expected a validation error")
}
if len(fakeClient.Actions()) != 0 {
t.Errorf("expected no action to be taken")
}
}) })
} }

View File

@ -23,6 +23,7 @@ import (
"k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server" genericapiserver "k8s.io/apiserver/pkg/server"
serverstorage "k8s.io/apiserver/pkg/server/storage" serverstorage "k8s.io/apiserver/pkg/server/storage"
"k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/kubernetes/pkg/api/legacyscheme" "k8s.io/kubernetes/pkg/api/legacyscheme"
"k8s.io/kubernetes/pkg/apis/resource" "k8s.io/kubernetes/pkg/apis/resource"
deviceclassstore "k8s.io/kubernetes/pkg/registry/resource/deviceclass/storage" deviceclassstore "k8s.io/kubernetes/pkg/registry/resource/deviceclass/storage"
@ -35,20 +36,22 @@ import (
// feature gate because it might be useful to provide access to these resources // feature gate because it might be useful to provide access to these resources
// while their feature is off to allow cleaning them up. // while their feature is off to allow cleaning them up.
type RESTStorageProvider struct{} type RESTStorageProvider struct {
NamespaceClient v1.NamespaceInterface
}
func (p RESTStorageProvider) NewRESTStorage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (genericapiserver.APIGroupInfo, error) { func (p RESTStorageProvider) NewRESTStorage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (genericapiserver.APIGroupInfo, error) {
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(resource.GroupName, legacyscheme.Scheme, legacyscheme.ParameterCodec, legacyscheme.Codecs) apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(resource.GroupName, legacyscheme.Scheme, legacyscheme.ParameterCodec, legacyscheme.Codecs)
// If you add a version here, be sure to add an entry in `k8s.io/kubernetes/cmd/kube-apiserver/app/aggregator.go with specific priorities. // If you add a version here, be sure to add an entry in `k8s.io/kubernetes/cmd/kube-apiserver/app/aggregator.go with specific priorities.
// TODO refactor the plumbing to provide the information in the APIGroupInfo // TODO refactor the plumbing to provide the information in the APIGroupInfo
if storageMap, err := p.v1alpha3Storage(apiResourceConfigSource, restOptionsGetter); err != nil { if storageMap, err := p.v1alpha3Storage(apiResourceConfigSource, restOptionsGetter, p.NamespaceClient); err != nil {
return genericapiserver.APIGroupInfo{}, err return genericapiserver.APIGroupInfo{}, err
} else if len(storageMap) > 0 { } else if len(storageMap) > 0 {
apiGroupInfo.VersionedResourcesStorageMap[resourcev1alpha3.SchemeGroupVersion.Version] = storageMap apiGroupInfo.VersionedResourcesStorageMap[resourcev1alpha3.SchemeGroupVersion.Version] = storageMap
} }
if storageMap, err := p.v1beta1Storage(apiResourceConfigSource, restOptionsGetter); err != nil { if storageMap, err := p.v1beta1Storage(apiResourceConfigSource, restOptionsGetter, p.NamespaceClient); err != nil {
return genericapiserver.APIGroupInfo{}, err return genericapiserver.APIGroupInfo{}, err
} else if len(storageMap) > 0 { } else if len(storageMap) > 0 {
apiGroupInfo.VersionedResourcesStorageMap[resourcev1beta1.SchemeGroupVersion.Version] = storageMap apiGroupInfo.VersionedResourcesStorageMap[resourcev1beta1.SchemeGroupVersion.Version] = storageMap
@ -57,7 +60,7 @@ func (p RESTStorageProvider) NewRESTStorage(apiResourceConfigSource serverstorag
return apiGroupInfo, nil return apiGroupInfo, nil
} }
func (p RESTStorageProvider) v1alpha3Storage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (map[string]rest.Storage, error) { func (p RESTStorageProvider) v1alpha3Storage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter, nsClient v1.NamespaceInterface) (map[string]rest.Storage, error) {
storage := map[string]rest.Storage{} storage := map[string]rest.Storage{}
if resource := "deviceclasses"; apiResourceConfigSource.ResourceEnabled(resourcev1alpha3.SchemeGroupVersion.WithResource(resource)) { if resource := "deviceclasses"; apiResourceConfigSource.ResourceEnabled(resourcev1alpha3.SchemeGroupVersion.WithResource(resource)) {
@ -69,7 +72,7 @@ func (p RESTStorageProvider) v1alpha3Storage(apiResourceConfigSource serverstora
} }
if resource := "resourceclaims"; apiResourceConfigSource.ResourceEnabled(resourcev1alpha3.SchemeGroupVersion.WithResource(resource)) { if resource := "resourceclaims"; apiResourceConfigSource.ResourceEnabled(resourcev1alpha3.SchemeGroupVersion.WithResource(resource)) {
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter) resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -78,7 +81,7 @@ func (p RESTStorageProvider) v1alpha3Storage(apiResourceConfigSource serverstora
} }
if resource := "resourceclaimtemplates"; apiResourceConfigSource.ResourceEnabled(resourcev1alpha3.SchemeGroupVersion.WithResource(resource)) { if resource := "resourceclaimtemplates"; apiResourceConfigSource.ResourceEnabled(resourcev1alpha3.SchemeGroupVersion.WithResource(resource)) {
resourceClaimTemplateStorage, err := resourceclaimtemplatestore.NewREST(restOptionsGetter) resourceClaimTemplateStorage, err := resourceclaimtemplatestore.NewREST(restOptionsGetter, nsClient)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -96,7 +99,7 @@ func (p RESTStorageProvider) v1alpha3Storage(apiResourceConfigSource serverstora
return storage, nil return storage, nil
} }
func (p RESTStorageProvider) v1beta1Storage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (map[string]rest.Storage, error) { func (p RESTStorageProvider) v1beta1Storage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter, nsClient v1.NamespaceInterface) (map[string]rest.Storage, error) {
storage := map[string]rest.Storage{} storage := map[string]rest.Storage{}
if resource := "deviceclasses"; apiResourceConfigSource.ResourceEnabled(resourcev1beta1.SchemeGroupVersion.WithResource(resource)) { if resource := "deviceclasses"; apiResourceConfigSource.ResourceEnabled(resourcev1beta1.SchemeGroupVersion.WithResource(resource)) {
@ -108,7 +111,7 @@ func (p RESTStorageProvider) v1beta1Storage(apiResourceConfigSource serverstorag
} }
if resource := "resourceclaims"; apiResourceConfigSource.ResourceEnabled(resourcev1beta1.SchemeGroupVersion.WithResource(resource)) { if resource := "resourceclaims"; apiResourceConfigSource.ResourceEnabled(resourcev1beta1.SchemeGroupVersion.WithResource(resource)) {
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter) resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -117,7 +120,7 @@ func (p RESTStorageProvider) v1beta1Storage(apiResourceConfigSource serverstorag
} }
if resource := "resourceclaimtemplates"; apiResourceConfigSource.ResourceEnabled(resourcev1beta1.SchemeGroupVersion.WithResource(resource)) { if resource := "resourceclaimtemplates"; apiResourceConfigSource.ResourceEnabled(resourcev1beta1.SchemeGroupVersion.WithResource(resource)) {
resourceClaimTemplateStorage, err := resourceclaimtemplatestore.NewREST(restOptionsGetter) resourceClaimTemplateStorage, err := resourceclaimtemplatestore.NewREST(restOptionsGetter, nsClient)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -0,0 +1,99 @@
/*
Copyright 2025 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"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/kubernetes/pkg/apis/resource"
)
// AuthorizedForAdmin checks if the request is authorized to get admin access to devices
// based on namespace label
func AuthorizedForAdmin(ctx context.Context, deviceRequests []resource.DeviceRequest, namespaceName string, nsClient v1.NamespaceInterface) field.ErrorList {
var allErrs field.ErrorList
adminRequested := false
var adminAccessPath *field.Path
// no need to check old request since spec is immutable
for i := range deviceRequests {
value := deviceRequests[i].AdminAccess
if value != nil && *value {
adminRequested = true
adminAccessPath = field.NewPath("spec", "devices", "requests").Index(i).Child("adminAccess")
break
}
}
if !adminRequested {
// No need to validate unless admin access is requested
return allErrs
}
// Retrieve the namespace object from the store
ns, err := nsClient.Get(ctx, namespaceName, metav1.GetOptions{})
if err != nil {
return append(allErrs, field.InternalError(adminAccessPath, fmt.Errorf("could not retrieve namespace to verify admin access: %w", err)))
}
if ns.Labels[resource.DRAAdminNamespaceLabelKey] != "true" {
return append(allErrs, field.Forbidden(adminAccessPath, fmt.Sprintf("admin access to devices requires the `%s: true` label on the containing namespace", resource.DRAAdminNamespaceLabelKey)))
}
return allErrs
}
// AuthorizedForAdminStatus checks if the request status is authorized to get admin access to devices
// based on namespace label
func AuthorizedForAdminStatus(ctx context.Context, newAllocationResult, oldAllocationResult []resource.DeviceRequestAllocationResult, namespaceName string, nsClient v1.NamespaceInterface) field.ErrorList {
var allErrs field.ErrorList
var adminAccessPath *field.Path
if wasGranted, _ := adminRequested(oldAllocationResult); wasGranted {
// No need to validate if old status has admin access granted, since status.Allocation is immutable
return allErrs
}
isRequested, adminAccessPath := adminRequested(newAllocationResult)
if !isRequested {
// No need to validate unless admin access is requested
return allErrs
}
// Retrieve the namespace object from the store
ns, err := nsClient.Get(ctx, namespaceName, metav1.GetOptions{})
if err != nil {
return append(allErrs, field.InternalError(adminAccessPath, fmt.Errorf("could not retrieve namespace to verify admin access: %w", err)))
}
if ns.Labels[resource.DRAAdminNamespaceLabelKey] != "true" {
return append(allErrs, field.Forbidden(adminAccessPath, fmt.Sprintf("admin access to devices requires the `%s: true` label on the containing namespace", resource.DRAAdminNamespaceLabelKey)))
}
return allErrs
}
func adminRequested(deviceRequestResults []resource.DeviceRequestAllocationResult) (bool, *field.Path) {
for i := range deviceRequestResults {
value := deviceRequestResults[i].AdminAccess
if value != nil && *value {
return true, field.NewPath("status", "allocation", "devices", "results").Index(i).Child("adminAccess")
}
}
return false, nil
}

View File

@ -383,6 +383,15 @@ const (
DeviceConfigMaxSize = 32 DeviceConfigMaxSize = 32
) )
// DRAAdminNamespaceLabelKey is a label key used to grant administrative access
// to certain resource.k8s.io API types within a namespace. When this label is
// set on a namespace with the value "true" (case-sensitive), it allows the use
// of adminAccess: true in any namespaced resource.k8s.io API types. Currently,
// this permission applies to ResourceClaim and ResourceClaimTemplate objects.
const (
DRAAdminNamespaceLabelKey = "resource.k8s.io/admin-access"
)
// DeviceRequest is a request for devices required for a claim. // DeviceRequest is a request for devices required for a claim.
// This is typically a request for a single resource like a device, but can // This is typically a request for a single resource like a device, but can
// also ask for several identical devices. // also ask for several identical devices.

View File

@ -391,6 +391,15 @@ const (
DeviceConfigMaxSize = 32 DeviceConfigMaxSize = 32
) )
// DRAAdminNamespaceLabelKey is a label key used to grant administrative access
// to certain resource.k8s.io API types within a namespace. When this label is
// set on a namespace with the value "true" (case-sensitive), it allows the use
// of adminAccess: true in any namespaced resource.k8s.io API types. Currently,
// this permission applies to ResourceClaim and ResourceClaimTemplate objects.
const (
DRAAdminNamespaceLabelKey = "resource.k8s.io/admin-access"
)
// DeviceRequest is a request for devices required for a claim. // DeviceRequest is a request for devices required for a claim.
// This is typically a request for a single resource like a device, but can // This is typically a request for a single resource like a device, but can
// also ask for several identical devices. // also ask for several identical devices.

View File

@ -32,7 +32,6 @@ import (
"github.com/onsi/gomega/gstruct" "github.com/onsi/gomega/gstruct"
"github.com/onsi/gomega/types" "github.com/onsi/gomega/types"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
resourceapi "k8s.io/api/resource/v1beta1" resourceapi "k8s.io/api/resource/v1beta1"
@ -59,9 +58,6 @@ const (
podStartTimeout = 5 * time.Minute podStartTimeout = 5 * time.Minute
) )
//go:embed test-driver/deploy/example/admin-access-policy.yaml
var adminAccessPolicyYAML string
// networkResources can be passed to NewDriver directly. // networkResources can be passed to NewDriver directly.
func networkResources() Resources { func networkResources() Resources {
return Resources{} return Resources{}
@ -1379,30 +1375,13 @@ var _ = framework.SIGDescribe("node")("DRA", feature.DynamicResourceAllocation,
driver := NewDriver(f, nodes, networkResources) driver := NewDriver(f, nodes, networkResources)
b := newBuilder(f, driver) b := newBuilder(f, driver)
f.It("support validating admission policy for admin access", feature.DRAAdminAccess, framework.WithFeatureGate(features.DRAAdminAccess), framework.WithFeatureGate(features.DynamicResourceAllocation), func(ctx context.Context) { f.It("validate ResourceClaimTemplate and ResourceClaim for admin access", feature.DRAAdminAccess, framework.WithFeatureGate(features.DRAAdminAccess), framework.WithFeatureGate(features.DynamicResourceAllocation), func(ctx context.Context) {
// Create VAP, after making it unique to the current test.
adminAccessPolicyYAML := strings.ReplaceAll(adminAccessPolicyYAML, "dra.example.com", b.f.UniqueName)
driver.createFromYAML(ctx, []byte(adminAccessPolicyYAML), "")
// Wait for both VAPs to be processed. This ensures that there are no check errors in the status.
matchStatus := gomega.Equal(admissionregistrationv1.ValidatingAdmissionPolicyStatus{ObservedGeneration: 1, TypeChecking: &admissionregistrationv1.TypeChecking{}})
gomega.Eventually(ctx, framework.ListObjects(b.f.ClientSet.AdmissionregistrationV1().ValidatingAdmissionPolicies().List, metav1.ListOptions{})).Should(gomega.HaveField("Items", gomega.ContainElements(
gstruct.MatchFields(gstruct.IgnoreExtras, gstruct.Fields{
"ObjectMeta": gomega.HaveField("Name", "resourceclaim-policy."+b.f.UniqueName),
"Status": matchStatus,
}),
gstruct.MatchFields(gstruct.IgnoreExtras, gstruct.Fields{
"ObjectMeta": gomega.HaveField("Name", "resourceclaimtemplate-policy."+b.f.UniqueName),
"Status": matchStatus,
}),
)))
// Attempt to create claim and claim template with admin access. Must fail eventually. // Attempt to create claim and claim template with admin access. Must fail eventually.
claim := b.externalClaim() claim := b.externalClaim()
claim.Spec.Devices.Requests[0].AdminAccess = ptr.To(true) claim.Spec.Devices.Requests[0].AdminAccess = ptr.To(true)
_, claimTemplate := b.podInline() _, claimTemplate := b.podInline()
claimTemplate.Spec.Spec.Devices.Requests[0].AdminAccess = ptr.To(true) claimTemplate.Spec.Spec.Devices.Requests[0].AdminAccess = ptr.To(true)
matchVAPError := gomega.MatchError(gomega.ContainSubstring("admin access to devices not enabled in namespace " + b.f.Namespace.Name)) matchValidationError := gomega.MatchError(gomega.ContainSubstring("admin access to devices requires the `resource.k8s.io/admin-access: true` label on the containing namespace"))
gomega.Eventually(ctx, func(ctx context.Context) error { gomega.Eventually(ctx, func(ctx context.Context) error {
// First delete, in case that it succeeded earlier. // First delete, in case that it succeeded earlier.
if err := b.f.ClientSet.ResourceV1beta1().ResourceClaims(b.f.Namespace.Name).Delete(ctx, claim.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { if err := b.f.ClientSet.ResourceV1beta1().ResourceClaims(b.f.Namespace.Name).Delete(ctx, claim.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
@ -1410,7 +1389,7 @@ var _ = framework.SIGDescribe("node")("DRA", feature.DynamicResourceAllocation,
} }
_, err := b.f.ClientSet.ResourceV1beta1().ResourceClaims(b.f.Namespace.Name).Create(ctx, claim, metav1.CreateOptions{}) _, err := b.f.ClientSet.ResourceV1beta1().ResourceClaims(b.f.Namespace.Name).Create(ctx, claim, metav1.CreateOptions{})
return err return err
}).Should(matchVAPError) }).Should(matchValidationError)
gomega.Eventually(ctx, func(ctx context.Context) error { gomega.Eventually(ctx, func(ctx context.Context) error {
// First delete, in case that it succeeded earlier. // First delete, in case that it succeeded earlier.
@ -1419,11 +1398,11 @@ var _ = framework.SIGDescribe("node")("DRA", feature.DynamicResourceAllocation,
} }
_, err := b.f.ClientSet.ResourceV1beta1().ResourceClaimTemplates(b.f.Namespace.Name).Create(ctx, claimTemplate, metav1.CreateOptions{}) _, err := b.f.ClientSet.ResourceV1beta1().ResourceClaimTemplates(b.f.Namespace.Name).Create(ctx, claimTemplate, metav1.CreateOptions{})
return err return err
}).Should(matchVAPError) }).Should(matchValidationError)
// After labeling the namespace, creation must (eventually...) succeed. // After labeling the namespace, creation must (eventually...) succeed.
_, err := b.f.ClientSet.CoreV1().Namespaces().Apply(ctx, _, err := b.f.ClientSet.CoreV1().Namespaces().Apply(ctx,
applyv1.Namespace(b.f.Namespace.Name).WithLabels(map[string]string{"admin-access." + b.f.UniqueName: "on"}), applyv1.Namespace(b.f.Namespace.Name).WithLabels(map[string]string{"resource.k8s.io/admin-access": "true"}),
metav1.ApplyOptions{FieldManager: b.f.UniqueName}) metav1.ApplyOptions{FieldManager: b.f.UniqueName})
framework.ExpectNoError(err) framework.ExpectNoError(err)
gomega.Eventually(ctx, func(ctx context.Context) error { gomega.Eventually(ctx, func(ctx context.Context) error {
@ -1499,6 +1478,12 @@ var _ = framework.SIGDescribe("node")("DRA", feature.DynamicResourceAllocation,
}) })
f.It("DaemonSet with admin access", feature.DRAAdminAccess, framework.WithFeatureGate(features.DRAAdminAccess), framework.WithFeatureGate(features.DynamicResourceAllocation), func(ctx context.Context) { f.It("DaemonSet with admin access", feature.DRAAdminAccess, framework.WithFeatureGate(features.DRAAdminAccess), framework.WithFeatureGate(features.DynamicResourceAllocation), func(ctx context.Context) {
// Ensure namespace has the dra admin label.
_, err := b.f.ClientSet.CoreV1().Namespaces().Apply(ctx,
applyv1.Namespace(b.f.Namespace.Name).WithLabels(map[string]string{"resource.k8s.io/admin-access": "true"}),
metav1.ApplyOptions{FieldManager: b.f.UniqueName})
framework.ExpectNoError(err)
pod, template := b.podInline() pod, template := b.podInline()
template.Spec.Spec.Devices.Requests[0].AdminAccess = ptr.To(true) template.Spec.Spec.Devices.Requests[0].AdminAccess = ptr.To(true)
// Limit the daemon set to the one node where we have the driver. // Limit the daemon set to the one node where we have the driver.
@ -1507,7 +1492,8 @@ var _ = framework.SIGDescribe("node")("DRA", feature.DynamicResourceAllocation,
pod.Spec.RestartPolicy = v1.RestartPolicyAlways pod.Spec.RestartPolicy = v1.RestartPolicyAlways
daemonSet := &appsv1.DaemonSet{ daemonSet := &appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: "monitoring-ds", Name: "monitoring-ds",
Namespace: b.f.Namespace.Name,
}, },
Spec: appsv1.DaemonSetSpec{ Spec: appsv1.DaemonSetSpec{
Selector: &metav1.LabelSelector{ Selector: &metav1.LabelSelector{

View File

@ -87,7 +87,7 @@ var (
// - Non-alpha-numeric characters replaced by hyphen. // - Non-alpha-numeric characters replaced by hyphen.
// - Truncated in the middle to make it short enough for GenerateName. // - Truncated in the middle to make it short enough for GenerateName.
// - Hyphen plus random suffix added by the apiserver. // - Hyphen plus random suffix added by the apiserver.
func createTestNamespace(tCtx ktesting.TContext) string { func createTestNamespace(tCtx ktesting.TContext, labels map[string]string) string {
tCtx.Helper() tCtx.Helper()
name := regexp.MustCompile(`[^[:alnum:]_-]`).ReplaceAllString(tCtx.Name(), "-") name := regexp.MustCompile(`[^[:alnum:]_-]`).ReplaceAllString(tCtx.Name(), "-")
name = strings.ToLower(name) name = strings.ToLower(name)
@ -95,6 +95,7 @@ func createTestNamespace(tCtx ktesting.TContext) string {
name = name[:30] + "--" + name[len(name)-30:] name = name[:30] + "--" + name[len(name)-30:]
} }
ns := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: name + "-"}} ns := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: name + "-"}}
ns.Labels = labels
ns, err := tCtx.Client().CoreV1().Namespaces().Create(tCtx, ns, metav1.CreateOptions{}) ns, err := tCtx.Client().CoreV1().Namespaces().Create(tCtx, ns, metav1.CreateOptions{})
tCtx.ExpectNoError(err, "create test namespace") tCtx.ExpectNoError(err, "create test namespace")
tCtx.CleanupCtx(func(tCtx ktesting.TContext) { tCtx.CleanupCtx(func(tCtx ktesting.TContext) {
@ -211,7 +212,7 @@ func newDefaultSchedulerComponentConfig(tCtx ktesting.TContext) *config.KubeSche
// whether that field is or isn't getting dropped. // whether that field is or isn't getting dropped.
func testPod(tCtx ktesting.TContext, draEnabled bool) { func testPod(tCtx ktesting.TContext, draEnabled bool) {
tCtx.Parallel() tCtx.Parallel()
namespace := createTestNamespace(tCtx) namespace := createTestNamespace(tCtx, nil)
podWithClaimName := podWithClaimName.DeepCopy() podWithClaimName := podWithClaimName.DeepCopy()
podWithClaimName.Namespace = namespace podWithClaimName.Namespace = namespace
pod, err := tCtx.Client().CoreV1().Pods(namespace).Create(tCtx, podWithClaimName, metav1.CreateOptions{}) pod, err := tCtx.Client().CoreV1().Pods(namespace).Create(tCtx, podWithClaimName, metav1.CreateOptions{})
@ -235,7 +236,7 @@ func testAPIDisabled(tCtx ktesting.TContext) {
// testConvert creates a claim using a one API version and reads it with another. // testConvert creates a claim using a one API version and reads it with another.
func testConvert(tCtx ktesting.TContext) { func testConvert(tCtx ktesting.TContext) {
tCtx.Parallel() tCtx.Parallel()
namespace := createTestNamespace(tCtx) namespace := createTestNamespace(tCtx, nil)
claim := claim.DeepCopy() claim := claim.DeepCopy()
claim.Namespace = namespace claim.Namespace = namespace
claim, err := tCtx.Client().ResourceV1beta1().ResourceClaims(namespace).Create(tCtx, claim, metav1.CreateOptions{}) claim, err := tCtx.Client().ResourceV1beta1().ResourceClaims(namespace).Create(tCtx, claim, metav1.CreateOptions{})
@ -248,17 +249,34 @@ func testConvert(tCtx ktesting.TContext) {
// testAdminAccess creates a claim with AdminAccess and then checks // testAdminAccess creates a claim with AdminAccess and then checks
// whether that field is or isn't getting dropped. // whether that field is or isn't getting dropped.
// when the AdminAccess feature is enabled, it also checks that the field
// is only allowed to be used in namespace with the Resource Admin Access label
func testAdminAccess(tCtx ktesting.TContext, adminAccessEnabled bool) { func testAdminAccess(tCtx ktesting.TContext, adminAccessEnabled bool) {
tCtx.Parallel() namespace := createTestNamespace(tCtx, nil)
namespace := createTestNamespace(tCtx) claim1 := claim.DeepCopy()
claim := claim.DeepCopy() claim1.Namespace = namespace
claim.Namespace = namespace claim1.Spec.Devices.Requests[0].AdminAccess = ptr.To(true)
claim.Spec.Devices.Requests[0].AdminAccess = ptr.To(true) // create claim with AdminAccess in non-admin namespace
claim, err := tCtx.Client().ResourceV1beta1().ResourceClaims(namespace).Create(tCtx, claim, metav1.CreateOptions{}) _, err := tCtx.Client().ResourceV1beta1().ResourceClaims(namespace).Create(tCtx, claim1, metav1.CreateOptions{})
tCtx.ExpectNoError(err, "create claim")
if adminAccessEnabled { if adminAccessEnabled {
if !ptr.Deref(claim.Spec.Devices.Requests[0].AdminAccess, false) { if err != nil {
tCtx.Fatal("should store AdminAccess in ResourceClaim") // should result in validation error
assert.ErrorContains(tCtx, err, "admin access to devices requires the `resource.k8s.io/admin-access: true` label on the containing namespace", "the error message should have contained the expected error message")
return
} else {
tCtx.Fatal("expected validation error(s), got none")
}
// create claim with AdminAccess in admin namespace
adminNS := createTestNamespace(tCtx, map[string]string{"resource.k8s.io/admin-access": "true"})
claim2 := claim.DeepCopy()
claim2.Namespace = adminNS
claim2.Name = "claim2"
claim2.Spec.Devices.Requests[0].AdminAccess = ptr.To(true)
claim2, err := tCtx.Client().ResourceV1beta1().ResourceClaims(adminNS).Create(tCtx, claim2, metav1.CreateOptions{})
tCtx.ExpectNoError(err, "create claim")
if !ptr.Deref(claim2.Spec.Devices.Requests[0].AdminAccess, true) {
tCtx.Fatalf("should store AdminAccess in ResourceClaim %v", claim2)
} }
} else { } else {
if claim.Spec.Devices.Requests[0].AdminAccess != nil { if claim.Spec.Devices.Requests[0].AdminAccess != nil {
@ -271,7 +289,7 @@ func testPrioritizedList(tCtx ktesting.TContext, enabled bool) {
tCtx.Parallel() tCtx.Parallel()
_, err := tCtx.Client().ResourceV1beta1().DeviceClasses().Create(tCtx, class, metav1.CreateOptions{}) _, err := tCtx.Client().ResourceV1beta1().DeviceClasses().Create(tCtx, class, metav1.CreateOptions{})
tCtx.ExpectNoError(err, "create class") tCtx.ExpectNoError(err, "create class")
namespace := createTestNamespace(tCtx) namespace := createTestNamespace(tCtx, nil)
claim := claimPrioritizedList.DeepCopy() claim := claimPrioritizedList.DeepCopy()
claim.Namespace = namespace claim.Namespace = namespace
claim, err = tCtx.Client().ResourceV1beta1().ResourceClaims(namespace).Create(tCtx, claim, metav1.CreateOptions{}) claim, err = tCtx.Client().ResourceV1beta1().ResourceClaims(namespace).Create(tCtx, claim, metav1.CreateOptions{})