Skip to content

Commit 06c35b0

Browse files
committed
wip:feat(kubernetes)!: simplified Kubernetes client access for toolsets
Signed-off-by: Marc Nuri <[email protected]>
1 parent 45c9131 commit 06c35b0

File tree

11 files changed

+152
-221
lines changed

11 files changed

+152
-221
lines changed

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-
}

pkg/kubernetes/accesscontrol_clientset.go

Lines changed: 75 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -3,71 +3,106 @@ package kubernetes
33
import (
44
"context"
55
"fmt"
6+
"net/http"
67

7-
authenticationv1api "k8s.io/api/authentication/v1"
8-
authorizationv1api "k8s.io/api/authorization/v1"
8+
"github.com/containers/kubernetes-mcp-server/pkg/config"
99
v1 "k8s.io/api/core/v1"
10+
"k8s.io/apimachinery/pkg/api/meta"
1011
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11-
"k8s.io/apimachinery/pkg/runtime/schema"
1212
"k8s.io/apimachinery/pkg/util/httpstream"
1313
"k8s.io/client-go/discovery"
14+
"k8s.io/client-go/discovery/cached/memory"
15+
"k8s.io/client-go/dynamic"
1416
"k8s.io/client-go/kubernetes"
1517
authenticationv1 "k8s.io/client-go/kubernetes/typed/authentication/v1"
1618
authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
1719
corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
1820
"k8s.io/client-go/rest"
21+
"k8s.io/client-go/restmapper"
1922
"k8s.io/client-go/tools/remotecommand"
2023
"k8s.io/metrics/pkg/apis/metrics"
2124
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
2225
metricsv1beta1 "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1beta1"
23-
24-
"github.com/containers/kubernetes-mcp-server/pkg/config"
2526
)
2627

2728
// AccessControlClientset is a limited clientset delegating interface to the standard kubernetes.Clientset
2829
// Only a limited set of functions are implemented with a single point of access to the kubernetes API where
2930
// apiVersion and kinds are checked for allowed access
3031
type AccessControlClientset struct {
31-
cfg *rest.Config
32-
delegate kubernetes.Interface
33-
discoveryClient discovery.DiscoveryInterface
32+
cfg *rest.Config
33+
kubernetes.Interface
34+
discoveryClient discovery.CachedDiscoveryInterface
35+
dynamicClient *dynamic.DynamicClient
36+
restMapper meta.ResettableRESTMapper
3437
metricsV1beta1 *metricsv1beta1.MetricsV1beta1Client
35-
staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice
3638
}
3739

38-
func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface {
40+
func NewAccessControlClientset(staticConfig *config.StaticConfig, restConfig *rest.Config) (*AccessControlClientset, error) {
41+
rest.CopyConfig(restConfig)
42+
acc := &AccessControlClientset{
43+
cfg: rest.CopyConfig(restConfig),
44+
}
45+
if acc.cfg.UserAgent == "" {
46+
acc.cfg.UserAgent = rest.DefaultKubernetesUserAgent()
47+
}
48+
acc.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper {
49+
return &AccessControlRoundTripper{
50+
delegate: original,
51+
staticConfig: staticConfig,
52+
restMapper: acc.restMapper,
53+
}
54+
})
55+
discoveryClient, err := discovery.NewDiscoveryClientForConfig(acc.cfg)
56+
if err != nil {
57+
return nil, fmt.Errorf("failed to create discovery client: %v", err)
58+
}
59+
acc.discoveryClient = memory.NewMemCacheClient(discoveryClient)
60+
acc.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(acc.discoveryClient)
61+
acc.Interface, err = kubernetes.NewForConfig(acc.cfg)
62+
if err != nil {
63+
return nil, err
64+
}
65+
acc.dynamicClient, err = dynamic.NewForConfig(acc.cfg)
66+
if err != nil {
67+
return nil, err
68+
}
69+
acc.metricsV1beta1, err = metricsv1beta1.NewForConfig(acc.cfg)
70+
if err != nil {
71+
return nil, err
72+
}
73+
return acc, nil
74+
}
75+
76+
func (a *AccessControlClientset) DiscoveryClient() discovery.CachedDiscoveryInterface {
3977
return a.discoveryClient
4078
}
4179

80+
func (a *AccessControlClientset) DynamicClient() dynamic.Interface {
81+
return a.dynamicClient
82+
}
83+
84+
func (a *AccessControlClientset) RESTMapper() meta.ResettableRESTMapper {
85+
return a.restMapper
86+
}
87+
88+
// Nodes returns NodeInterface
89+
// Deprecated: use CoreV1().Nodes() directly
4290
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)
46-
}
47-
return a.delegate.CoreV1().Nodes(), nil
91+
return a.CoreV1().Nodes(), nil
4892
}
4993

5094
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)
54-
}
55-
56-
if _, err := a.delegate.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil {
95+
if _, err := a.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil {
5796
return nil, fmt.Errorf("failed to get node %s: %w", name, err)
5897
}
5998

6099
url := []string{"api", "v1", "nodes", name, "proxy", "logs"}
61-
return a.delegate.CoreV1().RESTClient().
100+
return a.CoreV1().RESTClient().
62101
Get().
63102
AbsPath(url...), nil
64103
}
65104

66105
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)
70-
}
71106
versionedMetrics := &metricsv1beta1api.NodeMetricsList{}
72107
var err error
73108
if name != "" {
@@ -87,37 +122,26 @@ func (a *AccessControlClientset) NodesMetricses(ctx context.Context, name string
87122
}
88123

89124
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-
}
94-
95-
if _, err := a.delegate.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil {
125+
if _, err := a.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil {
96126
return nil, fmt.Errorf("failed to get node %s: %w", name, err)
97127
}
98128

99129
url := []string{"api", "v1", "nodes", name, "proxy", "stats", "summary"}
100-
return a.delegate.CoreV1().RESTClient().
130+
return a.CoreV1().RESTClient().
101131
Get().
102132
AbsPath(url...), nil
103133
}
104134

135+
// Pods returns PodInterface
136+
// Deprecated: use CoreV1().Pods(namespace) directly
105137
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
138+
return a.CoreV1().Pods(namespace), nil
111139
}
112140

113141
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-
}
118142
// Compute URL
119143
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397
120-
execRequest := a.delegate.CoreV1().RESTClient().
144+
execRequest := a.CoreV1().RESTClient().
121145
Post().
122146
Resource("pods").
123147
Namespace(namespace).
@@ -138,10 +162,6 @@ func (a *AccessControlClientset) PodsExec(namespace, name string, podExecOptions
138162
}
139163

140164
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-
}
145165
versionedMetrics := &metricsv1beta1api.PodMetricsList{}
146166
var err error
147167
if name != "" {
@@ -160,45 +180,20 @@ func (a *AccessControlClientset) PodsMetricses(ctx context.Context, namespace, n
160180
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil)
161181
}
162182

183+
// Services returns ServiceInterface
184+
// Deprecated: use CoreV1().Services(namespace) directly
163185
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
186+
return a.CoreV1().Services(namespace), nil
169187
}
170188

189+
// SelfSubjectAccessReviews returns SelfSubjectAccessReviewInterface
190+
// Deprecated: use AuthorizationV1().SelfSubjectAccessReviews() directly
171191
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
192+
return a.AuthorizationV1().SelfSubjectAccessReviews(), nil
177193
}
178194

179195
// TokenReview returns TokenReviewInterface
196+
// Deprecated: use AuthenticationV1().TokenReviews() directly
180197
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
198+
return a.AuthenticationV1().TokenReviews(), nil
204199
}

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
}

pkg/kubernetes/kubernetes_derived_test.go

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ users:
8282
s.Equal(derived.manager.staticConfig, testStaticConfig, "staticConfig not properly wired to derived manager")
8383

8484
s.Run("RestConfig is correctly copied and sensitive fields are omitted", func() {
85-
derivedCfg := derived.manager.cfg
85+
derivedCfg := derived.manager.accessControlClientSet.cfg
8686
s.Require().NotNil(derivedCfg, "derived config is nil")
8787

88-
originalCfg := testManager.cfg
88+
originalCfg := testManager.accessControlClientSet.cfg
8989
s.Equalf(originalCfg.Host, derivedCfg.Host, "expected Host %s, got %s", originalCfg.Host, derivedCfg.Host)
9090
s.Equalf(originalCfg.APIPath, derivedCfg.APIPath, "expected APIPath %s, got %s", originalCfg.APIPath, derivedCfg.APIPath)
9191
s.Equalf(originalCfg.QPS, derivedCfg.QPS, "expected QPS %f, got %f", originalCfg.QPS, derivedCfg.QPS)
@@ -122,11 +122,7 @@ users:
122122
s.Run("derived manager has initialized clients", func() {
123123
// Verify that the derived manager has proper clients initialized
124124
s.NotNilf(derived.manager.accessControlClientSet, "expected accessControlClientSet to be initialized")
125-
s.Equalf(testStaticConfig, derived.manager.accessControlClientSet.staticConfig, "staticConfig not properly wired to derived manager")
126-
s.NotNilf(derived.manager.discoveryClient, "expected discoveryClient to be initialized")
127-
s.NotNilf(derived.manager.restMapper, "expected accessControlRESTMapper to be initialized")
128125
//s.Equalf(testStaticConfig, derived.manager.re.staticConfig, "staticConfig not properly wired to derived manager")
129-
s.NotNilf(derived.manager.dynamicClient, "expected dynamicClient to be initialized")
130126
})
131127
})
132128
})
@@ -172,7 +168,7 @@ users:
172168
s.NotEqual(derived.manager, testManager, "expected new derived manager, got original manager")
173169
s.Equal(derived.manager.staticConfig, testStaticConfig, "staticConfig not properly wired to derived manager")
174170

175-
derivedCfg := derived.manager.cfg
171+
derivedCfg := derived.manager.accessControlClientSet.cfg
176172
s.Require().NotNil(derivedCfg, "derived config is nil")
177173

178174
s.Equalf("aiTana-julIA", derivedCfg.BearerToken, "expected BearerToken %s, got %s", "aiTana-julIA", derivedCfg.BearerToken)

0 commit comments

Comments
 (0)