Skip to content

Commit ee32058

Browse files
committed
dra e2e: demonstrate how to use RBAC + VAP for a kubelet plugin
In reality, the kubelet plugin of a DRA driver is meant to be deployed as a daemonset with a service account that limits its permissions. https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/#additional-metadata-in-pod-bound-tokens ensures that the node name is bound to the pod, which then can be used in a validating admission policy (VAP) to ensure that the operations are limited to the node. In E2E testing, we emulate that via impersonation. This ensures that the plugin does not accidentally depend on additional permissions.
1 parent 348f94a commit ee32058

File tree

3 files changed

+304
-9
lines changed

3 files changed

+304
-9
lines changed

test/e2e/dra/deploy.go

Lines changed: 128 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package dra
1919
import (
2020
"bytes"
2121
"context"
22+
_ "embed"
2223
"errors"
2324
"fmt"
2425
"net"
@@ -38,10 +39,19 @@ import (
3839
v1 "k8s.io/api/core/v1"
3940
resourcev1alpha2 "k8s.io/api/resource/v1alpha2"
4041
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
42+
apierrors "k8s.io/apimachinery/pkg/api/errors"
43+
"k8s.io/apimachinery/pkg/api/meta"
4144
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
45+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
4246
"k8s.io/apimachinery/pkg/labels"
47+
"k8s.io/apimachinery/pkg/runtime/schema"
4348
"k8s.io/apimachinery/pkg/selection"
49+
"k8s.io/apiserver/pkg/authentication/serviceaccount"
50+
"k8s.io/client-go/discovery/cached/memory"
4451
resourceapiinformer "k8s.io/client-go/informers/resource/v1alpha2"
52+
"k8s.io/client-go/kubernetes"
53+
"k8s.io/client-go/rest"
54+
"k8s.io/client-go/restmapper"
4555
"k8s.io/client-go/tools/cache"
4656
"k8s.io/dynamic-resource-allocation/kubeletplugin"
4757
"k8s.io/klog/v2"
@@ -52,6 +62,7 @@ import (
5262
e2eskipper "k8s.io/kubernetes/test/e2e/framework/skipper"
5363
"k8s.io/kubernetes/test/e2e/storage/drivers/proxy"
5464
"k8s.io/kubernetes/test/e2e/storage/utils"
65+
"sigs.k8s.io/yaml"
5566
)
5667

5768
const (
@@ -63,10 +74,14 @@ type Nodes struct {
6374
NodeNames []string
6475
}
6576

77+
//go:embed test-driver/deploy/example/plugin-permissions.yaml
78+
var pluginPermissions string
79+
6680
// NewNodes selects nodes to run the test on.
6781
func NewNodes(f *framework.Framework, minNodes, maxNodes int) *Nodes {
6882
nodes := &Nodes{}
6983
ginkgo.BeforeEach(func(ctx context.Context) {
84+
7085
ginkgo.By("selecting nodes")
7186
// The kubelet plugin is harder. We deploy the builtin manifest
7287
// after patching in the driver name and all nodes on which we
@@ -166,15 +181,19 @@ type MethodInstance struct {
166181
}
167182

168183
type Driver struct {
169-
f *framework.Framework
170-
ctx context.Context
171-
cleanup []func() // executed first-in-first-out
172-
wg sync.WaitGroup
184+
f *framework.Framework
185+
ctx context.Context
186+
cleanup []func() // executed first-in-first-out
187+
wg sync.WaitGroup
188+
serviceAccountName string
173189

174190
NameSuffix string
175191
Controller *app.ExampleController
176192
Name string
177-
Nodes map[string]*app.ExamplePlugin
193+
194+
// Nodes contains entries for each node selected for a test when the test runs.
195+
// In addition, there is one entry for a fictional node.
196+
Nodes map[string]KubeletPlugin
178197

179198
parameterMode parameterMode
180199
parameterAPIGroup string
@@ -189,6 +208,11 @@ type Driver struct {
189208
callCounts map[MethodInstance]int64
190209
}
191210

211+
type KubeletPlugin struct {
212+
*app.ExamplePlugin
213+
ClientSet kubernetes.Interface
214+
}
215+
192216
type parameterMode string
193217

194218
const (
@@ -199,7 +223,7 @@ const (
199223

200224
func (d *Driver) SetUp(nodes *Nodes, resources app.Resources) {
201225
ginkgo.By(fmt.Sprintf("deploying driver on nodes %v", nodes.NodeNames))
202-
d.Nodes = map[string]*app.ExamplePlugin{}
226+
d.Nodes = make(map[string]KubeletPlugin)
203227
d.Name = d.f.UniqueName + d.NameSuffix + ".k8s.io"
204228
resources.DriverName = d.Name
205229

@@ -250,6 +274,13 @@ func (d *Driver) SetUp(nodes *Nodes, resources app.Resources) {
250274
framework.Failf("unknown test driver parameter mode: %s", d.parameterMode)
251275
}
252276

277+
// Create service account and corresponding RBAC rules.
278+
d.serviceAccountName = "dra-kubelet-plugin-" + d.Name + "-service-account"
279+
content := pluginPermissions
280+
content = strings.ReplaceAll(content, "dra-kubelet-plugin-namespace", d.f.Namespace.Name)
281+
content = strings.ReplaceAll(content, "dra-kubelet-plugin", "dra-kubelet-plugin-"+d.Name)
282+
d.createFromYAML(ctx, []byte(content), d.f.Namespace.Name)
283+
253284
instanceKey := "app.kubernetes.io/instance"
254285
rsName := ""
255286
draAddr := path.Join(framework.TestContext.KubeletRootDir, "plugins", d.Name+".sock")
@@ -262,6 +293,7 @@ func (d *Driver) SetUp(nodes *Nodes, resources app.Resources) {
262293
item.Spec.Replicas = &numNodes
263294
item.Spec.Selector.MatchLabels[instanceKey] = d.Name
264295
item.Spec.Template.Labels[instanceKey] = d.Name
296+
item.Spec.Template.Spec.ServiceAccountName = d.serviceAccountName
265297
item.Spec.Template.Spec.Affinity.PodAntiAffinity.RequiredDuringSchedulingIgnoredDuringExecution[0].LabelSelector.MatchLabels[instanceKey] = d.Name
266298
item.Spec.Template.Spec.Affinity.NodeAffinity = &v1.NodeAffinity{
267299
RequiredDuringSchedulingIgnoredDuringExecution: &v1.NodeSelector{
@@ -305,15 +337,31 @@ func (d *Driver) SetUp(nodes *Nodes, resources app.Resources) {
305337
framework.ExpectNoError(err, "list proxy pods")
306338
gomega.Expect(numNodes).To(gomega.Equal(int32(len(pods.Items))), "number of proxy pods")
307339

308-
// Run registar and plugin for each of the pods.
340+
// Run registrar and plugin for each of the pods.
309341
for _, pod := range pods.Items {
310342
// Need a local variable, not the loop variable, for the anonymous
311343
// callback functions below.
312344
pod := pod
313345
nodename := pod.Spec.NodeName
346+
347+
// Authenticate the plugin so that it has the exact same
348+
// permissions as the daemonset pod. This includes RBAC and a
349+
// validating admission policy which limits writes to per-node
350+
// ResourceSlices.
351+
//
352+
// We could retrieve
353+
// /var/run/secrets/kubernetes.io/serviceaccount/token from
354+
// each pod and use it. That would check that
355+
// ServiceAccountTokenNodeBindingValidation works. But that's
356+
// better covered by a test owned by SIG Auth (like the one in
357+
// https://github.com/kubernetes/kubernetes/pull/124711).
358+
//
359+
// Here we merely use impersonation, which is faster.
360+
driverClient := d.impersonateKubeletPlugin(&pod)
361+
314362
logger := klog.LoggerWithValues(klog.LoggerWithName(klog.Background(), "kubelet plugin"), "node", pod.Spec.NodeName, "pod", klog.KObj(&pod))
315363
loggerCtx := klog.NewContext(ctx, logger)
316-
plugin, err := app.StartPlugin(loggerCtx, "/cdi", d.Name, d.f.ClientSet, nodename,
364+
plugin, err := app.StartPlugin(loggerCtx, "/cdi", d.Name, driverClient, nodename,
317365
app.FileOperations{
318366
Create: func(name string, content []byte) error {
319367
klog.Background().Info("creating CDI file", "node", nodename, "filename", name, "content", string(content))
@@ -342,7 +390,7 @@ func (d *Driver) SetUp(nodes *Nodes, resources app.Resources) {
342390
// Depends on cancel being called first.
343391
plugin.Stop()
344392
})
345-
d.Nodes[nodename] = plugin
393+
d.Nodes[nodename] = KubeletPlugin{ExamplePlugin: plugin, ClientSet: driverClient}
346394
}
347395

348396
// Wait for registration.
@@ -359,6 +407,26 @@ func (d *Driver) SetUp(nodes *Nodes, resources app.Resources) {
359407
}).WithTimeout(time.Minute).Should(gomega.BeEmpty(), "hosts where the plugin has not been registered yet")
360408
}
361409

410+
func (d *Driver) impersonateKubeletPlugin(pod *v1.Pod) kubernetes.Interface {
411+
ginkgo.GinkgoHelper()
412+
driverUserInfo := (&serviceaccount.ServiceAccountInfo{
413+
Name: d.serviceAccountName,
414+
Namespace: pod.Namespace,
415+
NodeName: pod.Spec.NodeName,
416+
PodName: pod.Name,
417+
PodUID: string(pod.UID),
418+
}).UserInfo()
419+
driverClientConfig := d.f.ClientConfig()
420+
driverClientConfig.Impersonate = rest.ImpersonationConfig{
421+
UserName: driverUserInfo.GetName(),
422+
Groups: driverUserInfo.GetGroups(),
423+
Extra: driverUserInfo.GetExtra(),
424+
}
425+
driverClient, err := kubernetes.NewForConfig(driverClientConfig)
426+
framework.ExpectNoError(err, "create client for driver")
427+
return driverClient
428+
}
429+
362430
func (d *Driver) createFile(pod *v1.Pod, name string, content []byte) error {
363431
buffer := bytes.NewBuffer(content)
364432
// Writing the content can be slow. Better create a temporary file and
@@ -375,6 +443,57 @@ func (d *Driver) removeFile(pod *v1.Pod, name string) error {
375443
return d.podIO(pod).RemoveAll(name)
376444
}
377445

446+
func (d *Driver) createFromYAML(ctx context.Context, content []byte, namespace string) {
447+
// Not caching the discovery result isn't very efficient, but good enough.
448+
discoveryCache := memory.NewMemCacheClient(d.f.ClientSet.Discovery())
449+
restMapper := restmapper.NewDeferredDiscoveryRESTMapper(discoveryCache)
450+
451+
for _, content := range bytes.Split(content, []byte("---\n")) {
452+
if len(content) == 0 {
453+
continue
454+
}
455+
456+
var obj *unstructured.Unstructured
457+
framework.ExpectNoError(yaml.UnmarshalStrict(content, &obj), fmt.Sprintf("Full YAML:\n%s\n", string(content)))
458+
459+
gv, err := schema.ParseGroupVersion(obj.GetAPIVersion())
460+
framework.ExpectNoError(err, fmt.Sprintf("extract group+version from object %q", klog.KObj(obj)))
461+
gk := schema.GroupKind{Group: gv.Group, Kind: obj.GetKind()}
462+
463+
mapping, err := restMapper.RESTMapping(gk, gv.Version)
464+
framework.ExpectNoError(err, fmt.Sprintf("map %q to resource", gk))
465+
466+
resourceClient := d.f.DynamicClient.Resource(mapping.Resource)
467+
options := metav1.CreateOptions{
468+
// If the YAML input is invalid, then we want the
469+
// apiserver to tell us via an error. This can
470+
// happen because decoding into an unstructured object
471+
// doesn't validate.
472+
FieldValidation: "Strict",
473+
}
474+
switch mapping.Scope.Name() {
475+
case meta.RESTScopeNameRoot:
476+
_, err = resourceClient.Create(ctx, obj, options)
477+
case meta.RESTScopeNameNamespace:
478+
if namespace == "" {
479+
framework.Failf("need namespace for object type %s", gk)
480+
}
481+
_, err = resourceClient.Namespace(namespace).Create(ctx, obj, options)
482+
}
483+
framework.ExpectNoError(err, "create object")
484+
ginkgo.DeferCleanup(func(ctx context.Context) {
485+
del := resourceClient.Delete
486+
if mapping.Scope.Name() == meta.RESTScopeNameNamespace {
487+
del = resourceClient.Namespace(namespace).Delete
488+
}
489+
err := del(ctx, obj.GetName(), metav1.DeleteOptions{})
490+
if !apierrors.IsNotFound(err) {
491+
framework.ExpectNoError(err, fmt.Sprintf("deleting %s.%s %s", obj.GetKind(), obj.GetAPIVersion(), klog.KObj(obj)))
492+
}
493+
})
494+
}
495+
}
496+
378497
func (d *Driver) podIO(pod *v1.Pod) proxy.PodDirIO {
379498
logger := klog.Background()
380499
return proxy.PodDirIO{

test/e2e/dra/dra.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/onsi/gomega"
3030
"github.com/onsi/gomega/gcustom"
3131
"github.com/onsi/gomega/gstruct"
32+
"github.com/onsi/gomega/types"
3233

3334
v1 "k8s.io/api/core/v1"
3435
resourcev1alpha2 "k8s.io/api/resource/v1alpha2"
@@ -891,6 +892,96 @@ var _ = framework.SIGDescribe("node")("DRA", feature.DynamicResourceAllocation,
891892
driver := NewDriver(f, nodes, perNode(1, nodes))
892893
driver.parameterMode = parameterModeStructured
893894

895+
f.It("must apply per-node permission checks", func(ctx context.Context) {
896+
// All of the operations use the client set of a kubelet plugin for
897+
// a fictional node which both don't exist, so nothing interferes
898+
// when we actually manage to create a slice.
899+
fictionalNodeName := "dra-fictional-node"
900+
gomega.Expect(nodes.NodeNames).NotTo(gomega.ContainElement(fictionalNodeName))
901+
fictionalNodeClient := driver.impersonateKubeletPlugin(&v1.Pod{
902+
ObjectMeta: metav1.ObjectMeta{
903+
Name: fictionalNodeName + "-dra-plugin",
904+
Namespace: f.Namespace.Name,
905+
UID: "12345",
906+
},
907+
Spec: v1.PodSpec{
908+
NodeName: fictionalNodeName,
909+
},
910+
})
911+
912+
// This is for some actual node in the cluster.
913+
realNodeName := nodes.NodeNames[0]
914+
realNodeClient := driver.Nodes[realNodeName].ClientSet
915+
916+
// This is the slice that we try to create. It needs to be deleted
917+
// after testing, if it still exists at that time.
918+
fictionalNodeSlice := &resourcev1alpha2.ResourceSlice{
919+
ObjectMeta: metav1.ObjectMeta{
920+
Name: fictionalNodeName + "-slice",
921+
},
922+
NodeName: fictionalNodeName,
923+
DriverName: "dra.example.com",
924+
ResourceModel: resourcev1alpha2.ResourceModel{
925+
NamedResources: &resourcev1alpha2.NamedResourcesResources{},
926+
},
927+
}
928+
ginkgo.DeferCleanup(func(ctx context.Context) {
929+
err := f.ClientSet.ResourceV1alpha2().ResourceSlices().Delete(ctx, fictionalNodeSlice.Name, metav1.DeleteOptions{})
930+
if !apierrors.IsNotFound(err) {
931+
framework.ExpectNoError(err)
932+
}
933+
})
934+
935+
// Message from test-driver/deploy/example/plugin-permissions.yaml
936+
matchVAPDeniedError := gomega.MatchError(gomega.ContainSubstring("may only modify resourceslices that belong to the node the pod is running on"))
937+
938+
mustCreate := func(clientSet kubernetes.Interface, clientName string, slice *resourcev1alpha2.ResourceSlice) *resourcev1alpha2.ResourceSlice {
939+
ginkgo.GinkgoHelper()
940+
slice, err := clientSet.ResourceV1alpha2().ResourceSlices().Create(ctx, slice, metav1.CreateOptions{})
941+
framework.ExpectNoError(err, fmt.Sprintf("CREATE: %s + %s", clientName, slice.Name))
942+
return slice
943+
}
944+
mustUpdate := func(clientSet kubernetes.Interface, clientName string, slice *resourcev1alpha2.ResourceSlice) *resourcev1alpha2.ResourceSlice {
945+
ginkgo.GinkgoHelper()
946+
slice, err := clientSet.ResourceV1alpha2().ResourceSlices().Update(ctx, slice, metav1.UpdateOptions{})
947+
framework.ExpectNoError(err, fmt.Sprintf("UPDATE: %s + %s", clientName, slice.Name))
948+
return slice
949+
}
950+
mustDelete := func(clientSet kubernetes.Interface, clientName string, slice *resourcev1alpha2.ResourceSlice) {
951+
ginkgo.GinkgoHelper()
952+
err := clientSet.ResourceV1alpha2().ResourceSlices().Delete(ctx, slice.Name, metav1.DeleteOptions{})
953+
framework.ExpectNoError(err, fmt.Sprintf("DELETE: %s + %s", clientName, slice.Name))
954+
}
955+
mustFailToCreate := func(clientSet kubernetes.Interface, clientName string, slice *resourcev1alpha2.ResourceSlice, matchError types.GomegaMatcher) {
956+
ginkgo.GinkgoHelper()
957+
_, err := clientSet.ResourceV1alpha2().ResourceSlices().Create(ctx, slice, metav1.CreateOptions{})
958+
gomega.Expect(err).To(matchError, fmt.Sprintf("CREATE: %s + %s", clientName, slice.Name))
959+
}
960+
mustFailToUpdate := func(clientSet kubernetes.Interface, clientName string, slice *resourcev1alpha2.ResourceSlice, matchError types.GomegaMatcher) {
961+
ginkgo.GinkgoHelper()
962+
_, err := clientSet.ResourceV1alpha2().ResourceSlices().Update(ctx, slice, metav1.UpdateOptions{})
963+
gomega.Expect(err).To(matchError, fmt.Sprintf("UPDATE: %s + %s", clientName, slice.Name))
964+
}
965+
mustFailToDelete := func(clientSet kubernetes.Interface, clientName string, slice *resourcev1alpha2.ResourceSlice, matchError types.GomegaMatcher) {
966+
ginkgo.GinkgoHelper()
967+
err := clientSet.ResourceV1alpha2().ResourceSlices().Delete(ctx, slice.Name, metav1.DeleteOptions{})
968+
gomega.Expect(err).To(matchError, fmt.Sprintf("DELETE: %s + %s", clientName, slice.Name))
969+
}
970+
971+
// Create with different clients, keep it in the end.
972+
mustFailToCreate(realNodeClient, "real plugin", fictionalNodeSlice, matchVAPDeniedError)
973+
createdFictionalNodeSlice := mustCreate(fictionalNodeClient, "fictional plugin", fictionalNodeSlice)
974+
975+
// Update with different clients.
976+
mustFailToUpdate(realNodeClient, "real plugin", createdFictionalNodeSlice, matchVAPDeniedError)
977+
createdFictionalNodeSlice = mustUpdate(fictionalNodeClient, "fictional plugin", createdFictionalNodeSlice)
978+
createdFictionalNodeSlice = mustUpdate(f.ClientSet, "admin", createdFictionalNodeSlice)
979+
980+
// Delete with different clients.
981+
mustFailToDelete(realNodeClient, "real plugin", createdFictionalNodeSlice, matchVAPDeniedError)
982+
mustDelete(fictionalNodeClient, "fictional plugin", createdFictionalNodeSlice)
983+
})
984+
894985
f.It("must manage ResourceSlices", f.WithSlow(), func(ctx context.Context) {
895986
driverName := driver.Name
896987

@@ -1100,6 +1191,7 @@ var _ = framework.SIGDescribe("node")("DRA", feature.DynamicResourceAllocation,
11001191
b := newBuilder(f, driver)
11011192
preScheduledTests(b, driver, resourcev1alpha2.AllocationModeImmediate)
11021193
claimTests(b, driver, resourcev1alpha2.AllocationModeImmediate)
1194+
11031195
})
11041196
})
11051197

0 commit comments

Comments
 (0)