mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
feat(config): deny resources by using RESTMapper as an interceptor (149)
feat(config): deny resources by using RESTMapper as an interceptor This approach ensures that resources in the deny list are **always** processed regardless of the implementation. The RESTMapper takes care of verifying that the requested Group Version Kind complies with the deny list while checking for the REST endpoint. --- feat(config): provide a limited clientset which check access --- review: addressed PR comments --- feat(config): provide a limited metrics clientset to check access --- review: addressed PR comments regarding pods_exec
This commit is contained in:
40
pkg/kubernetes/accesscontrol.go
Normal file
40
pkg/kubernetes/accesscontrol.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/manusa/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())
|
||||
}
|
||||
130
pkg/kubernetes/accesscontrol_clientset.go
Normal file
130
pkg/kubernetes/accesscontrol_clientset.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
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"
|
||||
"k8s.io/client-go/discovery"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
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"
|
||||
metricsv1beta1 "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1beta1"
|
||||
|
||||
"github.com/manusa/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
|
||||
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) 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) 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)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
80
pkg/kubernetes/accesscontrol_restmapper.go
Normal file
80
pkg/kubernetes/accesscontrol_restmapper.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/restmapper"
|
||||
|
||||
"github.com/manusa/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}
|
||||
}
|
||||
@@ -2,17 +2,16 @@ package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/client-go/discovery"
|
||||
"k8s.io/client-go/discovery/cached/memory"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/restmapper"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
@@ -37,24 +36,27 @@ type Kubernetes struct {
|
||||
|
||||
type Manager struct {
|
||||
// Kubeconfig path override
|
||||
Kubeconfig string
|
||||
cfg *rest.Config
|
||||
clientCmdConfig clientcmd.ClientConfig
|
||||
CloseWatchKubeConfig CloseWatchKubeConfig
|
||||
scheme *runtime.Scheme
|
||||
parameterCodec runtime.ParameterCodec
|
||||
clientSet kubernetes.Interface
|
||||
discoveryClient discovery.CachedDiscoveryInterface
|
||||
deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper
|
||||
dynamicClient *dynamic.DynamicClient
|
||||
Kubeconfig string
|
||||
cfg *rest.Config
|
||||
clientCmdConfig clientcmd.ClientConfig
|
||||
discoveryClient discovery.CachedDiscoveryInterface
|
||||
accessControlClientSet *AccessControlClientset
|
||||
accessControlRESTMapper *AccessControlRESTMapper
|
||||
dynamicClient *dynamic.DynamicClient
|
||||
|
||||
StaticConfig *config.StaticConfig
|
||||
staticConfig *config.StaticConfig
|
||||
CloseWatchKubeConfig CloseWatchKubeConfig
|
||||
}
|
||||
|
||||
var Scheme = scheme.Scheme
|
||||
var ParameterCodec = runtime.NewParameterCodec(Scheme)
|
||||
|
||||
var _ helm.Kubernetes = &Manager{}
|
||||
|
||||
func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error) {
|
||||
k8s := &Manager{
|
||||
Kubeconfig: kubeconfig,
|
||||
StaticConfig: config,
|
||||
staticConfig: config,
|
||||
}
|
||||
if err := resolveKubernetesConfigurations(k8s); err != nil {
|
||||
return nil, err
|
||||
@@ -64,21 +66,19 @@ func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error
|
||||
// return &impersonateRoundTripper{original}
|
||||
//})
|
||||
var err error
|
||||
k8s.clientSet, err = kubernetes.NewForConfig(k8s.cfg)
|
||||
k8s.accessControlClientSet, err = NewAccessControlClientset(k8s.cfg, k8s.staticConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k8s.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(k8s.clientSet.CoreV1().RESTClient()))
|
||||
k8s.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient)
|
||||
k8s.discoveryClient = memory.NewMemCacheClient(k8s.accessControlClientSet.DiscoveryClient())
|
||||
k8s.accessControlRESTMapper = NewAccessControlRESTMapper(
|
||||
restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient),
|
||||
k8s.staticConfig,
|
||||
)
|
||||
k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k8s.scheme = runtime.NewScheme()
|
||||
if err = v1.AddToScheme(k8s.scheme); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k8s.parameterCodec = runtime.NewParameterCodec(k8s.scheme)
|
||||
return k8s, nil
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func (m *Manager) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error
|
||||
}
|
||||
|
||||
func (m *Manager) ToRESTMapper() (meta.RESTMapper, error) {
|
||||
return m.deferredDiscoveryRESTMapper, nil
|
||||
return m.accessControlRESTMapper, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Derived(ctx context.Context) *Kubernetes {
|
||||
@@ -156,15 +156,16 @@ func (m *Manager) Derived(ctx context.Context) *Kubernetes {
|
||||
Kubeconfig: m.Kubeconfig,
|
||||
clientCmdConfig: clientcmd.NewDefaultClientConfig(clientCmdApiConfig, nil),
|
||||
cfg: derivedCfg,
|
||||
scheme: m.scheme,
|
||||
parameterCodec: m.parameterCodec,
|
||||
}}
|
||||
derived.manager.clientSet, err = kubernetes.NewForConfig(derived.manager.cfg)
|
||||
derived.manager.accessControlClientSet, err = NewAccessControlClientset(derived.manager.cfg, derived.manager.staticConfig)
|
||||
if err != nil {
|
||||
return &Kubernetes{manager: m}
|
||||
}
|
||||
derived.manager.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(derived.manager.clientSet.CoreV1().RESTClient()))
|
||||
derived.manager.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient)
|
||||
derived.manager.discoveryClient = memory.NewMemCacheClient(derived.manager.accessControlClientSet.DiscoveryClient())
|
||||
derived.manager.accessControlRESTMapper = NewAccessControlRESTMapper(
|
||||
restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient),
|
||||
derived.manager.staticConfig,
|
||||
)
|
||||
derived.manager.dynamicClient, err = dynamic.NewForConfig(derived.manager.cfg)
|
||||
if err != nil {
|
||||
return &Kubernetes{manager: m}
|
||||
|
||||
@@ -5,21 +5,20 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"k8s.io/metrics/pkg/apis/metrics"
|
||||
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
|
||||
metricsclientset "k8s.io/metrics/pkg/client/clientset/versioned"
|
||||
|
||||
"github.com/manusa/kubernetes-mcp-server/pkg/version"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
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"
|
||||
"k8s.io/metrics/pkg/apis/metrics"
|
||||
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
|
||||
|
||||
"github.com/manusa/kubernetes-mcp-server/pkg/version"
|
||||
)
|
||||
|
||||
type PodsTopOptions struct {
|
||||
@@ -49,7 +48,7 @@ func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (*unst
|
||||
|
||||
func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (string, error) {
|
||||
namespace = k.NamespaceOrDefault(namespace)
|
||||
pod, err := k.manager.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||
pod, err := k.ResourcesGet(ctx, &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}, namespace, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -62,11 +61,15 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st
|
||||
|
||||
// Delete managed service
|
||||
if isManaged {
|
||||
if sl, _ := k.manager.clientSet.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{
|
||||
services, err := k.manager.accessControlClientSet.Services(namespace)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sl, _ := services.List(ctx, metav1.ListOptions{
|
||||
LabelSelector: managedLabelSelector.String(),
|
||||
}); sl != nil {
|
||||
for _, svc := range sl.Items {
|
||||
_ = k.manager.clientSet.CoreV1().Services(namespace).Delete(ctx, svc.Name, metav1.DeleteOptions{})
|
||||
_ = services.Delete(ctx, svc.Name, metav1.DeleteOptions{})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,12 +89,16 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st
|
||||
|
||||
}
|
||||
return "Pod deleted successfully",
|
||||
k.manager.clientSet.CoreV1().Pods(namespace).Delete(ctx, name, metav1.DeleteOptions{})
|
||||
k.ResourcesDelete(ctx, &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}, namespace, name)
|
||||
}
|
||||
|
||||
func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string) (string, error) {
|
||||
tailLines := int64(256)
|
||||
req := k.manager.clientSet.CoreV1().Pods(k.NamespaceOrDefault(namespace)).GetLogs(name, &v1.PodLogOptions{
|
||||
pods, err := k.manager.accessControlClientSet.Pods(k.NamespaceOrDefault(namespace))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req := pods.GetLogs(name, &v1.PodLogOptions{
|
||||
TailLines: &tailLines,
|
||||
Container: container,
|
||||
})
|
||||
@@ -197,30 +204,16 @@ func (k *Kubernetes) PodsTop(ctx context.Context, options PodsTopOptions) (*metr
|
||||
} else {
|
||||
namespace = k.NamespaceOrDefault(namespace)
|
||||
}
|
||||
metricsClient, err := metricsclientset.NewForConfig(k.manager.cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create metrics client: %w", err)
|
||||
}
|
||||
versionedMetrics := &metricsv1beta1api.PodMetricsList{}
|
||||
if options.Name != "" {
|
||||
m, err := metricsClient.MetricsV1beta1().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 = metricsClient.MetricsV1beta1().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)
|
||||
return k.manager.accessControlClientSet.PodsMetricses(ctx, namespace, options.Name, options.ListOptions)
|
||||
}
|
||||
|
||||
func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
|
||||
namespace = k.NamespaceOrDefault(namespace)
|
||||
pod, err := k.manager.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||
pods, err := k.manager.accessControlClientSet.Pods(namespace)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pod, err := pods.Get(ctx, name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -237,7 +230,7 @@ func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container st
|
||||
Stdout: true,
|
||||
Stderr: true,
|
||||
}
|
||||
executor, err := k.createExecutor(namespace, name, podExecOptions)
|
||||
executor, err := k.manager.accessControlClientSet.PodsExec(namespace, name, podExecOptions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -256,26 +249,3 @@ func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container st
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (k *Kubernetes) createExecutor(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) {
|
||||
// Compute URL
|
||||
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397
|
||||
req := k.manager.clientSet.CoreV1().RESTClient().
|
||||
Post().
|
||||
Resource("pods").
|
||||
Namespace(namespace).
|
||||
Name(name).
|
||||
SubResource("exec")
|
||||
req.VersionedParams(podExecOptions, k.manager.parameterCodec)
|
||||
spdyExec, err := remotecommand.NewSPDYExecutor(k.manager.cfg, "POST", req.URL())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webSocketExec, err := remotecommand.NewWebSocketExecutor(k.manager.cfg, "GET", req.URL().String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool {
|
||||
return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,10 +34,6 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !k.isAllowed(gvk) {
|
||||
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
|
||||
}
|
||||
|
||||
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
|
||||
isNamespaced, _ := k.isNamespaced(gvk)
|
||||
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
|
||||
@@ -55,10 +51,6 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !k.isAllowed(gvk) {
|
||||
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
|
||||
}
|
||||
|
||||
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
|
||||
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
|
||||
namespace = k.NamespaceOrDefault(namespace)
|
||||
@@ -86,10 +78,6 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
|
||||
return err
|
||||
}
|
||||
|
||||
if !k.isAllowed(gvk) {
|
||||
return fmt.Errorf("resource not allowed: %s", gvk.String())
|
||||
}
|
||||
|
||||
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
|
||||
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
|
||||
namespace = k.NamespaceOrDefault(namespace)
|
||||
@@ -113,7 +101,7 @@ func (k *Kubernetes) resourcesListAsTable(ctx context.Context, gvk *schema.Group
|
||||
}
|
||||
url = append(url, gvr.Resource)
|
||||
var table metav1.Table
|
||||
err := k.manager.clientSet.CoreV1().RESTClient().
|
||||
err := k.manager.discoveryClient.RESTClient().
|
||||
Get().
|
||||
SetHeader("Accept", strings.Join([]string{
|
||||
fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1.SchemeGroupVersion.Version, metav1.GroupName),
|
||||
@@ -121,7 +109,7 @@ func (k *Kubernetes) resourcesListAsTable(ctx context.Context, gvk *schema.Group
|
||||
"application/json",
|
||||
}, ",")).
|
||||
AbsPath(url...).
|
||||
SpecificallyVersionedParams(&options.ListOptions, k.manager.parameterCodec, schema.GroupVersion{Version: "v1"}).
|
||||
SpecificallyVersionedParams(&options.ListOptions, ParameterCodec, schema.GroupVersion{Version: "v1"}).
|
||||
Do(ctx).Into(&table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -152,10 +140,6 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
|
||||
return nil, rErr
|
||||
}
|
||||
|
||||
if !k.isAllowed(&gvk) {
|
||||
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
|
||||
}
|
||||
|
||||
namespace := obj.GetNamespace()
|
||||
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
|
||||
if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced {
|
||||
@@ -169,44 +153,20 @@ 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.deferredDiscoveryRESTMapper.Reset()
|
||||
k.manager.accessControlRESTMapper.Reset()
|
||||
}
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {
|
||||
m, err := k.manager.deferredDiscoveryRESTMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version)
|
||||
m, err := k.manager.accessControlRESTMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m.Resource, nil
|
||||
}
|
||||
|
||||
// isAllowed checks the resource is in denied list or not.
|
||||
// If it is in denied list, this function returns false.
|
||||
func (k *Kubernetes) isAllowed(gvk *schema.GroupVersionKind) bool {
|
||||
if k.manager.StaticConfig == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, val := range k.manager.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 (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
|
||||
apiResourceList, err := k.manager.discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String())
|
||||
if err != nil {
|
||||
@@ -228,7 +188,11 @@ func (k *Kubernetes) supportsGroupVersion(groupVersion string) bool {
|
||||
}
|
||||
|
||||
func (k *Kubernetes) canIUse(ctx context.Context, gvr *schema.GroupVersionResource, namespace, verb string) bool {
|
||||
response, err := k.manager.clientSet.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authv1.SelfSubjectAccessReview{
|
||||
accessReviews, err := k.manager.accessControlClientSet.SelfSubjectAccessReviews()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
response, err := accessReviews.Create(ctx, &authv1.SelfSubjectAccessReview{
|
||||
Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authv1.ResourceAttributes{
|
||||
Namespace: namespace,
|
||||
Verb: verb,
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestEventsListDenied(t *testing.T) {
|
||||
t.Run("events_list describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to list events in all namespaces: resource not allowed: /v1, Kind=Event"
|
||||
if eventList.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, eventList.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, eventList.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,7 +59,6 @@ func TestHelmInstall(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHelmInstallDenied(t *testing.T) {
|
||||
t.Skip("To be implemented") // TODO: helm_install is not checking for denied resources
|
||||
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
|
||||
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
@@ -74,9 +73,10 @@ func TestHelmInstallDenied(t *testing.T) {
|
||||
}
|
||||
})
|
||||
t.Run("helm_install describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to install helm chart: resource not allowed: /v1, Kind=Secret"
|
||||
if helmInstall.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, helmInstall.Content[0].(mcp.TextContent).Text)
|
||||
toolOutput := helmInstall.Content[0].(mcp.TextContent).Text
|
||||
expectedMessage := ": resource not allowed: /v1, Kind=Secret"
|
||||
if !strings.HasPrefix(toolOutput, "failed to install helm chart") || !strings.HasSuffix(toolOutput, expectedMessage) {
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, helmInstall.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -225,7 +225,33 @@ func TestHelmUninstall(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHelmUninstallDenied(t *testing.T) {
|
||||
t.Skip("To be implemented") // TODO: helm_uninstall is not checking for denied resources
|
||||
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
|
||||
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
kc := c.newKubernetesClient()
|
||||
clearHelmReleases(c.ctx, kc)
|
||||
_, _ = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "sh.helm.release.v1.existent-release-to-uninstall.v0",
|
||||
Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
|
||||
"\"name\":\"existent-release-to-uninstall\"," +
|
||||
"\"info\":{\"status\":\"deployed\"}," +
|
||||
"\"manifest\":\"apiVersion: v1\\nkind: Secret\\nmetadata:\\n name: secret-to-deny\\n namespace: default\\n\"" +
|
||||
"}"))),
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
helmUninstall, _ := c.callTool("helm_uninstall", map[string]interface{}{
|
||||
"name": "existent-release-to-uninstall",
|
||||
})
|
||||
t.Run("helm_uninstall has error", func(t *testing.T) {
|
||||
if !helmUninstall.IsError {
|
||||
t.Fatalf("call tool should fail")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func clearHelmReleases(ctx context.Context, kc *kubernetes.Clientset) {
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestNamespacesListDenied(t *testing.T) {
|
||||
t.Run("namespaces_list describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to list namespaces: resource not allowed: /v1, Kind=Namespace"
|
||||
if namespacesList.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, namespacesList.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, namespacesList.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -167,7 +167,7 @@ func TestProjectsListInOpenShiftDenied(t *testing.T) {
|
||||
t.Run("projects_list describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to list projects: resource not allowed: project.openshift.io/v1, Kind=Project"
|
||||
if projectsList.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, projectsList.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, projectsList.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/manusa/kubernetes-mcp-server/pkg/config"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"io"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
@@ -101,5 +102,25 @@ func TestPodsExec(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPodsExecDenied(t *testing.T) {
|
||||
t.Skip("To be implemented") // TODO: exec is not checking for denied resources
|
||||
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
|
||||
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
podsRun, _ := c.callTool("pods_exec", map[string]interface{}{
|
||||
"namespace": "default",
|
||||
"name": "pod-to-exec",
|
||||
"command": []interface{}{"ls", "-l"},
|
||||
"container": "a-specific-container",
|
||||
})
|
||||
t.Run("pods_exec has error", func(t *testing.T) {
|
||||
if !podsRun.IsError {
|
||||
t.Fatalf("call tool should fail")
|
||||
}
|
||||
})
|
||||
t.Run("pods_exec describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to exec in pod pod-to-exec in namespace default: resource not allowed: /v1, Kind=Pod"
|
||||
if podsRun.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsRun.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestPodsListDenied(t *testing.T) {
|
||||
t.Run("pods_list describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to list pods in all namespaces: resource not allowed: /v1, Kind=Pod"
|
||||
if podsList.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsList.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsList.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
podsListInNamespace, _ := c.callTool("pods_list_in_namespace", map[string]interface{}{"namespace": "ns-1"})
|
||||
@@ -202,7 +202,7 @@ func TestPodsListDenied(t *testing.T) {
|
||||
t.Run("pods_list_in_namespace describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to list pods in namespace ns-1: resource not allowed: /v1, Kind=Pod"
|
||||
if podsListInNamespace.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsListInNamespace.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsListInNamespace.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -425,7 +425,7 @@ func TestPodsGetDenied(t *testing.T) {
|
||||
t.Run("pods_get describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to get pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
|
||||
if podsGet.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsGet.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsGet.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -563,7 +563,6 @@ func TestPodsDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPodsDeleteDenied(t *testing.T) {
|
||||
t.Skip("To be implemented") // TODO: delete is not checking for denied resources
|
||||
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
|
||||
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
@@ -576,7 +575,7 @@ func TestPodsDeleteDenied(t *testing.T) {
|
||||
t.Run("pods_delete describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to delete pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
|
||||
if podsDelete.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsDelete.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsDelete.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -723,7 +722,6 @@ func TestPodsLog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPodsLogDenied(t *testing.T) {
|
||||
t.Skip("To be implemented") // TODO: log is not checking for denied resources
|
||||
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
|
||||
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
@@ -734,9 +732,9 @@ func TestPodsLogDenied(t *testing.T) {
|
||||
}
|
||||
})
|
||||
t.Run("pods_log describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to log pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
|
||||
expectedMessage := "failed to get pod a-pod-in-default log in namespace : resource not allowed: /v1, Kind=Pod"
|
||||
if podsLog.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsLog.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsLog.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -905,7 +903,7 @@ func TestPodsRunDenied(t *testing.T) {
|
||||
t.Run("pods_run describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to run pod in namespace : resource not allowed: /v1, Kind=Pod"
|
||||
if podsRun.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsRun.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsRun.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
|
||||
"github.com/manusa/kubernetes-mcp-server/pkg/config"
|
||||
)
|
||||
|
||||
func TestPodsTopMetricsUnavailable(t *testing.T) {
|
||||
@@ -206,5 +209,40 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPodsTopDenied(t *testing.T) {
|
||||
t.Skip("To be implemented") // TODO: top is not checking for denied resources
|
||||
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Group: "metrics.k8s.io", Version: "v1beta1"}}}
|
||||
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
|
||||
mockServer := NewMockServer()
|
||||
defer mockServer.Close()
|
||||
c.withKubeConfig(mockServer.config)
|
||||
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
|
||||
if req.URL.Path == "/api" {
|
||||
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["metrics.k8s.io/v1beta1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
|
||||
return
|
||||
}
|
||||
// Request Performed by DiscoveryClient to Kube API (Get API Groups)
|
||||
if req.URL.Path == "/apis" {
|
||||
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
|
||||
return
|
||||
}
|
||||
// Request Performed by DiscoveryClient to Kube API (Get API Resources)
|
||||
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
|
||||
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"PodMetrics","verbs":["get","list"]}]}`))
|
||||
return
|
||||
}
|
||||
}))
|
||||
podsTop, _ := c.callTool("pods_top", map[string]interface{}{})
|
||||
t.Run("pods_run has error", func(t *testing.T) {
|
||||
if !podsTop.IsError {
|
||||
t.Fatalf("call tool should fail")
|
||||
}
|
||||
})
|
||||
t.Run("pods_run describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to get pods top: resource not allowed: metrics.k8s.io/v1beta1, Kind=PodMetrics"
|
||||
if podsTop.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsTop.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestResourcesListDenied(t *testing.T) {
|
||||
t.Run("resources_list (denied by kind) describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to list resources: resource not allowed: /v1, Kind=Secret"
|
||||
if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
deniedByGroup, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role"})
|
||||
@@ -181,7 +181,7 @@ func TestResourcesListDenied(t *testing.T) {
|
||||
t.Run("resources_list (denied by group) describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to list resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
|
||||
if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
allowedResource, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
|
||||
@@ -381,7 +381,7 @@ func TestResourcesGetDenied(t *testing.T) {
|
||||
t.Run("resources_get (denied by kind) describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to get resource: resource not allowed: /v1, Kind=Secret"
|
||||
if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
deniedByGroup, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"})
|
||||
@@ -393,7 +393,7 @@ func TestResourcesGetDenied(t *testing.T) {
|
||||
t.Run("resources_get (denied by group) describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to get resource: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
|
||||
if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
allowedResource, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "default"})
|
||||
@@ -601,7 +601,7 @@ func TestResourcesCreateOrUpdateDenied(t *testing.T) {
|
||||
t.Run("resources_create_or_update (denied by kind) describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to create or update resources: resource not allowed: /v1, Kind=Secret"
|
||||
if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
roleYaml := "apiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n name: a-denied-role\n namespace: default\n"
|
||||
@@ -614,7 +614,7 @@ func TestResourcesCreateOrUpdateDenied(t *testing.T) {
|
||||
t.Run("resources_create_or_update (denied by group) describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to create or update resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
|
||||
if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
configMapYaml := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a-cm-created-or-updated\n namespace: default\n"
|
||||
@@ -766,7 +766,7 @@ func TestResourcesDeleteDenied(t *testing.T) {
|
||||
t.Run("resources_delete (denied by kind) describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to delete resource: resource not allowed: /v1, Kind=Secret"
|
||||
if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
deniedByGroup, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"})
|
||||
@@ -778,7 +778,7 @@ func TestResourcesDeleteDenied(t *testing.T) {
|
||||
t.Run("resources_delete (denied by group) describes denial", func(t *testing.T) {
|
||||
expectedMessage := "failed to delete resource: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
|
||||
if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
|
||||
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
|
||||
}
|
||||
})
|
||||
allowedResource, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "allowed-configmap-to-delete"})
|
||||
|
||||
Reference in New Issue
Block a user