Skip to content

Commit 461c02b

Browse files
committed
feat(kubernetes)!: simplified Kubernetes client access for toolsets
Signed-off-by: Marc Nuri <[email protected]>
1 parent f0aa298 commit 461c02b

File tree

12 files changed

+212
-307
lines changed

12 files changed

+212
-307
lines changed

pkg/http/http_authorization_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ func (s *AuthorizationSuite) TestAuthorizationRequireOAuthFalse() {
325325
}
326326

327327
func (s *AuthorizationSuite) TestAuthorizationRawToken() {
328+
s.MockServer.ResetHandlers()
328329
tokenReviewHandler := test.NewTokenReviewHandler()
329330
s.MockServer.Handle(tokenReviewHandler)
330331

@@ -371,6 +372,7 @@ func (s *AuthorizationSuite) TestAuthorizationRawToken() {
371372
}
372373

373374
func (s *AuthorizationSuite) TestAuthorizationOidcToken() {
375+
s.MockServer.ResetHandlers()
374376
tokenReviewHandler := test.NewTokenReviewHandler()
375377
s.MockServer.Handle(tokenReviewHandler)
376378

@@ -418,6 +420,7 @@ func (s *AuthorizationSuite) TestAuthorizationOidcToken() {
418420
}
419421

420422
func (s *AuthorizationSuite) TestAuthorizationOidcTokenExchange() {
423+
s.MockServer.ResetHandlers()
421424
tokenReviewHandler := test.NewTokenReviewHandler()
422425
s.MockServer.Handle(tokenReviewHandler)
423426

pkg/kubernetes/accesscontrol.go

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1 @@
11
package kubernetes
2-
3-
import (
4-
"fmt"
5-
6-
"k8s.io/apimachinery/pkg/runtime/schema"
7-
8-
"github.com/containers/kubernetes-mcp-server/pkg/config"
9-
)
10-
11-
// isAllowed checks the resource is in denied list or not.
12-
// If it is in denied list, this function returns false.
13-
func isAllowed(
14-
staticConfig *config.StaticConfig, // TODO: maybe just use the denied resource slice
15-
gvk *schema.GroupVersionKind,
16-
) bool {
17-
if staticConfig == nil {
18-
return true
19-
}
20-
21-
for _, val := range staticConfig.DeniedResources {
22-
// If kind is empty, that means Group/Version pair is denied entirely
23-
if val.Kind == "" {
24-
if gvk.Group == val.Group && gvk.Version == val.Version {
25-
return false
26-
}
27-
}
28-
if gvk.Group == val.Group &&
29-
gvk.Version == val.Version &&
30-
gvk.Kind == val.Kind {
31-
return false
32-
}
33-
}
34-
35-
return true
36-
}
37-
38-
func isNotAllowedError(gvk *schema.GroupVersionKind) error {
39-
return fmt.Errorf("resource not allowed: %s", gvk.String())
40-
}
Lines changed: 66 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -1,204 +1,113 @@
11
package kubernetes
22

33
import (
4-
"context"
54
"fmt"
5+
"net/http"
66

7-
authenticationv1api "k8s.io/api/authentication/v1"
8-
authorizationv1api "k8s.io/api/authorization/v1"
9-
v1 "k8s.io/api/core/v1"
10-
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11-
"k8s.io/apimachinery/pkg/runtime/schema"
12-
"k8s.io/apimachinery/pkg/util/httpstream"
7+
"github.com/containers/kubernetes-mcp-server/pkg/config"
8+
"k8s.io/apimachinery/pkg/api/meta"
139
"k8s.io/client-go/discovery"
10+
"k8s.io/client-go/discovery/cached/memory"
11+
"k8s.io/client-go/dynamic"
1412
"k8s.io/client-go/kubernetes"
1513
authenticationv1 "k8s.io/client-go/kubernetes/typed/authentication/v1"
1614
authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
1715
corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
1816
"k8s.io/client-go/rest"
19-
"k8s.io/client-go/tools/remotecommand"
20-
"k8s.io/metrics/pkg/apis/metrics"
21-
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
17+
"k8s.io/client-go/restmapper"
2218
metricsv1beta1 "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1beta1"
23-
24-
"github.com/containers/kubernetes-mcp-server/pkg/config"
2519
)
2620

2721
// AccessControlClientset is a limited clientset delegating interface to the standard kubernetes.Clientset
2822
// Only a limited set of functions are implemented with a single point of access to the kubernetes API where
2923
// apiVersion and kinds are checked for allowed access
3024
type AccessControlClientset struct {
31-
cfg *rest.Config
32-
delegate kubernetes.Interface
33-
discoveryClient discovery.DiscoveryInterface
25+
cfg *rest.Config
26+
kubernetes.Interface
27+
restMapper meta.ResettableRESTMapper
28+
discoveryClient discovery.CachedDiscoveryInterface
29+
dynamicClient dynamic.Interface
3430
metricsV1beta1 *metricsv1beta1.MetricsV1beta1Client
35-
staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice
36-
}
37-
38-
func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface {
39-
return a.discoveryClient
4031
}
4132

42-
func (a *AccessControlClientset) Nodes() (corev1.NodeInterface, error) {
43-
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
44-
if !isAllowed(a.staticConfig, gvk) {
45-
return nil, isNotAllowedError(gvk)
33+
func NewAccessControlClientset(staticConfig *config.StaticConfig, restConfig *rest.Config) (*AccessControlClientset, error) {
34+
rest.CopyConfig(restConfig)
35+
acc := &AccessControlClientset{
36+
cfg: rest.CopyConfig(restConfig),
4637
}
47-
return a.delegate.CoreV1().Nodes(), nil
48-
}
49-
50-
func (a *AccessControlClientset) NodesLogs(ctx context.Context, name string) (*rest.Request, error) {
51-
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
52-
if !isAllowed(a.staticConfig, gvk) {
53-
return nil, isNotAllowedError(gvk)
38+
if acc.cfg.UserAgent == "" {
39+
acc.cfg.UserAgent = rest.DefaultKubernetesUserAgent()
5440
}
55-
56-
if _, err := a.delegate.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil {
57-
return nil, fmt.Errorf("failed to get node %s: %w", name, err)
41+
acc.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper {
42+
return &AccessControlRoundTripper{
43+
delegate: original,
44+
staticConfig: staticConfig,
45+
restMapper: acc.restMapper,
46+
}
47+
})
48+
discoveryClient, err := discovery.NewDiscoveryClientForConfig(acc.cfg)
49+
if err != nil {
50+
return nil, fmt.Errorf("failed to create discovery client: %v", err)
5851
}
59-
60-
url := []string{"api", "v1", "nodes", name, "proxy", "logs"}
61-
return a.delegate.CoreV1().RESTClient().
62-
Get().
63-
AbsPath(url...), nil
64-
}
65-
66-
func (a *AccessControlClientset) NodesMetricses(ctx context.Context, name string, listOptions metav1.ListOptions) (*metrics.NodeMetricsList, error) {
67-
gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "NodeMetrics"}
68-
if !isAllowed(a.staticConfig, gvk) {
69-
return nil, isNotAllowedError(gvk)
52+
acc.discoveryClient = memory.NewMemCacheClient(discoveryClient)
53+
acc.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(acc.discoveryClient)
54+
acc.Interface, err = kubernetes.NewForConfig(acc.cfg)
55+
if err != nil {
56+
return nil, err
7057
}
71-
versionedMetrics := &metricsv1beta1api.NodeMetricsList{}
72-
var err error
73-
if name != "" {
74-
m, err := a.metricsV1beta1.NodeMetricses().Get(ctx, name, metav1.GetOptions{})
75-
if err != nil {
76-
return nil, fmt.Errorf("failed to get metrics for node %s: %w", name, err)
77-
}
78-
versionedMetrics.Items = []metricsv1beta1api.NodeMetrics{*m}
79-
} else {
80-
versionedMetrics, err = a.metricsV1beta1.NodeMetricses().List(ctx, listOptions)
81-
if err != nil {
82-
return nil, fmt.Errorf("failed to list node metrics: %w", err)
83-
}
58+
acc.dynamicClient, err = dynamic.NewForConfig(acc.cfg)
59+
if err != nil {
60+
return nil, err
8461
}
85-
convertedMetrics := &metrics.NodeMetricsList{}
86-
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_NodeMetricsList_To_metrics_NodeMetricsList(versionedMetrics, convertedMetrics, nil)
62+
acc.metricsV1beta1, err = metricsv1beta1.NewForConfig(acc.cfg)
63+
if err != nil {
64+
return nil, err
65+
}
66+
return acc, nil
8767
}
8868

89-
func (a *AccessControlClientset) NodesStatsSummary(ctx context.Context, name string) (*rest.Request, error) {
90-
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
91-
if !isAllowed(a.staticConfig, gvk) {
92-
return nil, isNotAllowedError(gvk)
93-
}
69+
func (a *AccessControlClientset) RESTMapper() meta.ResettableRESTMapper {
70+
return a.restMapper
71+
}
9472

95-
if _, err := a.delegate.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil {
96-
return nil, fmt.Errorf("failed to get node %s: %w", name, err)
97-
}
73+
func (a *AccessControlClientset) DiscoveryClient() discovery.CachedDiscoveryInterface {
74+
return a.discoveryClient
75+
}
9876

99-
url := []string{"api", "v1", "nodes", name, "proxy", "stats", "summary"}
100-
return a.delegate.CoreV1().RESTClient().
101-
Get().
102-
AbsPath(url...), nil
77+
func (a *AccessControlClientset) DynamicClient() dynamic.Interface {
78+
return a.dynamicClient
10379
}
10480

105-
func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) {
106-
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
107-
if !isAllowed(a.staticConfig, gvk) {
108-
return nil, isNotAllowedError(gvk)
109-
}
110-
return a.delegate.CoreV1().Pods(namespace), nil
81+
func (a *AccessControlClientset) MetricsV1beta1Client() *metricsv1beta1.MetricsV1beta1Client {
82+
return a.metricsV1beta1
11183
}
11284

113-
func (a *AccessControlClientset) PodsExec(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) {
114-
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
115-
if !isAllowed(a.staticConfig, gvk) {
116-
return nil, isNotAllowedError(gvk)
117-
}
118-
// Compute URL
119-
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397
120-
execRequest := a.delegate.CoreV1().RESTClient().
121-
Post().
122-
Resource("pods").
123-
Namespace(namespace).
124-
Name(name).
125-
SubResource("exec")
126-
execRequest.VersionedParams(podExecOptions, ParameterCodec)
127-
spdyExec, err := remotecommand.NewSPDYExecutor(a.cfg, "POST", execRequest.URL())
128-
if err != nil {
129-
return nil, err
130-
}
131-
webSocketExec, err := remotecommand.NewWebSocketExecutor(a.cfg, "GET", execRequest.URL().String())
132-
if err != nil {
133-
return nil, err
134-
}
135-
return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool {
136-
return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err)
137-
})
85+
// Nodes returns NodeInterface
86+
// Deprecated: use CoreV1().Nodes() directly
87+
func (a *AccessControlClientset) Nodes() (corev1.NodeInterface, error) {
88+
return a.CoreV1().Nodes(), nil
13889
}
13990

140-
func (a *AccessControlClientset) PodsMetricses(ctx context.Context, namespace, name string, listOptions metav1.ListOptions) (*metrics.PodMetricsList, error) {
141-
gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "PodMetrics"}
142-
if !isAllowed(a.staticConfig, gvk) {
143-
return nil, isNotAllowedError(gvk)
144-
}
145-
versionedMetrics := &metricsv1beta1api.PodMetricsList{}
146-
var err error
147-
if name != "" {
148-
m, err := a.metricsV1beta1.PodMetricses(namespace).Get(ctx, name, metav1.GetOptions{})
149-
if err != nil {
150-
return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, name, err)
151-
}
152-
versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m}
153-
} else {
154-
versionedMetrics, err = a.metricsV1beta1.PodMetricses(namespace).List(ctx, listOptions)
155-
if err != nil {
156-
return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err)
157-
}
158-
}
159-
convertedMetrics := &metrics.PodMetricsList{}
160-
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil)
91+
// Pods returns PodInterface
92+
// Deprecated: use CoreV1().Pods(namespace) directly
93+
func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) {
94+
return a.CoreV1().Pods(namespace), nil
16195
}
16296

97+
// Services returns ServiceInterface
98+
// Deprecated: use CoreV1().Services(namespace) directly
16399
func (a *AccessControlClientset) Services(namespace string) (corev1.ServiceInterface, error) {
164-
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"}
165-
if !isAllowed(a.staticConfig, gvk) {
166-
return nil, isNotAllowedError(gvk)
167-
}
168-
return a.delegate.CoreV1().Services(namespace), nil
100+
return a.CoreV1().Services(namespace), nil
169101
}
170102

103+
// SelfSubjectAccessReviews returns SelfSubjectAccessReviewInterface
104+
// Deprecated: use AuthorizationV1().SelfSubjectAccessReviews() directly
171105
func (a *AccessControlClientset) SelfSubjectAccessReviews() (authorizationv1.SelfSubjectAccessReviewInterface, error) {
172-
gvk := &schema.GroupVersionKind{Group: authorizationv1api.GroupName, Version: authorizationv1api.SchemeGroupVersion.Version, Kind: "SelfSubjectAccessReview"}
173-
if !isAllowed(a.staticConfig, gvk) {
174-
return nil, isNotAllowedError(gvk)
175-
}
176-
return a.delegate.AuthorizationV1().SelfSubjectAccessReviews(), nil
106+
return a.AuthorizationV1().SelfSubjectAccessReviews(), nil
177107
}
178108

179109
// TokenReview returns TokenReviewInterface
110+
// Deprecated: use AuthenticationV1().TokenReviews() directly
180111
func (a *AccessControlClientset) TokenReview() (authenticationv1.TokenReviewInterface, error) {
181-
gvk := &schema.GroupVersionKind{Group: authenticationv1api.GroupName, Version: authorizationv1api.SchemeGroupVersion.Version, Kind: "TokenReview"}
182-
if !isAllowed(a.staticConfig, gvk) {
183-
return nil, isNotAllowedError(gvk)
184-
}
185-
return a.delegate.AuthenticationV1().TokenReviews(), nil
186-
}
187-
188-
func NewAccessControlClientset(cfg *rest.Config, staticConfig *config.StaticConfig) (*AccessControlClientset, error) {
189-
clientSet, err := kubernetes.NewForConfig(cfg)
190-
if err != nil {
191-
return nil, err
192-
}
193-
metricsClient, err := metricsv1beta1.NewForConfig(cfg)
194-
if err != nil {
195-
return nil, err
196-
}
197-
return &AccessControlClientset{
198-
cfg: cfg,
199-
delegate: clientSet,
200-
discoveryClient: clientSet.DiscoveryClient,
201-
metricsV1beta1: metricsClient,
202-
staticConfig: staticConfig,
203-
}, nil
112+
return a.AuthenticationV1().TokenReviews(), nil
204113
}

pkg/kubernetes/accesscontrol_round_tripper.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,39 @@ func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Respons
2727
if err != nil {
2828
return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to get kind for gvr %v: %w", gvr, err)
2929
}
30-
if !isAllowed(rt.staticConfig, &gvk) {
31-
return nil, isNotAllowedError(&gvk)
30+
if !rt.isAllowed(gvk) {
31+
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
3232
}
3333

3434
return rt.delegate.RoundTrip(req)
3535
}
3636

37+
// isAllowed checks the resource is in denied list or not.
38+
// If it is in denied list, this function returns false.
39+
func (rt *AccessControlRoundTripper) isAllowed(
40+
gvk schema.GroupVersionKind,
41+
) bool {
42+
if rt.staticConfig == nil {
43+
return true
44+
}
45+
46+
for _, val := range rt.staticConfig.DeniedResources {
47+
// If kind is empty, that means Group/Version pair is denied entirely
48+
if val.Kind == "" {
49+
if gvk.Group == val.Group && gvk.Version == val.Version {
50+
return false
51+
}
52+
}
53+
if gvk.Group == val.Group &&
54+
gvk.Version == val.Version &&
55+
gvk.Kind == val.Kind {
56+
return false
57+
}
58+
}
59+
60+
return true
61+
}
62+
3763
func parseURLToGVR(path string) (gvr schema.GroupVersionResource, ok bool) {
3864
parts := strings.Split(strings.Trim(path, "/"), "/")
3965

pkg/kubernetes/kubernetes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,5 @@ func (k *Kubernetes) NewHelm() *helm.Helm {
4141
// NewKiali returns a Kiali client initialized with the same StaticConfig and bearer token
4242
// as the underlying derived Kubernetes manager.
4343
func (k *Kubernetes) NewKiali() *kiali.Kiali {
44-
return kiali.NewKiali(k.manager.staticConfig, k.manager.cfg)
44+
return kiali.NewKiali(k.manager.staticConfig, k.AccessControlClientset().cfg)
4545
}

0 commit comments

Comments
 (0)