diff --git a/internal/apiserver/apiserver.go b/internal/apiserver/apiserver.go index e1710a3b9..726ec1d00 100644 --- a/internal/apiserver/apiserver.go +++ b/internal/apiserver/apiserver.go @@ -38,6 +38,9 @@ var ( type ExtraConfig struct { APIResourceConfigSource serverstorage.APIResourceConfigSource MachinePoolletConfig client.MachinePoolletClientConfig + + //GroupsToShowPoolResources define the group membership to show the pool resources + GroupsToShowPoolResources []string } // Config defines the config for the apiserver @@ -98,9 +101,12 @@ func (c completedConfig) New() (*OnmetalAPIServer, error) { corerest.StorageProvider{}, computerest.StorageProvider{ MachinePoolletClientConfig: c.ExtraConfig.MachinePoolletConfig, + GroupsToShowPoolResources: c.ExtraConfig.GroupsToShowPoolResources, }, networkingrest.StorageProvider{}, - storagerest.StorageProvider{}, + storagerest.StorageProvider{ + GroupsToShowPoolResources: c.ExtraConfig.GroupsToShowPoolResources, + }, } var apiGroupsInfos []*genericapiserver.APIGroupInfo diff --git a/internal/app/apiserver/apiserver.go b/internal/app/apiserver/apiserver.go index dd628fbb3..9e0870e74 100644 --- a/internal/app/apiserver/apiserver.go +++ b/internal/app/apiserver/apiserver.go @@ -80,6 +80,8 @@ type OnmetalAPIServerOptions struct { MachinePoolletConfig client.MachinePoolletClientConfig SharedInformerFactory informers.SharedInformerFactory + + GroupsToShowPoolResources []string } func (o *OnmetalAPIServerOptions) AddFlags(fs *pflag.FlagSet) { @@ -100,6 +102,9 @@ func (o *OnmetalAPIServerOptions) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&o.MachinePoolletConfig.CAFile, "machinepoollet-certificate-authority", o.MachinePoolletConfig.CAFile, "Path to a cert file for the certificate authority.") + + fs.StringSliceVar(&o.GroupsToShowPoolResources, "pool-status-view-allowed-groups", o.GroupsToShowPoolResources, + "Groups needed to show pool resources.") } func NewOnmetalAPIServerOptions() *OnmetalAPIServerOptions { @@ -237,8 +242,9 @@ func (o *OnmetalAPIServerOptions) Config() (*apiserver.Config, error) { config := &apiserver.Config{ GenericConfig: serverConfig, ExtraConfig: apiserver.ExtraConfig{ - APIResourceConfigSource: apiResourceConfig, - MachinePoolletConfig: o.MachinePoolletConfig, + APIResourceConfigSource: apiResourceConfig, + MachinePoolletConfig: o.MachinePoolletConfig, + GroupsToShowPoolResources: o.GroupsToShowPoolResources, }, } diff --git a/internal/app/app_suite_test.go b/internal/app/app_suite_test.go index 6f61b3676..3214371a3 100644 --- a/internal/app/app_suite_test.go +++ b/internal/app/app_suite_test.go @@ -49,8 +49,10 @@ const ( ) var ( - cfg *rest.Config - k8sClient client.Client + cfg *rest.Config + k8sClient client.Client + elevatedK8sClient client.Client + testEnv *envtest.Environment testEnvExt *utilsenvtest.EnvironmentExtensions ) @@ -95,13 +97,16 @@ var _ = BeforeSuite(func() { komega.SetClient(k8sClient) + const showResourcesGroup = "test-resource-group" apiSrv, err := apiserver.New(cfg, apiserver.Options{ - MainPath: "github.com/onmetal/onmetal-api/cmd/onmetal-apiserver", - BuildOptions: []buildutils.BuildOption{buildutils.ModModeMod}, - ETCDServers: []string{testEnv.ControlPlane.Etcd.URL.String()}, - Host: testEnvExt.APIServiceInstallOptions.LocalServingHost, - Port: testEnvExt.APIServiceInstallOptions.LocalServingPort, - CertDir: testEnvExt.APIServiceInstallOptions.LocalServingCertDir, + MainPath: "github.com/onmetal/onmetal-api/cmd/onmetal-apiserver", + BuildOptions: []buildutils.BuildOption{buildutils.ModModeMod}, + ETCDServers: []string{testEnv.ControlPlane.Etcd.URL.String()}, + Host: testEnvExt.APIServiceInstallOptions.LocalServingHost, + Port: testEnvExt.APIServiceInstallOptions.LocalServingPort, + CertDir: testEnvExt.APIServiceInstallOptions.LocalServingCertDir, + AttachOutput: true, + GroupsToShowPoolResources: []string{showResourcesGroup}, }) Expect(err).NotTo(HaveOccurred()) @@ -110,6 +115,19 @@ var _ = BeforeSuite(func() { err = utilsenvtest.WaitUntilAPIServicesReadyWithTimeout(apiServiceTimeout, testEnvExt, k8sClient, scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + + usr, err := testEnv.AddUser(envtest.User{ + Name: "elevated-user", + Groups: []string{ + "system:masters", + "system:authenticated", + showResourcesGroup, + }, + }, nil) + Expect(err).NotTo(HaveOccurred()) + + elevatedK8sClient, err = client.New(usr.Config(), client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) }) func SetupTest(ctx context.Context) *corev1.Namespace { diff --git a/internal/app/compute_test.go b/internal/app/compute_test.go index c4ad4e67b..1ed1d7d8f 100644 --- a/internal/app/compute_test.go +++ b/internal/app/compute_test.go @@ -25,6 +25,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/pointer" "sigs.k8s.io/controller-runtime/pkg/client" + . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" ) var _ = Describe("Compute", func() { @@ -304,4 +305,62 @@ var _ = Describe("Compute", func() { Expect(machineList.Items).To(ConsistOf(HaveField("UID", machine2.UID))) }) }) + + Context("MachinePool resources", func() { + It("should be masked", func() { + By("creating a new machine pool") + machinePool := &computev1alpha1.MachinePool{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "machine-pool-", + }, + Spec: computev1alpha1.MachinePoolSpec{ + ProviderID: "test", + }, + } + Expect(k8sClient.Create(ctx, machinePool)).To(Succeed()) + + By("patching the status") + Eventually(UpdateStatus(machinePool, func() { + machinePool.Status.Capacity = corev1alpha1.ResourceList{ + corev1alpha1.ClassCountFor(corev1alpha1.ClassTypeMachineClass, "test-class"): resource.MustParse("10"), + } + machinePool.Status.Allocatable = corev1alpha1.ResourceList{ + corev1alpha1.ClassCountFor(corev1alpha1.ClassTypeMachineClass, "test-class"): resource.MustParse("5"), + } + })).Should(Succeed()) + + By("checking that the resources are hidden by using GET") + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(machinePool), machinePool)).To(Succeed()) + Expect(machinePool.Status.Allocatable).To(BeNil()) + Expect(machinePool.Status.Capacity).To(BeNil()) + + By("checking that the resources are hidden by using LIST") + machinePools := &computev1alpha1.MachinePoolList{} + Expect(k8sClient.List(ctx, machinePools)).To(Succeed()) + Expect(machinePools.Items).To(ContainElement(SatisfyAll( + HaveField("Status.Allocatable", BeNil()), + HaveField("Status.Capacity", BeNil()), + ))) + + By("checking that the resources are shown using elevated user by using GET") + Expect(elevatedK8sClient.Get(ctx, client.ObjectKeyFromObject(machinePool), machinePool)).To(Succeed()) + Expect(machinePool.Status.Capacity).To(Equal(corev1alpha1.ResourceList{ + corev1alpha1.ClassCountFor(corev1alpha1.ClassTypeMachineClass, "test-class"): resource.MustParse("10"), + })) + Expect(machinePool.Status.Allocatable).To(Equal(corev1alpha1.ResourceList{ + corev1alpha1.ClassCountFor(corev1alpha1.ClassTypeMachineClass, "test-class"): resource.MustParse("5"), + })) + + By("checking that the resources are shown using elevated user by using LIST") + Expect(elevatedK8sClient.List(ctx, machinePools)).To(Succeed()) + Expect(machinePools.Items).To(ContainElement(SatisfyAll( + HaveField("Status.Capacity", Equal(corev1alpha1.ResourceList{ + corev1alpha1.ClassCountFor(corev1alpha1.ClassTypeMachineClass, "test-class"): resource.MustParse("10"), + })), + HaveField("Status.Allocatable", Equal(corev1alpha1.ResourceList{ + corev1alpha1.ClassCountFor(corev1alpha1.ClassTypeMachineClass, "test-class"): resource.MustParse("5"), + })), + ))) + }) + }) }) diff --git a/internal/app/storage_test.go b/internal/app/storage_test.go index 17c7ed476..61bb0ce98 100644 --- a/internal/app/storage_test.go +++ b/internal/app/storage_test.go @@ -24,6 +24,7 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" + . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" ) var _ = Describe("Storage", func() { @@ -208,6 +209,64 @@ var _ = Describe("Storage", func() { }) }) + Context("VolumePool resources", func() { + It("should be masked", func() { + By("creating a new volume pool") + volumePool := &storagev1alpha1.VolumePool{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "volume-pool-", + }, + Spec: storagev1alpha1.VolumePoolSpec{ + ProviderID: "test", + }, + } + Expect(k8sClient.Create(ctx, volumePool)).To(Succeed()) + + By("patching the status") + Eventually(UpdateStatus(volumePool, func() { + volumePool.Status.Capacity = corev1alpha1.ResourceList{ + corev1alpha1.ResourceStorage: resource.MustParse("10Gi"), + } + volumePool.Status.Allocatable = corev1alpha1.ResourceList{ + corev1alpha1.ResourceStorage: resource.MustParse("5Gi"), + } + })).Should(Succeed()) + + By("checking that the resources are hidden by using GET") + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(volumePool), volumePool)).To(Succeed()) + Expect(volumePool.Status.Allocatable).To(BeNil()) + Expect(volumePool.Status.Capacity).To(BeNil()) + + By("checking that the resources are hidden by using LIST") + volumePools := &storagev1alpha1.VolumePoolList{} + Expect(k8sClient.List(ctx, volumePools)).To(Succeed()) + Expect(volumePools.Items).To(ContainElement(SatisfyAll( + HaveField("Status.Allocatable", BeNil()), + HaveField("Status.Capacity", BeNil()), + ))) + + By("checking that the resources are shown using elevated user by using GET") + Expect(elevatedK8sClient.Get(ctx, client.ObjectKeyFromObject(volumePool), volumePool)).To(Succeed()) + Expect(volumePool.Status.Capacity).To(Equal(corev1alpha1.ResourceList{ + corev1alpha1.ResourceStorage: resource.MustParse("10Gi"), + })) + Expect(volumePool.Status.Allocatable).To(Equal(corev1alpha1.ResourceList{ + corev1alpha1.ResourceStorage: resource.MustParse("5Gi"), + })) + + By("checking that the resources are shown using elevated user by using LIST") + Expect(elevatedK8sClient.List(ctx, volumePools)).To(Succeed()) + Expect(volumePools.Items).To(ContainElement(SatisfyAll( + HaveField("Status.Capacity", Equal(corev1alpha1.ResourceList{ + corev1alpha1.ResourceStorage: resource.MustParse("10Gi"), + })), + HaveField("Status.Allocatable", Equal(corev1alpha1.ResourceList{ + corev1alpha1.ResourceStorage: resource.MustParse("5Gi"), + })), + ))) + }) + }) + Context("Bucket", func() { It("should allow listing buckets filtering by bucket pool name", func() { const ( diff --git a/internal/rbac/rbac.go b/internal/rbac/rbac.go new file mode 100644 index 000000000..58361a678 --- /dev/null +++ b/internal/rbac/rbac.go @@ -0,0 +1,41 @@ +// Copyright 2023 OnMetal 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 rbac + +import ( + "context" + + "golang.org/x/exp/slices" + "k8s.io/apiserver/pkg/endpoints/request" +) + +// UserIsMemberOf returns true if the ctx contains user.Info and if the user is member of one of the provided groups +func UserIsMemberOf(ctx context.Context, groups []string) bool { + if groups == nil { + return true + } + + user, ok := request.UserFrom(ctx) + if !ok { + return false + } + + for _, group := range groups { + if slices.Contains(user.GetGroups(), group) { + return true + } + } + return false +} diff --git a/internal/rbac/rbac_test.go b/internal/rbac/rbac_test.go new file mode 100644 index 000000000..3eb12d007 --- /dev/null +++ b/internal/rbac/rbac_test.go @@ -0,0 +1,95 @@ +// Copyright 2023 OnMetal 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 rbac + +import ( + "context" + "testing" + + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/endpoints/request" +) + +func TestUserIsMemberOf(t *testing.T) { + type args struct { + ctx context.Context + groups []string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "User is always part of empty groups - 1", + args: args{ + ctx: request.WithUser(request.NewContext(), &user.DefaultInfo{}), + groups: nil, + }, + want: true, + }, + { + name: "User is always part of empty groups - 2", + args: args{ + ctx: request.WithUser(request.NewContext(), &user.DefaultInfo{ + Name: "test", + Groups: []string{"group-1"}, + Extra: nil, + }), + groups: nil, + }, + want: true, + }, + { + name: "User is member of one of the required groups", + args: args{ + ctx: request.WithUser(request.NewContext(), &user.DefaultInfo{ + Name: "test", + Groups: []string{"group-1"}, + Extra: nil, + }), + groups: []string{"group-1", "group-2", "group-3"}, + }, + want: true, + }, + { + name: "User is not member of one of the required groups", + args: args{ + ctx: request.WithUser(request.NewContext(), &user.DefaultInfo{ + Name: "test", + Groups: []string{"group-1"}, + Extra: nil, + }), + groups: []string{"group-2", "group-3"}, + }, + want: false, + }, + { + name: "Context without user is not part of groups", + args: args{ + ctx: request.NewContext(), + groups: []string{"group-1", "group-2", "group-3"}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := UserIsMemberOf(tt.args.ctx, tt.args.groups); got != tt.want { + t.Errorf("UserIsMemberOf() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/registry/compute/machinepool/storage/storage.go b/internal/registry/compute/machinepool/storage/storage.go index 12ae48cf5..40365b642 100644 --- a/internal/registry/compute/machinepool/storage/storage.go +++ b/internal/registry/compute/machinepool/storage/storage.go @@ -16,8 +16,12 @@ package storage import ( "context" + "errors" "fmt" + "github.com/onmetal/onmetal-api/internal/rbac" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + computev1alpha1 "github.com/onmetal/onmetal-api/api/compute/v1alpha1" "github.com/onmetal/onmetal-api/internal/apis/compute" "github.com/onmetal/onmetal-api/internal/apis/compute/v1alpha1" @@ -33,6 +37,48 @@ import ( type REST struct { *genericregistry.Store + groupsToShowPoolResources []string +} + +func (e *REST) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + obj, err := e.Store.Get(ctx, name, options) + if err != nil { + return nil, err + } + + if rbac.UserIsMemberOf(ctx, e.groupsToShowPoolResources) { + return obj, nil + } + + pool, ok := obj.(*compute.MachinePool) + if !ok { + return nil, errors.New("failed to ") + } + pool.Status.Allocatable = nil + pool.Status.Capacity = nil + + return pool, nil +} + +func (e *REST) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) { + println("list") + objList, err := e.Store.List(ctx, options) + + if rbac.UserIsMemberOf(ctx, e.groupsToShowPoolResources) { + return objList, err + } + + pools, ok := objList.(*compute.MachinePoolList) + if !ok { + return nil, err + } + + for index := range pools.Items { + pools.Items[index].Status.Allocatable = nil + pools.Items[index].Status.Capacity = nil + } + + return pools, nil } type MachinePoolStorage struct { @@ -41,7 +87,7 @@ type MachinePoolStorage struct { MachinePoolletConnectionInfo client.ConnectionInfoGetter } -func NewStorage(optsGetter generic.RESTOptionsGetter, machinePoolletClientConfig client.MachinePoolletClientConfig) (MachinePoolStorage, error) { +func NewStorage(optsGetter generic.RESTOptionsGetter, machinePoolletClientConfig client.MachinePoolletClientConfig, groupsToShowPoolResources []string) (MachinePoolStorage, error) { store := &genericregistry.Store{ NewFunc: func() runtime.Object { return &compute.MachinePool{} @@ -69,7 +115,10 @@ func NewStorage(optsGetter generic.RESTOptionsGetter, machinePoolletClientConfig statusStore.UpdateStrategy = machinepool.StatusStrategy statusStore.ResetFieldsStrategy = machinepool.StatusStrategy - machinePoolRest := &REST{store} + machinePoolRest := &REST{ + Store: store, + groupsToShowPoolResources: groupsToShowPoolResources, + } statusRest := &StatusREST{&statusStore} // Build a MachinePoolGetter that looks up nodes using the REST handler diff --git a/internal/registry/compute/rest/rest.go b/internal/registry/compute/rest/rest.go index 4f4ae1842..11e313f30 100644 --- a/internal/registry/compute/rest/rest.go +++ b/internal/registry/compute/rest/rest.go @@ -32,6 +32,7 @@ import ( type StorageProvider struct { MachinePoolletClientConfig machinepoolletclient.MachinePoolletClientConfig + GroupsToShowPoolResources []string } func (p StorageProvider) GroupName() string { @@ -62,7 +63,7 @@ func (p StorageProvider) v1alpha1Storage(restOptionsGetter generic.RESTOptionsGe storageMap["machineclasses"] = machineClassStorage.MachineClass - machinePoolStorage, err := machinepoolstorage.NewStorage(restOptionsGetter, p.MachinePoolletClientConfig) + machinePoolStorage, err := machinepoolstorage.NewStorage(restOptionsGetter, p.MachinePoolletClientConfig, p.GroupsToShowPoolResources) if err != nil { return storageMap, err } diff --git a/internal/registry/storage/rest/rest.go b/internal/registry/storage/rest/rest.go index c78cae3cf..f66412ff3 100644 --- a/internal/registry/storage/rest/rest.go +++ b/internal/registry/storage/rest/rest.go @@ -33,7 +33,9 @@ import ( serverstorage "k8s.io/apiserver/pkg/server/storage" ) -type StorageProvider struct{} +type StorageProvider struct { + GroupsToShowPoolResources []string +} func (p StorageProvider) GroupName() string { return storage.SchemeGroupVersion.Group @@ -63,7 +65,7 @@ func (p StorageProvider) v1alpha1Storage(restOptionsGetter generic.RESTOptionsGe storageMap["volumeclasses"] = volumeClassStorage.VolumeClass - volumePoolStorage, err := volumepoolstorage.NewStorage(restOptionsGetter) + volumePoolStorage, err := volumepoolstorage.NewStorage(restOptionsGetter, p.GroupsToShowPoolResources) if err != nil { return storageMap, err } diff --git a/internal/registry/storage/volumepool/storage/storage.go b/internal/registry/storage/volumepool/storage/storage.go index bd6ba840b..e897707f3 100644 --- a/internal/registry/storage/volumepool/storage/storage.go +++ b/internal/registry/storage/volumepool/storage/storage.go @@ -18,7 +18,9 @@ import ( "context" "github.com/onmetal/onmetal-api/internal/apis/storage" + "github.com/onmetal/onmetal-api/internal/rbac" "github.com/onmetal/onmetal-api/internal/registry/storage/volumepool" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/generic" @@ -29,6 +31,47 @@ import ( type REST struct { *genericregistry.Store + groupsToShowPoolResources []string +} + +func (e *REST) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + obj, err := e.Store.Get(ctx, name, options) + if err != nil { + return nil, err + } + + if rbac.UserIsMemberOf(ctx, e.groupsToShowPoolResources) { + return obj, err + } + + pool, ok := obj.(*storage.VolumePool) + if !ok { + return nil, err + } + pool.Status.Allocatable = nil + pool.Status.Capacity = nil + + return pool, nil +} + +func (e *REST) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) { + objList, err := e.Store.List(ctx, options) + + if rbac.UserIsMemberOf(ctx, e.groupsToShowPoolResources) { + return objList, err + } + + pools, ok := objList.(*storage.VolumePoolList) + if !ok { + return nil, err + } + + for index := range pools.Items { + pools.Items[index].Status.Allocatable = nil + pools.Items[index].Status.Capacity = nil + } + + return pools, nil } type VolumePoolStorage struct { @@ -36,7 +79,7 @@ type VolumePoolStorage struct { Status *StatusREST } -func NewStorage(optsGetter generic.RESTOptionsGetter) (VolumePoolStorage, error) { +func NewStorage(optsGetter generic.RESTOptionsGetter, groupsToShowPoolResources []string) (VolumePoolStorage, error) { store := &genericregistry.Store{ NewFunc: func() runtime.Object { return &storage.VolumePool{} @@ -65,8 +108,11 @@ func NewStorage(optsGetter generic.RESTOptionsGetter) (VolumePoolStorage, error) statusStore.ResetFieldsStrategy = volumepool.StatusStrategy return VolumePoolStorage{ - VolumePool: &REST{store}, - Status: &StatusREST{&statusStore}, + VolumePool: &REST{ + Store: store, + groupsToShowPoolResources: groupsToShowPoolResources, + }, + Status: &StatusREST{&statusStore}, }, nil } diff --git a/utils/envtest/apiserver/apiserver.go b/utils/envtest/apiserver/apiserver.go index 8e3651c44..58dd6304b 100644 --- a/utils/envtest/apiserver/apiserver.go +++ b/utils/envtest/apiserver/apiserver.go @@ -93,10 +93,12 @@ type Options struct { HealthTimeout time.Duration WaitTimeout time.Duration + + GroupsToShowPoolResources []string } func MergeArgs(customArgs, defaultArgs ProcessArgs) ProcessArgs { - res := make(ProcessArgs) + res := EmptyProcessArgs() for key, value := range defaultArgs { res[key] = value } @@ -127,7 +129,11 @@ func setAPIServerOptionsDefaults(opts *Options) { opts.Stderr = os.Stderr } if opts.Args == nil { - opts.Args = make(ProcessArgs) + opts.Args = EmptyProcessArgs() + } + + if opts.GroupsToShowPoolResources != nil { + opts.Args.Set("pool-status-view-allowed-groups", opts.GroupsToShowPoolResources...) } }