refactor(helm): adapt Helm contribution to project structure

This commit is contained in:
Marc Nuri
2025-05-09 18:49:00 +02:00
parent 34eabdef13
commit b4928f8230
12 changed files with 236 additions and 207 deletions

View File

@@ -1,50 +1,52 @@
package helm
import (
"context"
"log"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/release"
"k8s.io/cli-runtime/pkg/genericclioptions"
"log"
"sigs.k8s.io/yaml"
)
// Helm provides methods to interact with Helm releases
// Mirrors the abstraction style of pkg/kubernetes
type Helm struct {
settings *cli.EnvSettings
type Kubernetes interface {
genericclioptions.RESTClientGetter
NamespaceOrDefault(namespace string) string
}
// NewHelm creates a new Helm instance using kubeconfig, context, and namespace settings
func NewHelm(kubeconfig, kubeContext, namespace string) *Helm {
type Helm struct {
kubernetes Kubernetes
}
// NewHelm creates a new Helm instance
func NewHelm(kubernetes Kubernetes, namespace string) *Helm {
settings := cli.New()
if kubeconfig != "" {
settings.KubeConfig = kubeconfig
}
if kubeContext != "" {
settings.KubeContext = kubeContext
}
if namespace != "" {
settings.SetNamespace(namespace)
}
return &Helm{settings: settings}
return &Helm{kubernetes: kubernetes}
}
// ReleasesList lists Helm releases in a specific namespace (or all namespaces if namespace is empty)
func (h *Helm) ReleasesList(ctx context.Context, namespace string) ([]*release.Release, error) {
// If no namespace is given, use the default from kubeconfig
if namespace == "" {
namespace = h.settings.Namespace()
}
// ReleasesList lists all the releases for the specified namespace (or current namespace if). Or allNamespaces is true, it lists all releases across all namespaces.
func (h *Helm) ReleasesList(namespace string, allNamespaces bool) (string, error) {
cfg := new(action.Configuration)
if err := cfg.Init(h.settings.RESTClientGetter(), namespace, "", log.Printf); err != nil {
return nil, err
applicableNamespace := ""
if !allNamespaces {
applicableNamespace = h.kubernetes.NamespaceOrDefault(namespace)
}
if err := cfg.Init(h.kubernetes, applicableNamespace, "", log.Printf); err != nil {
return "", err
}
list := action.NewList(cfg)
// To list across all namespaces, set AllNamespaces to true
if namespace == "" || namespace == "all" {
list.AllNamespaces = true
list.AllNamespaces = allNamespaces
releases, err := list.Run()
if err != nil {
return "", err
} else if len(releases) == 0 {
return "No Helm releases found", nil
}
return list.Run()
ret, err := yaml.Marshal(releases)
if err != nil {
return "", err
}
return string(ret), nil
}

View File

@@ -1,40 +0,0 @@
package helm
import (
"context"
"testing"
)
func TestNewHelm(t *testing.T) {
h := NewHelm("", "", "")
if h == nil {
t.Fatal("expected non-nil Helm instance")
}
if h.settings == nil {
t.Fatal("expected non-nil settings in Helm instance")
}
}
func TestHelm_ReleasesList_DefaultNamespace(t *testing.T) {
h := NewHelm("", "", "")
// Use a namespace that is likely to exist, or leave empty for default
releases, err := h.ReleasesList(context.Background(), "")
if err != nil {
t.Skipf("skipping: could not list releases (likely no cluster/helm configured): %v", err)
}
// No assertion on releases count, just check type
if releases == nil {
t.Error("expected releases slice, got nil")
}
}
func TestHelm_ReleasesList_AllNamespaces(t *testing.T) {
h := NewHelm("", "", "")
releases, err := h.ReleasesList(context.Background(), "all")
if err != nil {
t.Skipf("skipping: could not list all releases (likely no cluster/helm configured): %v", err)
}
if releases == nil {
t.Error("expected releases slice, got nil")
}
}

View File

@@ -52,6 +52,30 @@ func (k *Kubernetes) IsInCluster() bool {
return err == nil && cfg != nil
}
func (k *Kubernetes) configuredNamespace() string {
if ns, _, nsErr := k.clientCmdConfig.Namespace(); nsErr == nil {
return ns
}
return ""
}
func (k *Kubernetes) NamespaceOrDefault(namespace string) string {
if namespace == "" {
return k.configuredNamespace()
}
return namespace
}
// ToRESTConfig returns the rest.Config object (genericclioptions.RESTClientGetter)
func (k *Kubernetes) ToRESTConfig() (*rest.Config, error) {
return k.cfg, nil
}
// ToRawKubeConfigLoader returns the clientcmd.ClientConfig object (genericclioptions.RESTClientGetter)
func (k *Kubernetes) ToRawKubeConfigLoader() clientcmd.ClientConfig {
return k.clientCmdConfig
}
func (k *Kubernetes) ConfigurationView(minify bool) (string, error) {
var cfg clientcmdapi.Config
var err error

View File

@@ -2,7 +2,9 @@ package kubernetes
import (
"github.com/fsnotify/fsnotify"
"github.com/manusa/kubernetes-mcp-server/pkg/helm"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/discovery"
@@ -26,9 +28,10 @@ type Kubernetes struct {
scheme *runtime.Scheme
parameterCodec runtime.ParameterCodec
clientSet kubernetes.Interface
discoveryClient *discovery.DiscoveryClient
discoveryClient discovery.CachedDiscoveryInterface
deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper
dynamicClient *dynamic.DynamicClient
Helm *helm.Helm
}
func NewKubernetes(kubeconfig string) (*Kubernetes, error) {
@@ -43,10 +46,11 @@ func NewKubernetes(kubeconfig string) (*Kubernetes, error) {
if err != nil {
return nil, err
}
k8s.discoveryClient, err = discovery.NewDiscoveryClientForConfig(k8s.cfg)
discoveryClient, err := discovery.NewDiscoveryClientForConfig(k8s.cfg)
if err != nil {
return nil, err
}
k8s.discoveryClient = memory.NewMemCacheClient(discoveryClient)
k8s.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(k8s.discoveryClient))
k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg)
if err != nil {
@@ -57,6 +61,7 @@ func NewKubernetes(kubeconfig string) (*Kubernetes, error) {
return nil, err
}
k8s.parameterCodec = runtime.NewParameterCodec(k8s.scheme)
k8s.Helm = helm.NewHelm(k8s, "TODO")
return k8s, nil
}
@@ -102,6 +107,14 @@ func (k *Kubernetes) Close() {
}
}
func (k *Kubernetes) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
return k.discoveryClient, nil
}
func (k *Kubernetes) ToRESTMapper() (meta.RESTMapper, error) {
return k.deferredDiscoveryRESTMapper, nil
}
func marshal(v any) (string, error) {
switch t := v.(type) {
case []unstructured.Unstructured:
@@ -123,37 +136,3 @@ func marshal(v any) (string, error) {
}
return string(ret), nil
}
// KubeconfigPath returns the kubeconfig path used by this Kubernetes client
func (k *Kubernetes) KubeconfigPath() string {
return k.Kubeconfig
}
// CurrentContext returns the current context from the kubeconfig
func (k *Kubernetes) CurrentContext() string {
if k.clientCmdConfig == nil {
return ""
}
if rawConfig, err := k.clientCmdConfig.RawConfig(); err == nil {
return rawConfig.CurrentContext
}
return ""
}
// ConfiguredNamespace returns the namespace configured in the kubeconfig/context
func (k *Kubernetes) ConfiguredNamespace() string {
if k.clientCmdConfig == nil {
return ""
}
if ns, _, nsErr := k.clientCmdConfig.Namespace(); nsErr == nil {
return ns
}
return ""
}
func (k *Kubernetes) namespaceOrDefault(namespace string) string {
if namespace == "" {
return k.ConfiguredNamespace()
}
return namespace
}

View File

@@ -32,11 +32,11 @@ func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string)
func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (string, error) {
return k.ResourcesGet(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Pod",
}, k.namespaceOrDefault(namespace), name)
}, k.NamespaceOrDefault(namespace), name)
}
func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (string, error) {
namespace = k.namespaceOrDefault(namespace)
namespace = k.NamespaceOrDefault(namespace)
pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return "", err
@@ -79,7 +79,7 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st
func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string) (string, error) {
tailLines := int64(256)
req := k.clientSet.CoreV1().Pods(k.namespaceOrDefault(namespace)).GetLogs(name, &v1.PodLogOptions{
req := k.clientSet.CoreV1().Pods(k.NamespaceOrDefault(namespace)).GetLogs(name, &v1.PodLogOptions{
TailLines: &tailLines,
Container: container,
})
@@ -108,7 +108,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
var resources []any
pod := &v1.Pod{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.namespaceOrDefault(namespace), Labels: labels},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.NamespaceOrDefault(namespace), Labels: labels},
Spec: v1.PodSpec{Containers: []v1.Container{{
Name: name,
Image: image,
@@ -120,7 +120,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
pod.Spec.Containers[0].Ports = []v1.ContainerPort{{ContainerPort: port}}
resources = append(resources, &v1.Service{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.namespaceOrDefault(namespace), Labels: labels},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.NamespaceOrDefault(namespace), Labels: labels},
Spec: v1.ServiceSpec{
Selector: labels,
Type: v1.ServiceTypeClusterIP,
@@ -135,7 +135,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
"kind": "Route",
"metadata": map[string]interface{}{
"name": name,
"namespace": k.namespaceOrDefault(namespace),
"namespace": k.NamespaceOrDefault(namespace),
"labels": labels,
},
"spec": map[string]interface{}{
@@ -175,7 +175,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
}
func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
namespace = k.namespaceOrDefault(namespace)
namespace = k.NamespaceOrDefault(namespace)
pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return "", err

View File

@@ -34,7 +34,7 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
}
// 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)
namespace = k.NamespaceOrDefault(namespace)
}
rg, err := k.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
@@ -64,7 +64,7 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
}
// 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)
namespace = k.NamespaceOrDefault(namespace)
}
return k.dynamicClient.Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
}
@@ -77,7 +77,7 @@ func (k *Kubernetes) resourcesList(ctx context.Context, gvk *schema.GroupVersion
// 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 == "" {
namespace = k.ConfiguredNamespace()
namespace = k.configuredNamespace()
}
return k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
}
@@ -92,7 +92,7 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
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 {
namespace = k.namespaceOrDefault(namespace)
namespace = k.NamespaceOrDefault(namespace)
}
resources[i], rErr = k.dynamicClient.Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{
FieldManager: version.BinaryName,

View File

@@ -5,64 +5,33 @@ import (
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"gopkg.in/yaml.v3"
)
func (s *Server) initHelm() []server.ServerTool {
rets := make([]server.ServerTool, 0)
rets = append(rets, server.ServerTool{
Tool: mcp.NewTool("helm_list",
mcp.WithDescription("List all Helm releases in all namespaces."),
mcp.WithDescription("List all of the Helm releases in the current or provided namespace (or in all namespaces if specified)"),
mcp.WithString("namespace", mcp.Description("Namespace to list Helm releases from (Optional, all namespaces if not provided)")),
mcp.WithBoolean("all_namespaces", mcp.Description("If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)")),
),
Handler: s.helmReleasesList,
})
rets = append(rets, server.ServerTool{
Tool: mcp.NewTool("helm_list_in_namespace",
mcp.WithDescription("List all Helm releases in the specified namespace."),
mcp.WithString("namespace", mcp.Description("Namespace to list Helm releases from."), mcp.Required()),
),
Handler: s.helmListInNamespace,
Handler: s.helmList,
})
return rets
}
func (s *Server) helmReleasesList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
releases, err := s.helm.ReleasesList(ctx, "")
func (s *Server) helmList(_ context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
allNamespaces := false
if v, ok := ctr.Params.Arguments["all_namespaces"].(bool); ok {
allNamespaces = v
}
namespace := ""
if v, ok := ctr.Params.Arguments["namespace"].(string); ok {
namespace = v
}
ret, err := s.k.Helm.ReleasesList(namespace, allNamespaces)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list helm releases: %w", err)), nil
return NewTextResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil
}
for _, r := range releases {
if r != nil && r.Chart != nil {
r.Chart.Templates = nil
r.Chart.Files = nil
}
}
out, err := yaml.Marshal(releases)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to marshal helm releases: %w", err)), nil
}
return NewTextResult(string(out), nil), nil
}
// helmListInNamespace lists Helm releases in a specified namespace
func (s *Server) helmListInNamespace(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := req.Params.Arguments["namespace"]
if ns == nil || ns.(string) == "" {
return NewTextResult("", fmt.Errorf("missing required argument: namespace")), nil
}
releases, err := s.helm.ReleasesList(ctx, ns.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list helm releases in namespace %s: %w", ns, err)), nil
}
for _, r := range releases {
if r != nil && r.Chart != nil {
r.Chart.Templates = nil
r.Chart.Files = nil
}
}
out, err := yaml.Marshal(releases)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to marshal helm releases: %w", err)), nil
}
return NewTextResult(string(out), nil), nil
return NewTextResult(ret, err), nil
}

100
pkg/mcp/helm_test.go Normal file
View File

@@ -0,0 +1,100 @@
package mcp
import (
"encoding/base64"
"github.com/mark3labs/mcp-go/mcp"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
"testing"
)
func TestHelmList(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()
_ = kc.CoreV1().Secrets("default").Delete(c.ctx, "release-to-list", metav1.DeleteOptions{})
toolResult, err := c.callTool("helm_list", map[string]interface{}{})
t.Run("helm_list with no releases, returns not found", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "No Helm releases found" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
})
_, err = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "release-to-list",
Labels: map[string]string{"owner": "helm"},
},
Data: map[string][]byte{
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
"\"name\":\"release-to-list\"," +
"\"info\":{\"status\":\"deployed\"}" +
"}"))),
},
}, metav1.CreateOptions{})
toolResult, err = c.callTool("helm_list", map[string]interface{}{})
t.Run("helm_list with deployed release, returns release", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
var decoded []map[string]interface{}
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
if len(decoded) != 1 {
t.Fatalf("invalid helm list count, expected 1, got %v", len(decoded))
}
if decoded[0]["name"] != "release-to-list" {
t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
}
if decoded[0]["info"].(map[string]interface{})["status"] != "deployed" {
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["info"].(map[string]interface{})["status"])
}
})
toolResult, err = c.callTool("helm_list", map[string]interface{}{"namespace": "ns-1"})
t.Run("helm_list with deployed release in other namespaces, returns not found", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "No Helm releases found" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
})
toolResult, err = c.callTool("helm_list", map[string]interface{}{"namespace": "ns-1", "all_namespaces": true})
t.Run("helm_list with deployed release in all namespaces, returns release", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
var decoded []map[string]interface{}
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
if len(decoded) != 1 {
t.Fatalf("invalid helm list count, expected 1, got %v", len(decoded))
}
if decoded[0]["name"] != "release-to-list" {
t.Fatalf("invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
}
if decoded[0]["info"].(map[string]interface{})["status"] != "deployed" {
t.Fatalf("invalid helm list status, expected deployed, got %v", decoded[0]["info"].(map[string]interface{})["status"])
}
})
})
}

View File

@@ -1,7 +1,6 @@
package mcp
import (
"github.com/manusa/kubernetes-mcp-server/pkg/helm"
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
"github.com/manusa/kubernetes-mcp-server/pkg/version"
"github.com/mark3labs/mcp-go/mcp"
@@ -17,7 +16,6 @@ type Server struct {
configuration *Configuration
server *server.MCPServer
k *kubernetes.Kubernetes
helm *helm.Helm
}
func NewSever(configuration Configuration) (*Server, error) {
@@ -35,13 +33,6 @@ func NewSever(configuration Configuration) (*Server, error) {
if err := s.reloadKubernetesClient(); err != nil {
return nil, err
}
// After Kubernetes client is initialized, set up Helm with the same config
if s.k != nil {
kubeconfig := s.k.KubeconfigPath()
kubeContext := s.k.CurrentContext()
namespace := s.k.ConfiguredNamespace()
s.helm = helm.NewHelm(kubeconfig, kubeContext, namespace)
}
s.k.WatchKubeConfig(s.reloadKubernetesClient)
return s, nil
}

View File

@@ -54,6 +54,7 @@ func TestTools(t *testing.T) {
expectedNames := []string{
"configuration_view",
"events_list",
"helm_list",
"namespaces_list",
"pods_list",
"pods_list_in_namespace",
@@ -61,6 +62,7 @@ func TestTools(t *testing.T) {
"pods_delete",
"pods_log",
"pods_run",
"pods_exec",
"resources_list",
"resources_get",
"resources_create_or_update",