From 14af777a37708c4d3dec32f3a2e4d49917265535 Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Mon, 17 Nov 2025 16:15:39 -0500 Subject: [PATCH 1/4] feat: add accesscontrol rest client Signed-off-by: Calum Murray --- pkg/kubernetes/accesscontrol_restclient.go | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 pkg/kubernetes/accesscontrol_restclient.go diff --git a/pkg/kubernetes/accesscontrol_restclient.go b/pkg/kubernetes/accesscontrol_restclient.go new file mode 100644 index 00000000..79a2390d --- /dev/null +++ b/pkg/kubernetes/accesscontrol_restclient.go @@ -0,0 +1,61 @@ +package kubernetes + +import ( + "fmt" + "net/http" + "strings" + + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type AccessControlRoundTripper struct { + delegate http.RoundTripper + accessControlRESTMapper *AccessControlRESTMapper +} + +func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + gvr, err := parseURLToGVR(req.URL.Path) + if err != nil { + return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to parse url: %w", err) + } + + _, err = rt.accessControlRESTMapper.KindFor(gvr) + if err != nil { + return nil, fmt.Errorf("not allowed to access resource: %v", gvr) + } + + return rt.delegate.RoundTrip(req) +} + +func parseURLToGVR(path string) (schema.GroupVersionResource, error) { + parts := strings.Split(strings.Trim(path, "/"), "/") + + if len(parts) < 3 { + return schema.GroupVersionResource{}, fmt.Errorf("not an api path: %s", path) + } + + gvr := schema.GroupVersionResource{} + + switch parts[0] { + case "api": + gvr.Group = "" + gvr.Version = parts[1] + if parts[2] == "namespaces" && len(parts) > 4 { + gvr.Resource = parts[4] + } else { + gvr.Resource = parts[2] + } + case "apis": + gvr.Group = parts[1] + gvr.Version = parts[2] + if parts[3] == "namespaces" && len(parts) > 5 { + gvr.Resource = parts[5] + } else { + gvr.Resource = parts[3] + } + default: + return schema.GroupVersionResource{}, fmt.Errorf("unknown prefix: %s", parts[0]) + } + + return gvr, nil +} From 87351bb6209e9a070bc059e6910186c3dd5f13ae Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Mon, 17 Nov 2025 16:21:32 -0500 Subject: [PATCH 2/4] feat: expose accesscontrol rest client through Kubernetes interface Signed-off-by: Calum Murray --- pkg/kubernetes/kubernetes.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 7de8d6ff..dfd925d9 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -1,13 +1,16 @@ package kubernetes import ( - "k8s.io/apimachinery/pkg/runtime" + "net/http" - "github.com/containers/kubernetes-mcp-server/pkg/helm" - "github.com/containers/kubernetes-mcp-server/pkg/kiali" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" + + "github.com/containers/kubernetes-mcp-server/pkg/helm" + "github.com/containers/kubernetes-mcp-server/pkg/kiali" ) type HeaderKey string @@ -25,6 +28,28 @@ type Kubernetes struct { manager *Manager } +// AccessControlRestClient returns the access-controlled rest.Interface +// This ensures that any denied resources configured in the system are properly enforced +func (k *Kubernetes) AccessControlRestClient() (rest.Interface, error) { + config, err := k.manager.ToRESTConfig() + if err != nil { + return nil, err + } + config.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { + return &AccessControlRoundTripper{ + delegate: rt, + accessControlRESTMapper: k.manager.accessControlRESTMapper, + } + } + + client, err := rest.RESTClientFor(config) + if err != nil { + return nil, err + } + + return client, nil +} + // AccessControlClientset returns the access-controlled clientset // This ensures that any denied resources configured in the system are properly enforced func (k *Kubernetes) AccessControlClientset() *AccessControlClientset { From f0aa29813e34f09aead4b61799e85d917f9ed67c Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Wed, 19 Nov 2025 11:57:19 +0100 Subject: [PATCH 3/4] feat(kubernetes): access control round tripper Signed-off-by: Marc Nuri --- pkg/kubernetes/accesscontrol_restclient.go | 61 ----- pkg/kubernetes/accesscontrol_restmapper.go | 80 ------ pkg/kubernetes/accesscontrol_round_tripper.go | 70 +++++ .../accesscontrol_round_tripper_test.go | 247 ++++++++++++++++++ pkg/kubernetes/kubernetes.go | 26 -- pkg/kubernetes/kubernetes_derived_test.go | 4 +- pkg/kubernetes/manager.go | 37 +-- pkg/kubernetes/resources.go | 7 +- pkg/mcp/helm_test.go | 35 +++ 9 files changed, 378 insertions(+), 189 deletions(-) delete mode 100644 pkg/kubernetes/accesscontrol_restclient.go delete mode 100644 pkg/kubernetes/accesscontrol_restmapper.go create mode 100644 pkg/kubernetes/accesscontrol_round_tripper.go create mode 100644 pkg/kubernetes/accesscontrol_round_tripper_test.go diff --git a/pkg/kubernetes/accesscontrol_restclient.go b/pkg/kubernetes/accesscontrol_restclient.go deleted file mode 100644 index 79a2390d..00000000 --- a/pkg/kubernetes/accesscontrol_restclient.go +++ /dev/null @@ -1,61 +0,0 @@ -package kubernetes - -import ( - "fmt" - "net/http" - "strings" - - "k8s.io/apimachinery/pkg/runtime/schema" -) - -type AccessControlRoundTripper struct { - delegate http.RoundTripper - accessControlRESTMapper *AccessControlRESTMapper -} - -func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - gvr, err := parseURLToGVR(req.URL.Path) - if err != nil { - return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to parse url: %w", err) - } - - _, err = rt.accessControlRESTMapper.KindFor(gvr) - if err != nil { - return nil, fmt.Errorf("not allowed to access resource: %v", gvr) - } - - return rt.delegate.RoundTrip(req) -} - -func parseURLToGVR(path string) (schema.GroupVersionResource, error) { - parts := strings.Split(strings.Trim(path, "/"), "/") - - if len(parts) < 3 { - return schema.GroupVersionResource{}, fmt.Errorf("not an api path: %s", path) - } - - gvr := schema.GroupVersionResource{} - - switch parts[0] { - case "api": - gvr.Group = "" - gvr.Version = parts[1] - if parts[2] == "namespaces" && len(parts) > 4 { - gvr.Resource = parts[4] - } else { - gvr.Resource = parts[2] - } - case "apis": - gvr.Group = parts[1] - gvr.Version = parts[2] - if parts[3] == "namespaces" && len(parts) > 5 { - gvr.Resource = parts[5] - } else { - gvr.Resource = parts[3] - } - default: - return schema.GroupVersionResource{}, fmt.Errorf("unknown prefix: %s", parts[0]) - } - - return gvr, nil -} diff --git a/pkg/kubernetes/accesscontrol_restmapper.go b/pkg/kubernetes/accesscontrol_restmapper.go deleted file mode 100644 index 06269480..00000000 --- a/pkg/kubernetes/accesscontrol_restmapper.go +++ /dev/null @@ -1,80 +0,0 @@ -package kubernetes - -import ( - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/restmapper" - - "github.com/containers/kubernetes-mcp-server/pkg/config" -) - -type AccessControlRESTMapper struct { - delegate *restmapper.DeferredDiscoveryRESTMapper - staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice -} - -var _ meta.RESTMapper = &AccessControlRESTMapper{} - -func (a AccessControlRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) { - gvk, err := a.delegate.KindFor(resource) - if err != nil { - return schema.GroupVersionKind{}, err - } - if !isAllowed(a.staticConfig, &gvk) { - return schema.GroupVersionKind{}, isNotAllowedError(&gvk) - } - return gvk, nil -} - -func (a AccessControlRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) { - gvks, err := a.delegate.KindsFor(resource) - if err != nil { - return nil, err - } - for i := range gvks { - if !isAllowed(a.staticConfig, &gvks[i]) { - return nil, isNotAllowedError(&gvks[i]) - } - } - return gvks, nil -} - -func (a AccessControlRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) { - return a.delegate.ResourceFor(input) -} - -func (a AccessControlRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) { - return a.delegate.ResourcesFor(input) -} - -func (a AccessControlRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) { - for _, version := range versions { - gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - } - return a.delegate.RESTMapping(gk, versions...) -} - -func (a AccessControlRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) { - for _, version := range versions { - gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - } - return a.delegate.RESTMappings(gk, versions...) -} - -func (a AccessControlRESTMapper) ResourceSingularizer(resource string) (singular string, err error) { - return a.delegate.ResourceSingularizer(resource) -} - -func (a AccessControlRESTMapper) Reset() { - a.delegate.Reset() -} - -func NewAccessControlRESTMapper(delegate *restmapper.DeferredDiscoveryRESTMapper, staticConfig *config.StaticConfig) *AccessControlRESTMapper { - return &AccessControlRESTMapper{delegate: delegate, staticConfig: staticConfig} -} diff --git a/pkg/kubernetes/accesscontrol_round_tripper.go b/pkg/kubernetes/accesscontrol_round_tripper.go new file mode 100644 index 00000000..909568c2 --- /dev/null +++ b/pkg/kubernetes/accesscontrol_round_tripper.go @@ -0,0 +1,70 @@ +package kubernetes + +import ( + "fmt" + "net/http" + "strings" + + "github.com/containers/kubernetes-mcp-server/pkg/config" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type AccessControlRoundTripper struct { + delegate http.RoundTripper + staticConfig *config.StaticConfig + restMapper meta.RESTMapper +} + +func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + gvr, ok := parseURLToGVR(req.URL.Path) + // Not an API resource request, just pass through + if !ok { + return rt.delegate.RoundTrip(req) + } + + gvk, err := rt.restMapper.KindFor(gvr) + if err != nil { + return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to get kind for gvr %v: %w", gvr, err) + } + if !isAllowed(rt.staticConfig, &gvk) { + return nil, isNotAllowedError(&gvk) + } + + return rt.delegate.RoundTrip(req) +} + +func parseURLToGVR(path string) (gvr schema.GroupVersionResource, ok bool) { + parts := strings.Split(strings.Trim(path, "/"), "/") + + gvr = schema.GroupVersionResource{} + switch parts[0] { + case "api": + // /api or /api/v1 are discovery endpoints + if len(parts) < 3 { + return + } + gvr.Group = "" + gvr.Version = parts[1] + if parts[2] == "namespaces" && len(parts) > 4 { + gvr.Resource = parts[4] + } else { + gvr.Resource = parts[2] + } + case "apis": + // /apis, /apis/apps, or /apis/apps/v1 are discovery endpoints + if len(parts) < 4 { + return + } + gvr.Group = parts[1] + gvr.Version = parts[2] + if parts[3] == "namespaces" && len(parts) > 5 { + gvr.Resource = parts[5] + } else { + gvr.Resource = parts[3] + } + default: + return + } + return gvr, true +} diff --git a/pkg/kubernetes/accesscontrol_round_tripper_test.go b/pkg/kubernetes/accesscontrol_round_tripper_test.go new file mode 100644 index 00000000..8706df20 --- /dev/null +++ b/pkg/kubernetes/accesscontrol_round_tripper_test.go @@ -0,0 +1,247 @@ +package kubernetes + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/BurntSushi/toml" + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/stretchr/testify/suite" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/restmapper" +) + +type mockRoundTripper struct { + called *bool + onRequest func(w http.ResponseWriter, r *http.Request) +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + *m.called = true + rec := httptest.NewRecorder() + m.onRequest(rec, req) + return rec.Result(), nil +} + +type AccessControlRoundTripperTestSuite struct { + suite.Suite + mockServer *test.MockServer + restMapper *restmapper.DeferredDiscoveryRESTMapper +} + +func (s *AccessControlRoundTripperTestSuite) SetupTest() { + s.mockServer = test.NewMockServer() + s.mockServer.Handle(&test.DiscoveryClientHandler{}) + + clientSet, err := kubernetes.NewForConfig(s.mockServer.Config()) + s.Require().NoError(err, "Expected no error creating clientset") + + s.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(clientSet.Discovery())) +} + +func (s *AccessControlRoundTripperTestSuite) TearDownTest() { + s.mockServer.Close() +} + +func (s *AccessControlRoundTripperTestSuite) TestRoundTripForNonAPIResources() { + delegateCalled := false + mockDelegate := &mockRoundTripper{ + called: &delegateCalled, + onRequest: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + } + + rt := &AccessControlRoundTripper{ + delegate: mockDelegate, + staticConfig: nil, + restMapper: s.restMapper, + } + + testCases := []string{"healthz", "readyz", "livez", "metrics", "version"} + for _, testCase := range testCases { + s.Run("/"+testCase+" check endpoint bypasses access control", func() { + delegateCalled = false + resp, err := rt.RoundTrip(httptest.NewRequest("GET", "/"+testCase, nil)) + s.NoError(err) + s.NotNil(resp) + s.Equal(http.StatusOK, resp.StatusCode) + s.Truef(delegateCalled, "Expected delegate to be called for /%s", testCase) + }) + } +} + +func (s *AccessControlRoundTripperTestSuite) TestRoundTripForDiscoveryRequests() { + delegateCalled := false + mockDelegate := &mockRoundTripper{ + called: &delegateCalled, + onRequest: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + } + + rt := &AccessControlRoundTripper{ + delegate: mockDelegate, + staticConfig: nil, + restMapper: s.restMapper, + } + + testCases := []string{"/api", "/apis", "/api/v1", "/api/v1/", "/apis/apps", "/apis/apps/v1", "/apis/batch/v1"} + for _, testCase := range testCases { + s.Run("API Discovery endpoint "+testCase+" bypasses access control", func() { + delegateCalled = false + resp, err := rt.RoundTrip(httptest.NewRequest("GET", testCase, nil)) + s.NoError(err) + s.NotNil(resp) + s.Equal(http.StatusOK, resp.StatusCode) + s.True(delegateCalled, "Expected delegate to be called for /api") + }) + } +} + +func (s *AccessControlRoundTripperTestSuite) TestRoundTripForAllowedAPIResources() { + delegateCalled := false + mockDelegate := &mockRoundTripper{ + called: &delegateCalled, + onRequest: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + } + + rt := &AccessControlRoundTripper{ + delegate: mockDelegate, + staticConfig: nil, // nil config allows all resources + restMapper: s.restMapper, + } + + s.Run("List all pods is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/pods", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.Equal(http.StatusOK, resp.StatusCode) + s.True(delegateCalled, "Expected delegate to be called for listing pods") + }) + + s.Run("List pods in namespace is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/namespaces/default/pods", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.True(delegateCalled, "Expected delegate to be called for namespaced pods list") + }) + + s.Run("Get specific pod is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/namespaces/default/pods/my-pod", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.True(delegateCalled, "Expected delegate to be called for getting specific pod") + }) + + s.Run("Resource path with trailing slash is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/pods/", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.True(delegateCalled, "Expected delegate to be called for path with trailing slash") + }) + + s.Run("List Deployments is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/apis/apps/v1/deployments", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.True(delegateCalled, "Expected delegate to be called for listing deployments") + }) + + s.Run("List Deployments in namespace is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/apis/apps/v1/namespaces/default/deployments", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.True(delegateCalled, "Expected delegate to be called for namespaced deployments list") + }) +} + +func (s *AccessControlRoundTripperTestSuite) TestRoundTripForDeniedAPIResources() { + delegateCalled := false + mockDelegate := &mockRoundTripper{ + called: &delegateCalled, + onRequest: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + } + rt := &AccessControlRoundTripper{ + delegate: mockDelegate, + staticConfig: config.Default(), + restMapper: s.restMapper, + } + + s.Run("Specific resource kind is denied", func() { + s.Require().NoError(toml.Unmarshal([]byte(` + denied_resources = [ { version = "v1", kind = "Pod" } ] + `), rt.staticConfig), "Expected to parse denied resources config") + + s.Run("List pods is denied", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/pods", nil) + resp, err := rt.RoundTrip(req) + s.Error(err) + s.Nil(resp) + s.False(delegateCalled, "Expected delegate not to be called for denied resource") + s.Contains(err.Error(), "resource not allowed") + s.Contains(err.Error(), "Pod") + }) + + s.Run("Get specific pod is denied", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/namespaces/default/pods/my-pod", nil) + resp, err := rt.RoundTrip(req) + s.Error(err) + s.Nil(resp) + s.False(delegateCalled) + s.Contains(err.Error(), "resource not allowed") + }) + }) + + s.Run("Entire group/version is denied", func() { + s.Require().NoError(toml.Unmarshal([]byte(` + denied_resources = [ { version = "v1", kind = "" } ] + `), rt.staticConfig), "Expected to v1 denied resources config") + + s.Run("Pods in core/v1 are denied", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/pods", nil) + resp, err := rt.RoundTrip(req) + s.Error(err) + s.Nil(resp) + s.False(delegateCalled) + }) + + }) + + s.Run("RESTMapper error for unknown resource", func() { + rt.staticConfig = nil + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/unknownresources", nil) + resp, err := rt.RoundTrip(req) + s.Error(err) + s.Nil(resp) + s.False(delegateCalled, "Expected delegate not to be called when RESTMapper fails") + s.Contains(err.Error(), "failed to make request") + }) +} + +func TestAccessControlRoundTripper(t *testing.T) { + suite.Run(t, new(AccessControlRoundTripperTestSuite)) +} diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index dfd925d9..b9d7ea5e 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -1,12 +1,8 @@ package kubernetes import ( - "net/http" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" "github.com/containers/kubernetes-mcp-server/pkg/helm" @@ -28,28 +24,6 @@ type Kubernetes struct { manager *Manager } -// AccessControlRestClient returns the access-controlled rest.Interface -// This ensures that any denied resources configured in the system are properly enforced -func (k *Kubernetes) AccessControlRestClient() (rest.Interface, error) { - config, err := k.manager.ToRESTConfig() - if err != nil { - return nil, err - } - config.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { - return &AccessControlRoundTripper{ - delegate: rt, - accessControlRESTMapper: k.manager.accessControlRESTMapper, - } - } - - client, err := rest.RESTClientFor(config) - if err != nil { - return nil, err - } - - return client, nil -} - // AccessControlClientset returns the access-controlled clientset // This ensures that any denied resources configured in the system are properly enforced func (k *Kubernetes) AccessControlClientset() *AccessControlClientset { diff --git a/pkg/kubernetes/kubernetes_derived_test.go b/pkg/kubernetes/kubernetes_derived_test.go index 69d4ef33..7d0dd90d 100644 --- a/pkg/kubernetes/kubernetes_derived_test.go +++ b/pkg/kubernetes/kubernetes_derived_test.go @@ -124,8 +124,8 @@ users: s.NotNilf(derived.manager.accessControlClientSet, "expected accessControlClientSet to be initialized") s.Equalf(testStaticConfig, derived.manager.accessControlClientSet.staticConfig, "staticConfig not properly wired to derived manager") s.NotNilf(derived.manager.discoveryClient, "expected discoveryClient to be initialized") - s.NotNilf(derived.manager.accessControlRESTMapper, "expected accessControlRESTMapper to be initialized") - s.Equalf(testStaticConfig, derived.manager.accessControlRESTMapper.staticConfig, "staticConfig not properly wired to derived manager") + s.NotNilf(derived.manager.restMapper, "expected accessControlRESTMapper to be initialized") + //s.Equalf(testStaticConfig, derived.manager.re.staticConfig, "staticConfig not properly wired to derived manager") s.NotNilf(derived.manager.dynamicClient, "expected dynamicClient to be initialized") }) }) diff --git a/pkg/kubernetes/manager.go b/pkg/kubernetes/manager.go index d09b8790..d2e5d0fe 100644 --- a/pkg/kubernetes/manager.go +++ b/pkg/kubernetes/manager.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/http" "strings" "github.com/containers/kubernetes-mcp-server/pkg/config" @@ -23,12 +24,12 @@ import ( ) type Manager struct { - cfg *rest.Config - clientCmdConfig clientcmd.ClientConfig - discoveryClient discovery.CachedDiscoveryInterface - accessControlClientSet *AccessControlClientset - accessControlRESTMapper *AccessControlRESTMapper - dynamicClient *dynamic.DynamicClient + cfg *rest.Config + clientCmdConfig clientcmd.ClientConfig + discoveryClient discovery.CachedDiscoveryInterface + restMapper *restmapper.DeferredDiscoveryRESTMapper + accessControlClientSet *AccessControlClientset + dynamicClient *dynamic.DynamicClient staticConfig *config.StaticConfig CloseWatchKubeConfig CloseWatchKubeConfig @@ -117,10 +118,14 @@ func newManager(config *config.StaticConfig, restConfig *rest.Config, clientCmdC return nil, err } k8s.discoveryClient = memory.NewMemCacheClient(k8s.accessControlClientSet.DiscoveryClient()) - k8s.accessControlRESTMapper = NewAccessControlRESTMapper( - restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient), - k8s.staticConfig, - ) + k8s.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient) + k8s.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper { + return &AccessControlRoundTripper{ + delegate: original, + staticConfig: k8s.staticConfig, + restMapper: k8s.restMapper, + } + }) k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg) if err != nil { return nil, err @@ -189,7 +194,7 @@ func (m *Manager) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error } func (m *Manager) ToRESTMapper() (meta.RESTMapper, error) { - return m.accessControlRESTMapper, nil + return m.restMapper, nil } // ToRESTConfig returns the rest.Config object (genericclioptions.RESTClientGetter) @@ -243,8 +248,9 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) { } klog.V(5).Infof("%s header found (Bearer), using provided bearer token", OAuthAuthorizationHeader) derivedCfg := &rest.Config{ - Host: m.cfg.Host, - APIPath: m.cfg.APIPath, + Host: m.cfg.Host, + APIPath: m.cfg.APIPath, + WrapTransport: m.cfg.WrapTransport, // Copy only server verification TLS settings (CA bundle and server name) TLSClientConfig: rest.TLSClientConfig{ Insecure: m.cfg.Insecure, @@ -285,10 +291,7 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) { return &Kubernetes{manager: m}, nil } derived.manager.discoveryClient = memory.NewMemCacheClient(derived.manager.accessControlClientSet.DiscoveryClient()) - derived.manager.accessControlRESTMapper = NewAccessControlRESTMapper( - restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient), - derived.manager.staticConfig, - ) + derived.manager.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient) derived.manager.dynamicClient, err = dynamic.NewForConfig(derived.manager.cfg) if err != nil { if m.staticConfig.RequireOAuth { diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go index 1f559e12..35fc1a93 100644 --- a/pkg/kubernetes/resources.go +++ b/pkg/kubernetes/resources.go @@ -3,10 +3,11 @@ package kubernetes import ( "context" "fmt" - "k8s.io/apimachinery/pkg/runtime" "regexp" "strings" + "k8s.io/apimachinery/pkg/runtime" + "github.com/containers/kubernetes-mcp-server/pkg/version" authv1 "k8s.io/api/authorization/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -153,14 +154,14 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u } // Clear the cache to ensure the next operation is performed on the latest exposed APIs (will change after the CRD creation) if gvk.Kind == "CustomResourceDefinition" { - k.manager.accessControlRESTMapper.Reset() + k.manager.restMapper.Reset() } } return resources, nil } func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) { - m, err := k.manager.accessControlRESTMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version) + m, err := k.manager.restMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version) if err != nil { return nil, err } diff --git a/pkg/mcp/helm_test.go b/pkg/mcp/helm_test.go index f2af3d23..b2b67a08 100644 --- a/pkg/mcp/helm_test.go +++ b/pkg/mcp/helm_test.go @@ -202,6 +202,41 @@ func (s *HelmSuite) TestHelmList() { }) } +func (s *HelmSuite) TestHelmListDenied() { + s.Require().NoError(toml.Unmarshal([]byte(` + denied_resources = [ { version = "v1", kind = "Secret" } ] + `), s.Cfg), "Expected to parse denied resources config") + kc := kubernetes.NewForConfigOrDie(envTestRestConfig) + _, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sh.helm.release.v1.release-to-list-denied", + Labels: map[string]string{"owner": "helm", "name": "release-to-list-denied"}, + }, + Data: map[string][]byte{ + "release": []byte(base64.StdEncoding.EncodeToString([]byte("{" + + "\"name\":\"release-to-list-denied\"," + + "\"info\":{\"status\":\"deployed\"}" + + "}"))), + }, + }, metav1.CreateOptions{}) + s.Require().NoError(err) + s.InitMcpClient() + s.Run("helm_list() with deployed release (denied)", func() { + toolResult, err := s.CallTool("helm_list", map[string]interface{}{}) + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes denial", func() { + msg := toolResult.Content[0].(mcp.TextContent).Text + s.Contains(msg, "resource not allowed:") + s.Truef(strings.HasPrefix(msg, "failed to list helm releases"), "expected descriptive error, got %v", toolResult.Content[0].(mcp.TextContent).Text) + expectedMessage := ": resource not allowed: /v1, Kind=Secret" + s.Truef(strings.HasSuffix(msg, expectedMessage), "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) +} + func (s *HelmSuite) TestHelmUninstallNoReleases() { s.InitMcpClient() s.Run("helm_uninstall(name=release-to-uninstall) with no releases", func() { From 970fd25af185c697fda26ca6d633cabc5d76d74f Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Thu, 20 Nov 2025 15:47:54 +0100 Subject: [PATCH 4/4] feat(kubernetes)!: simplified Kubernetes client access for toolsets Signed-off-by: Marc Nuri --- pkg/http/http_authorization_test.go | 3 + pkg/kubernetes/accesscontrol.go | 39 --- pkg/kubernetes/accesscontrol_clientset.go | 223 ++++++------------ pkg/kubernetes/accesscontrol_round_tripper.go | 30 ++- pkg/kubernetes/kubernetes.go | 2 +- pkg/kubernetes/kubernetes_derived_test.go | 17 +- pkg/kubernetes/manager.go | 71 ++---- pkg/kubernetes/manager_test.go | 10 +- pkg/kubernetes/nodes.go | 35 ++- pkg/kubernetes/openshift.go | 8 +- pkg/kubernetes/pods.go | 58 +++-- pkg/kubernetes/resources.go | 23 +- 12 files changed, 212 insertions(+), 307 deletions(-) diff --git a/pkg/http/http_authorization_test.go b/pkg/http/http_authorization_test.go index ed7bf4b0..29b1b736 100644 --- a/pkg/http/http_authorization_test.go +++ b/pkg/http/http_authorization_test.go @@ -325,6 +325,7 @@ func (s *AuthorizationSuite) TestAuthorizationRequireOAuthFalse() { } func (s *AuthorizationSuite) TestAuthorizationRawToken() { + s.MockServer.ResetHandlers() tokenReviewHandler := test.NewTokenReviewHandler() s.MockServer.Handle(tokenReviewHandler) @@ -371,6 +372,7 @@ func (s *AuthorizationSuite) TestAuthorizationRawToken() { } func (s *AuthorizationSuite) TestAuthorizationOidcToken() { + s.MockServer.ResetHandlers() tokenReviewHandler := test.NewTokenReviewHandler() s.MockServer.Handle(tokenReviewHandler) @@ -418,6 +420,7 @@ func (s *AuthorizationSuite) TestAuthorizationOidcToken() { } func (s *AuthorizationSuite) TestAuthorizationOidcTokenExchange() { + s.MockServer.ResetHandlers() tokenReviewHandler := test.NewTokenReviewHandler() s.MockServer.Handle(tokenReviewHandler) diff --git a/pkg/kubernetes/accesscontrol.go b/pkg/kubernetes/accesscontrol.go index e35b5dfb..276009a4 100644 --- a/pkg/kubernetes/accesscontrol.go +++ b/pkg/kubernetes/accesscontrol.go @@ -1,40 +1 @@ package kubernetes - -import ( - "fmt" - - "k8s.io/apimachinery/pkg/runtime/schema" - - "github.com/containers/kubernetes-mcp-server/pkg/config" -) - -// isAllowed checks the resource is in denied list or not. -// If it is in denied list, this function returns false. -func isAllowed( - staticConfig *config.StaticConfig, // TODO: maybe just use the denied resource slice - gvk *schema.GroupVersionKind, -) bool { - if staticConfig == nil { - return true - } - - for _, val := range staticConfig.DeniedResources { - // If kind is empty, that means Group/Version pair is denied entirely - if val.Kind == "" { - if gvk.Group == val.Group && gvk.Version == val.Version { - return false - } - } - if gvk.Group == val.Group && - gvk.Version == val.Version && - gvk.Kind == val.Kind { - return false - } - } - - return true -} - -func isNotAllowedError(gvk *schema.GroupVersionKind) error { - return fmt.Errorf("resource not allowed: %s", gvk.String()) -} diff --git a/pkg/kubernetes/accesscontrol_clientset.go b/pkg/kubernetes/accesscontrol_clientset.go index a6c3fccd..e871bd96 100644 --- a/pkg/kubernetes/accesscontrol_clientset.go +++ b/pkg/kubernetes/accesscontrol_clientset.go @@ -1,204 +1,113 @@ package kubernetes import ( - "context" "fmt" + "net/http" - authenticationv1api "k8s.io/api/authentication/v1" - authorizationv1api "k8s.io/api/authorization/v1" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/httpstream" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" authenticationv1 "k8s.io/client-go/kubernetes/typed/authentication/v1" authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1" corev1 "k8s.io/client-go/kubernetes/typed/core/v1" "k8s.io/client-go/rest" - "k8s.io/client-go/tools/remotecommand" - "k8s.io/metrics/pkg/apis/metrics" - metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1" + "k8s.io/client-go/restmapper" metricsv1beta1 "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1beta1" - - "github.com/containers/kubernetes-mcp-server/pkg/config" ) // AccessControlClientset is a limited clientset delegating interface to the standard kubernetes.Clientset // Only a limited set of functions are implemented with a single point of access to the kubernetes API where // apiVersion and kinds are checked for allowed access type AccessControlClientset struct { - cfg *rest.Config - delegate kubernetes.Interface - discoveryClient discovery.DiscoveryInterface + cfg *rest.Config + kubernetes.Interface + restMapper meta.ResettableRESTMapper + discoveryClient discovery.CachedDiscoveryInterface + dynamicClient dynamic.Interface metricsV1beta1 *metricsv1beta1.MetricsV1beta1Client - staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice -} - -func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface { - return a.discoveryClient } -func (a *AccessControlClientset) Nodes() (corev1.NodeInterface, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) +func NewAccessControlClientset(staticConfig *config.StaticConfig, restConfig *rest.Config) (*AccessControlClientset, error) { + rest.CopyConfig(restConfig) + acc := &AccessControlClientset{ + cfg: rest.CopyConfig(restConfig), } - return a.delegate.CoreV1().Nodes(), nil -} - -func (a *AccessControlClientset) NodesLogs(ctx context.Context, name string) (*rest.Request, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) + if acc.cfg.UserAgent == "" { + acc.cfg.UserAgent = rest.DefaultKubernetesUserAgent() } - - if _, err := a.delegate.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil { - return nil, fmt.Errorf("failed to get node %s: %w", name, err) + acc.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper { + return &AccessControlRoundTripper{ + delegate: original, + staticConfig: staticConfig, + restMapper: acc.restMapper, + } + }) + discoveryClient, err := discovery.NewDiscoveryClientForConfig(acc.cfg) + if err != nil { + return nil, fmt.Errorf("failed to create discovery client: %v", err) } - - url := []string{"api", "v1", "nodes", name, "proxy", "logs"} - return a.delegate.CoreV1().RESTClient(). - Get(). - AbsPath(url...), nil -} - -func (a *AccessControlClientset) NodesMetricses(ctx context.Context, name string, listOptions metav1.ListOptions) (*metrics.NodeMetricsList, error) { - gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "NodeMetrics"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) + acc.discoveryClient = memory.NewMemCacheClient(discoveryClient) + acc.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(acc.discoveryClient) + acc.Interface, err = kubernetes.NewForConfig(acc.cfg) + if err != nil { + return nil, err } - versionedMetrics := &metricsv1beta1api.NodeMetricsList{} - var err error - if name != "" { - m, err := a.metricsV1beta1.NodeMetricses().Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to get metrics for node %s: %w", name, err) - } - versionedMetrics.Items = []metricsv1beta1api.NodeMetrics{*m} - } else { - versionedMetrics, err = a.metricsV1beta1.NodeMetricses().List(ctx, listOptions) - if err != nil { - return nil, fmt.Errorf("failed to list node metrics: %w", err) - } + acc.dynamicClient, err = dynamic.NewForConfig(acc.cfg) + if err != nil { + return nil, err } - convertedMetrics := &metrics.NodeMetricsList{} - return convertedMetrics, metricsv1beta1api.Convert_v1beta1_NodeMetricsList_To_metrics_NodeMetricsList(versionedMetrics, convertedMetrics, nil) + acc.metricsV1beta1, err = metricsv1beta1.NewForConfig(acc.cfg) + if err != nil { + return nil, err + } + return acc, nil } -func (a *AccessControlClientset) NodesStatsSummary(ctx context.Context, name string) (*rest.Request, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } +func (a *AccessControlClientset) RESTMapper() meta.ResettableRESTMapper { + return a.restMapper +} - if _, err := a.delegate.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil { - return nil, fmt.Errorf("failed to get node %s: %w", name, err) - } +func (a *AccessControlClientset) DiscoveryClient() discovery.CachedDiscoveryInterface { + return a.discoveryClient +} - url := []string{"api", "v1", "nodes", name, "proxy", "stats", "summary"} - return a.delegate.CoreV1().RESTClient(). - Get(). - AbsPath(url...), nil +func (a *AccessControlClientset) DynamicClient() dynamic.Interface { + return a.dynamicClient } -func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - return a.delegate.CoreV1().Pods(namespace), nil +func (a *AccessControlClientset) MetricsV1beta1Client() *metricsv1beta1.MetricsV1beta1Client { + return a.metricsV1beta1 } -func (a *AccessControlClientset) PodsExec(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - // Compute URL - // https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397 - execRequest := a.delegate.CoreV1().RESTClient(). - Post(). - Resource("pods"). - Namespace(namespace). - Name(name). - SubResource("exec") - execRequest.VersionedParams(podExecOptions, ParameterCodec) - spdyExec, err := remotecommand.NewSPDYExecutor(a.cfg, "POST", execRequest.URL()) - if err != nil { - return nil, err - } - webSocketExec, err := remotecommand.NewWebSocketExecutor(a.cfg, "GET", execRequest.URL().String()) - if err != nil { - return nil, err - } - return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool { - return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err) - }) +// Nodes returns NodeInterface +// Deprecated: use CoreV1().Nodes() directly +func (a *AccessControlClientset) Nodes() (corev1.NodeInterface, error) { + return a.CoreV1().Nodes(), nil } -func (a *AccessControlClientset) PodsMetricses(ctx context.Context, namespace, name string, listOptions metav1.ListOptions) (*metrics.PodMetricsList, error) { - gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "PodMetrics"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - versionedMetrics := &metricsv1beta1api.PodMetricsList{} - var err error - if name != "" { - m, err := a.metricsV1beta1.PodMetricses(namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, name, err) - } - versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m} - } else { - versionedMetrics, err = a.metricsV1beta1.PodMetricses(namespace).List(ctx, listOptions) - if err != nil { - return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err) - } - } - convertedMetrics := &metrics.PodMetricsList{} - return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil) +// Pods returns PodInterface +// Deprecated: use CoreV1().Pods(namespace) directly +func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) { + return a.CoreV1().Pods(namespace), nil } +// Services returns ServiceInterface +// Deprecated: use CoreV1().Services(namespace) directly func (a *AccessControlClientset) Services(namespace string) (corev1.ServiceInterface, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - return a.delegate.CoreV1().Services(namespace), nil + return a.CoreV1().Services(namespace), nil } +// SelfSubjectAccessReviews returns SelfSubjectAccessReviewInterface +// Deprecated: use AuthorizationV1().SelfSubjectAccessReviews() directly func (a *AccessControlClientset) SelfSubjectAccessReviews() (authorizationv1.SelfSubjectAccessReviewInterface, error) { - gvk := &schema.GroupVersionKind{Group: authorizationv1api.GroupName, Version: authorizationv1api.SchemeGroupVersion.Version, Kind: "SelfSubjectAccessReview"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - return a.delegate.AuthorizationV1().SelfSubjectAccessReviews(), nil + return a.AuthorizationV1().SelfSubjectAccessReviews(), nil } // TokenReview returns TokenReviewInterface +// Deprecated: use AuthenticationV1().TokenReviews() directly func (a *AccessControlClientset) TokenReview() (authenticationv1.TokenReviewInterface, error) { - gvk := &schema.GroupVersionKind{Group: authenticationv1api.GroupName, Version: authorizationv1api.SchemeGroupVersion.Version, Kind: "TokenReview"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - return a.delegate.AuthenticationV1().TokenReviews(), nil -} - -func NewAccessControlClientset(cfg *rest.Config, staticConfig *config.StaticConfig) (*AccessControlClientset, error) { - clientSet, err := kubernetes.NewForConfig(cfg) - if err != nil { - return nil, err - } - metricsClient, err := metricsv1beta1.NewForConfig(cfg) - if err != nil { - return nil, err - } - return &AccessControlClientset{ - cfg: cfg, - delegate: clientSet, - discoveryClient: clientSet.DiscoveryClient, - metricsV1beta1: metricsClient, - staticConfig: staticConfig, - }, nil + return a.AuthenticationV1().TokenReviews(), nil } diff --git a/pkg/kubernetes/accesscontrol_round_tripper.go b/pkg/kubernetes/accesscontrol_round_tripper.go index 909568c2..c818bb71 100644 --- a/pkg/kubernetes/accesscontrol_round_tripper.go +++ b/pkg/kubernetes/accesscontrol_round_tripper.go @@ -27,13 +27,39 @@ func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Respons if err != nil { return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to get kind for gvr %v: %w", gvr, err) } - if !isAllowed(rt.staticConfig, &gvk) { - return nil, isNotAllowedError(&gvk) + if !rt.isAllowed(gvk) { + return nil, fmt.Errorf("resource not allowed: %s", gvk.String()) } return rt.delegate.RoundTrip(req) } +// isAllowed checks the resource is in denied list or not. +// If it is in denied list, this function returns false. +func (rt *AccessControlRoundTripper) isAllowed( + gvk schema.GroupVersionKind, +) bool { + if rt.staticConfig == nil { + return true + } + + for _, val := range rt.staticConfig.DeniedResources { + // If kind is empty, that means Group/Version pair is denied entirely + if val.Kind == "" { + if gvk.Group == val.Group && gvk.Version == val.Version { + return false + } + } + if gvk.Group == val.Group && + gvk.Version == val.Version && + gvk.Kind == val.Kind { + return false + } + } + + return true +} + func parseURLToGVR(path string) (gvr schema.GroupVersionResource, ok bool) { parts := strings.Split(strings.Trim(path, "/"), "/") diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index b9d7ea5e..03bb9758 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -41,5 +41,5 @@ func (k *Kubernetes) NewHelm() *helm.Helm { // NewKiali returns a Kiali client initialized with the same StaticConfig and bearer token // as the underlying derived Kubernetes manager. func (k *Kubernetes) NewKiali() *kiali.Kiali { - return kiali.NewKiali(k.manager.staticConfig, k.manager.cfg) + return kiali.NewKiali(k.manager.staticConfig, k.AccessControlClientset().cfg) } diff --git a/pkg/kubernetes/kubernetes_derived_test.go b/pkg/kubernetes/kubernetes_derived_test.go index 7d0dd90d..88a39da3 100644 --- a/pkg/kubernetes/kubernetes_derived_test.go +++ b/pkg/kubernetes/kubernetes_derived_test.go @@ -82,10 +82,10 @@ users: s.Equal(derived.manager.staticConfig, testStaticConfig, "staticConfig not properly wired to derived manager") s.Run("RestConfig is correctly copied and sensitive fields are omitted", func() { - derivedCfg := derived.manager.cfg + derivedCfg := derived.manager.accessControlClientSet.cfg s.Require().NotNil(derivedCfg, "derived config is nil") - originalCfg := testManager.cfg + originalCfg := testManager.accessControlClientSet.cfg s.Equalf(originalCfg.Host, derivedCfg.Host, "expected Host %s, got %s", originalCfg.Host, derivedCfg.Host) s.Equalf(originalCfg.APIPath, derivedCfg.APIPath, "expected APIPath %s, got %s", originalCfg.APIPath, derivedCfg.APIPath) s.Equalf(originalCfg.QPS, derivedCfg.QPS, "expected QPS %f, got %f", originalCfg.QPS, derivedCfg.QPS) @@ -121,12 +121,11 @@ users: }) s.Run("derived manager has initialized clients", func() { // Verify that the derived manager has proper clients initialized - s.NotNilf(derived.manager.accessControlClientSet, "expected accessControlClientSet to be initialized") - s.Equalf(testStaticConfig, derived.manager.accessControlClientSet.staticConfig, "staticConfig not properly wired to derived manager") - s.NotNilf(derived.manager.discoveryClient, "expected discoveryClient to be initialized") - s.NotNilf(derived.manager.restMapper, "expected accessControlRESTMapper to be initialized") - //s.Equalf(testStaticConfig, derived.manager.re.staticConfig, "staticConfig not properly wired to derived manager") - s.NotNilf(derived.manager.dynamicClient, "expected dynamicClient to be initialized") + s.NotNilf(derived.AccessControlClientset(), "expected accessControlClientSet to be initialized") + s.Equalf(testStaticConfig, derived.manager.staticConfig, "staticConfig not properly wired to derived manager") + s.NotNilf(derived.AccessControlClientset().DiscoveryClient(), "expected discoveryClient to be initialized") + s.NotNilf(derived.AccessControlClientset().RESTMapper(), "expected accessControlRESTMapper to be initialized") + s.NotNilf(derived.AccessControlClientset().DynamicClient(), "expected dynamicClient to be initialized") }) }) }) @@ -172,7 +171,7 @@ users: s.NotEqual(derived.manager, testManager, "expected new derived manager, got original manager") s.Equal(derived.manager.staticConfig, testStaticConfig, "staticConfig not properly wired to derived manager") - derivedCfg := derived.manager.cfg + derivedCfg := derived.manager.accessControlClientSet.cfg s.Require().NotNil(derivedCfg, "derived config is nil") s.Equalf("aiTana-julIA", derivedCfg.BearerToken, "expected BearerToken %s, got %s", "aiTana-julIA", derivedCfg.BearerToken) diff --git a/pkg/kubernetes/manager.go b/pkg/kubernetes/manager.go index d2e5d0fe..32bd278e 100644 --- a/pkg/kubernetes/manager.go +++ b/pkg/kubernetes/manager.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/http" "strings" "github.com/containers/kubernetes-mcp-server/pkg/config" @@ -14,22 +13,15 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/discovery" - "k8s.io/client-go/discovery/cached/memory" - "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" - "k8s.io/client-go/restmapper" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "k8s.io/klog/v2" ) type Manager struct { - cfg *rest.Config clientCmdConfig clientcmd.ClientConfig - discoveryClient discovery.CachedDiscoveryInterface - restMapper *restmapper.DeferredDiscoveryRESTMapper accessControlClientSet *AccessControlClientset - dynamicClient *dynamic.DynamicClient staticConfig *config.StaticConfig CloseWatchKubeConfig CloseWatchKubeConfig @@ -102,31 +94,14 @@ func NewInClusterManager(config *config.StaticConfig) (*Manager, error) { func newManager(config *config.StaticConfig, restConfig *rest.Config, clientCmdConfig clientcmd.ClientConfig) (*Manager, error) { k8s := &Manager{ staticConfig: config, - cfg: restConfig, clientCmdConfig: clientCmdConfig, } - if k8s.cfg.UserAgent == "" { - k8s.cfg.UserAgent = rest.DefaultKubernetesUserAgent() - } var err error // TODO: Won't work because not all client-go clients use the shared context (e.g. discovery client uses context.TODO()) //k8s.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper { // return &impersonateRoundTripper{original} //}) - k8s.accessControlClientSet, err = NewAccessControlClientset(k8s.cfg, k8s.staticConfig) - if err != nil { - return nil, err - } - k8s.discoveryClient = memory.NewMemCacheClient(k8s.accessControlClientSet.DiscoveryClient()) - k8s.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient) - k8s.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper { - return &AccessControlRoundTripper{ - delegate: original, - staticConfig: k8s.staticConfig, - restMapper: k8s.restMapper, - } - }) - k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg) + k8s.accessControlClientSet, err = NewAccessControlClientset(k8s.staticConfig, restConfig) if err != nil { return nil, err } @@ -190,16 +165,16 @@ func (m *Manager) NamespaceOrDefault(namespace string) string { } func (m *Manager) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { - return m.discoveryClient, nil + return m.accessControlClientSet.DiscoveryClient(), nil } func (m *Manager) ToRESTMapper() (meta.RESTMapper, error) { - return m.restMapper, nil + return m.accessControlClientSet.RESTMapper(), nil } // ToRESTConfig returns the rest.Config object (genericclioptions.RESTClientGetter) func (m *Manager) ToRESTConfig() (*rest.Config, error) { - return m.cfg, nil + return m.accessControlClientSet.cfg, nil } // ToRawKubeConfigLoader returns the clientcmd.ClientConfig object (genericclioptions.RESTClientGetter) @@ -208,10 +183,7 @@ func (m *Manager) ToRawKubeConfigLoader() clientcmd.ClientConfig { } func (m *Manager) VerifyToken(ctx context.Context, token, audience string) (*authenticationv1api.UserInfo, []string, error) { - tokenReviewClient, err := m.accessControlClientSet.TokenReview() - if err != nil { - return nil, nil, err - } + tokenReviewClient := m.accessControlClientSet.AuthenticationV1().TokenReviews() tokenReview := &authenticationv1api.TokenReview{ TypeMeta: metav1.TypeMeta{ APIVersion: "authentication.k8s.io/v1", @@ -248,22 +220,22 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) { } klog.V(5).Infof("%s header found (Bearer), using provided bearer token", OAuthAuthorizationHeader) derivedCfg := &rest.Config{ - Host: m.cfg.Host, - APIPath: m.cfg.APIPath, - WrapTransport: m.cfg.WrapTransport, + Host: m.accessControlClientSet.cfg.Host, + APIPath: m.accessControlClientSet.cfg.APIPath, + WrapTransport: m.accessControlClientSet.cfg.WrapTransport, // Copy only server verification TLS settings (CA bundle and server name) TLSClientConfig: rest.TLSClientConfig{ - Insecure: m.cfg.Insecure, - ServerName: m.cfg.ServerName, - CAFile: m.cfg.CAFile, - CAData: m.cfg.CAData, + Insecure: m.accessControlClientSet.cfg.Insecure, + ServerName: m.accessControlClientSet.cfg.ServerName, + CAFile: m.accessControlClientSet.cfg.CAFile, + CAData: m.accessControlClientSet.cfg.CAData, }, BearerToken: strings.TrimPrefix(authorization, "Bearer "), // pass custom UserAgent to identify the client UserAgent: CustomUserAgent, - QPS: m.cfg.QPS, - Burst: m.cfg.Burst, - Timeout: m.cfg.Timeout, + QPS: m.accessControlClientSet.cfg.QPS, + Burst: m.accessControlClientSet.cfg.Burst, + Timeout: m.accessControlClientSet.cfg.Timeout, Impersonate: rest.ImpersonationConfig{}, } clientCmdApiConfig, err := m.clientCmdConfig.RawConfig() @@ -278,11 +250,10 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) { derived := &Kubernetes{ manager: &Manager{ clientCmdConfig: clientcmd.NewDefaultClientConfig(clientCmdApiConfig, nil), - cfg: derivedCfg, staticConfig: m.staticConfig, }, } - derived.manager.accessControlClientSet, err = NewAccessControlClientset(derived.manager.cfg, derived.manager.staticConfig) + derived.manager.accessControlClientSet, err = NewAccessControlClientset(derived.manager.staticConfig, derivedCfg) if err != nil { if m.staticConfig.RequireOAuth { klog.Errorf("failed to get kubeconfig: %v", err) @@ -290,15 +261,5 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) { } return &Kubernetes{manager: m}, nil } - derived.manager.discoveryClient = memory.NewMemCacheClient(derived.manager.accessControlClientSet.DiscoveryClient()) - derived.manager.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient) - derived.manager.dynamicClient, err = dynamic.NewForConfig(derived.manager.cfg) - if err != nil { - if m.staticConfig.RequireOAuth { - klog.Errorf("failed to initialize dynamic client: %v", err) - return nil, errors.New("failed to initialize dynamic client") - } - return &Kubernetes{manager: m}, nil - } return derived, nil } diff --git a/pkg/kubernetes/manager_test.go b/pkg/kubernetes/manager_test.go index 63241fa9..c6f9da6a 100644 --- a/pkg/kubernetes/manager_test.go +++ b/pkg/kubernetes/manager_test.go @@ -49,7 +49,7 @@ func (s *ManagerTestSuite) TestNewInClusterManager() { s.Equal("in-cluster", rawConfig.CurrentContext, "expected current context to be 'in-cluster'") }) s.Run("sets default user-agent", func() { - s.Contains(manager.cfg.UserAgent, "("+runtime.GOOS+"/"+runtime.GOARCH+")") + s.Contains(manager.accessControlClientSet.cfg.UserAgent, "("+runtime.GOOS+"/"+runtime.GOARCH+")") }) }) s.Run("with explicit kubeconfig", func() { @@ -98,10 +98,10 @@ func (s *ManagerTestSuite) TestNewKubeconfigManager() { s.Contains(manager.clientCmdConfig.ConfigAccess().GetLoadingPrecedence(), kubeconfig, "expected kubeconfig path to match") }) s.Run("sets default user-agent", func() { - s.Contains(manager.cfg.UserAgent, "("+runtime.GOOS+"/"+runtime.GOARCH+")") + s.Contains(manager.accessControlClientSet.cfg.UserAgent, "("+runtime.GOOS+"/"+runtime.GOARCH+")") }) s.Run("rest config host points to mock server", func() { - s.Equal(s.mockServer.Config().Host, manager.cfg.Host, "expected rest config host to match mock server") + s.Equal(s.mockServer.Config().Host, manager.accessControlClientSet.cfg.Host, "expected rest config host to match mock server") }) }) s.Run("with valid kubeconfig in env and explicit kubeconfig in config", func() { @@ -124,7 +124,7 @@ func (s *ManagerTestSuite) TestNewKubeconfigManager() { s.Contains(manager.clientCmdConfig.ConfigAccess().GetLoadingPrecedence(), kubeconfigExplicit, "expected kubeconfig path to match explicit") }) s.Run("rest config host points to mock server", func() { - s.Equal(s.mockServer.Config().Host, manager.cfg.Host, "expected rest config host to match mock server") + s.Equal(s.mockServer.Config().Host, manager.accessControlClientSet.cfg.Host, "expected rest config host to match mock server") }) }) s.Run("with valid kubeconfig in env and explicit kubeconfig context (valid)", func() { @@ -149,7 +149,7 @@ func (s *ManagerTestSuite) TestNewKubeconfigManager() { s.Contains(manager.clientCmdConfig.ConfigAccess().GetLoadingPrecedence(), kubeconfigFile, "expected kubeconfig path to match") }) s.Run("rest config host points to mock server", func() { - s.Equal(s.mockServer.Config().Host, manager.cfg.Host, "expected rest config host to match mock server") + s.Equal(s.mockServer.Config().Host, manager.accessControlClientSet.cfg.Host, "expected rest config host to match mock server") }) }) s.Run("with valid kubeconfig in env and explicit kubeconfig context (invalid)", func() { diff --git a/pkg/kubernetes/nodes.go b/pkg/kubernetes/nodes.go index a4321a9f..152f84cf 100644 --- a/pkg/kubernetes/nodes.go +++ b/pkg/kubernetes/nodes.go @@ -18,11 +18,13 @@ func (k *Kubernetes) NodesLog(ctx context.Context, name string, query string, ta // - /var/log/kube-proxy.log - kube-proxy logs // - /var/log/containers/ - container logs - req, err := k.AccessControlClientset().NodesLogs(ctx, name) - if err != nil { - return "", err + if _, err := k.AccessControlClientset().CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil { + return "", fmt.Errorf("failed to get node %s: %w", name, err) } + req := k.AccessControlClientset().CoreV1().RESTClient(). + Get(). + AbsPath("api", "v1", "nodes", name, "proxy", "logs") req.Param("query", query) // Query parameters for tail if tailLines > 0 { @@ -47,12 +49,14 @@ func (k *Kubernetes) NodesStatsSummary(ctx context.Context, name string) (string // https://kubernetes.io/docs/reference/instrumentation/understand-psi-metrics/ // This endpoint provides CPU, memory, filesystem, and network statistics - req, err := k.AccessControlClientset().NodesStatsSummary(ctx, name) - if err != nil { - return "", err + if _, err := k.AccessControlClientset().CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil { + return "", fmt.Errorf("failed to get node %s: %w", name, err) } - result := req.Do(ctx) + result := k.AccessControlClientset().CoreV1().RESTClient(). + Get(). + AbsPath("api", "v1", "nodes", name, "proxy", "stats", "summary"). + Do(ctx) if result.Error() != nil { return "", fmt.Errorf("failed to get node stats summary: %w", result.Error()) } @@ -75,5 +79,20 @@ func (k *Kubernetes) NodesTop(ctx context.Context, options NodesTopOptions) (*me if !k.supportsGroupVersion(metrics.GroupName + "/" + metricsv1beta1api.SchemeGroupVersion.Version) { return nil, errors.New("metrics API is not available") } - return k.manager.accessControlClientSet.NodesMetricses(ctx, options.Name, options.ListOptions) + versionedMetrics := &metricsv1beta1api.NodeMetricsList{} + var err error + if options.Name != "" { + m, err := k.AccessControlClientset().MetricsV1beta1Client().NodeMetricses().Get(ctx, options.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get metrics for node %s: %w", options.Name, err) + } + versionedMetrics.Items = []metricsv1beta1api.NodeMetrics{*m} + } else { + versionedMetrics, err = k.AccessControlClientset().MetricsV1beta1Client().NodeMetricses().List(ctx, options.ListOptions) + if err != nil { + return nil, fmt.Errorf("failed to list node metrics: %w", err) + } + } + convertedMetrics := &metrics.NodeMetricsList{} + return convertedMetrics, metricsv1beta1api.Convert_v1beta1_NodeMetricsList_To_metrics_NodeMetricsList(versionedMetrics, convertedMetrics, nil) } diff --git a/pkg/kubernetes/openshift.go b/pkg/kubernetes/openshift.go index 7cb3e273..cc6558cc 100644 --- a/pkg/kubernetes/openshift.go +++ b/pkg/kubernetes/openshift.go @@ -10,9 +10,13 @@ type Openshift interface { IsOpenShift(context.Context) bool } -func (m *Manager) IsOpenShift(_ context.Context) bool { +func (m *Manager) IsOpenShift(ctx context.Context) bool { // This method should be fast and not block (it's called at startup) - _, err := m.discoveryClient.ServerResourcesForGroupVersion(schema.GroupVersion{ + k, err := m.Derived(ctx) + if err != nil { + return false + } + _, err = k.AccessControlClientset().DiscoveryClient().ServerResourcesForGroupVersion(schema.GroupVersion{ Group: "project.openshift.io", Version: "v1", }.String()) diff --git a/pkg/kubernetes/pods.go b/pkg/kubernetes/pods.go index 4d333ea8..f36f1bee 100644 --- a/pkg/kubernetes/pods.go +++ b/pkg/kubernetes/pods.go @@ -12,6 +12,7 @@ import ( labelutil "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/httpstream" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/rand" "k8s.io/client-go/tools/remotecommand" @@ -22,7 +23,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/version" ) -// Default number of lines to retrieve from the end of the logs +// DefaultTailLines is the default number of lines to retrieve from the end of the logs const DefaultTailLines = int64(100) type PodsTopOptions struct { @@ -65,10 +66,7 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st // Delete managed service if isManaged { - services, err := k.manager.accessControlClientSet.Services(namespace) - if err != nil { - return "", err - } + services := k.AccessControlClientset().CoreV1().Services(namespace) if sl, _ := services.List(ctx, metav1.ListOptions{ LabelSelector: managedLabelSelector.String(), }); sl != nil { @@ -80,7 +78,7 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st // Delete managed Route if isManaged && k.supportsGroupVersion("route.openshift.io/v1") { - routeResources := k.manager.dynamicClient. + routeResources := k.AccessControlClientset().DynamicClient(). Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}). Namespace(namespace) if rl, _ := routeResources.List(ctx, metav1.ListOptions{ @@ -97,10 +95,7 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st } func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string, previous bool, tail int64) (string, error) { - pods, err := k.manager.accessControlClientSet.Pods(k.NamespaceOrDefault(namespace)) - if err != nil { - return "", err - } + pods := k.AccessControlClientset().CoreV1().Pods(k.NamespaceOrDefault(namespace)) logOptions := &v1.PodLogOptions{ Container: container, @@ -218,15 +213,27 @@ func (k *Kubernetes) PodsTop(ctx context.Context, options PodsTopOptions) (*metr } else { namespace = k.NamespaceOrDefault(namespace) } - return k.manager.accessControlClientSet.PodsMetricses(ctx, namespace, options.Name, options.ListOptions) + var err error + versionedMetrics := &metricsv1beta1api.PodMetricsList{} + if options.Name != "" { + m, err := k.AccessControlClientset().MetricsV1beta1Client().PodMetricses(namespace).Get(ctx, options.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, options.Name, err) + } + versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m} + } else { + versionedMetrics, err = k.AccessControlClientset().MetricsV1beta1Client().PodMetricses(namespace).List(ctx, options.ListOptions) + if err != nil { + return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err) + } + } + convertedMetrics := &metrics.PodMetricsList{} + return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil) } func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) { namespace = k.NamespaceOrDefault(namespace) - pods, err := k.manager.accessControlClientSet.Pods(namespace) - if err != nil { - return "", err - } + pods := k.AccessControlClientset().CoreV1().Pods(namespace) pod, err := pods.Get(ctx, name, metav1.GetOptions{}) if err != nil { return "", err @@ -244,7 +251,26 @@ func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container st Stdout: true, Stderr: true, } - executor, err := k.manager.accessControlClientSet.PodsExec(namespace, name, podExecOptions) + // Compute URL + // https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397 + execRequest := k.AccessControlClientset().CoreV1().RESTClient(). + Post(). + Resource("pods"). + Namespace(namespace). + Name(name). + SubResource("exec") + execRequest.VersionedParams(podExecOptions, ParameterCodec) + spdyExec, err := remotecommand.NewSPDYExecutor(k.AccessControlClientset().cfg, "POST", execRequest.URL()) + if err != nil { + return "", err + } + webSocketExec, err := remotecommand.NewWebSocketExecutor(k.AccessControlClientset().cfg, "GET", execRequest.URL().String()) + if err != nil { + return "", err + } + executor, err := remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool { + return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err) + }) if err != nil { return "", err } diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go index 35fc1a93..c73cc0f4 100644 --- a/pkg/kubernetes/resources.go +++ b/pkg/kubernetes/resources.go @@ -43,7 +43,7 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion if options.AsTable { return k.resourcesListAsTable(ctx, gvk, gvr, namespace, options) } - return k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, options.ListOptions) + return k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).List(ctx, options.ListOptions) } func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (*unstructured.Unstructured, error) { @@ -56,7 +56,7 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced { namespace = k.NamespaceOrDefault(namespace) } - return k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + return k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) } func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource string) ([]*unstructured.Unstructured, error) { @@ -83,7 +83,7 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced { namespace = k.NamespaceOrDefault(namespace) } - return k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + return k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) } // resourcesListAsTable retrieves a list of resources in a table format. @@ -102,7 +102,7 @@ func (k *Kubernetes) resourcesListAsTable(ctx context.Context, gvk *schema.Group } url = append(url, gvr.Resource) var table metav1.Table - err := k.manager.discoveryClient.RESTClient(). + err := k.AccessControlClientset().CoreV1().RESTClient(). Get(). SetHeader("Accept", strings.Join([]string{ fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1.SchemeGroupVersion.Version, metav1.GroupName), @@ -146,7 +146,7 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced { namespace = k.NamespaceOrDefault(namespace) } - resources[i], rErr = k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{ + resources[i], rErr = k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{ FieldManager: version.BinaryName, }) if rErr != nil { @@ -154,14 +154,14 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u } // Clear the cache to ensure the next operation is performed on the latest exposed APIs (will change after the CRD creation) if gvk.Kind == "CustomResourceDefinition" { - k.manager.restMapper.Reset() + k.AccessControlClientset().RESTMapper().Reset() } } return resources, nil } func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) { - m, err := k.manager.restMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version) + m, err := k.AccessControlClientset().RESTMapper().RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version) if err != nil { return nil, err } @@ -169,7 +169,7 @@ func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVer } func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) { - apiResourceList, err := k.manager.discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String()) + apiResourceList, err := k.AccessControlClientset().DiscoveryClient().ServerResourcesForGroupVersion(gvk.GroupVersion().String()) if err != nil { return false, err } @@ -182,17 +182,14 @@ func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) { } func (k *Kubernetes) supportsGroupVersion(groupVersion string) bool { - if _, err := k.manager.discoveryClient.ServerResourcesForGroupVersion(groupVersion); err != nil { + if _, err := k.AccessControlClientset().DiscoveryClient().ServerResourcesForGroupVersion(groupVersion); err != nil { return false } return true } func (k *Kubernetes) canIUse(ctx context.Context, gvr *schema.GroupVersionResource, namespace, verb string) bool { - accessReviews, err := k.manager.accessControlClientSet.SelfSubjectAccessReviews() - if err != nil { - return false - } + accessReviews := k.AccessControlClientset().AuthorizationV1().SelfSubjectAccessReviews() response, err := accessReviews.Create(ctx, &authv1.SelfSubjectAccessReview{ Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authv1.ResourceAttributes{ Namespace: namespace,