diff --git a/.gitignore b/.gitignore index 12b624e7..a705e2a1 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ python/build/ python/dist/ python/kubernetes_mcp_server.egg-info/ !python/kubernetes-mcp-server +/kubernetes-mcp-server.iml diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index db0ac542..84a1cf90 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -22,6 +22,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/config" "github.com/containers/kubernetes-mcp-server/pkg/helm" + "github.com/containers/kubernetes-mcp-server/pkg/olm" _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" ) @@ -212,3 +213,8 @@ func (k *Kubernetes) NewHelm() *helm.Helm { // This is a derived Kubernetes, so it already has the Helm initialized return helm.NewHelm(k.manager) } + +func (k *Kubernetes) NewOlm() *olm.Olm { + // Provide an OLM wrapper backed by the manager + return olm.NewOlm(k.manager) +} diff --git a/pkg/mcp/olm.go b/pkg/mcp/olm.go new file mode 100644 index 00000000..5a1f6192 --- /dev/null +++ b/pkg/mcp/olm.go @@ -0,0 +1,117 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func (s *Server) initOlm() []server.ServerTool { + return []server.ServerTool{ + {Tool: mcp.NewTool("olm_install", + mcp.WithDescription("Install an OLMv1 ClusterExtension resource from a manifest (YAML or JSON)"), + mcp.WithString("manifest", mcp.Description("ClusterExtension manifest to create or update (YAML or JSON)"), mcp.Required()), + // Tool annotations + mcp.WithTitleAnnotation("OLM: Install"), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(true), + ), Handler: s.olmInstall}, + {Tool: mcp.NewTool("olm_list", + mcp.WithDescription("List OLMv1 ClusterExtension resources in the cluster"), + // Tool annotations + mcp.WithTitleAnnotation("OLM: List"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(true), + ), Handler: s.olmList}, + {Tool: mcp.NewTool("olm_uninstall", + mcp.WithDescription("Uninstall (delete) an OLMv1 ClusterExtension resource by name"), + mcp.WithString("name", mcp.Description("Name of the ClusterExtension to delete"), mcp.Required()), + // Tool annotations + mcp.WithTitleAnnotation("OLM: Uninstall"), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), Handler: s.olmUninstall}, + {Tool: mcp.NewTool("olm_upgrade", + mcp.WithDescription("Upgrade (update) an existing OLMv1 ClusterExtension resource by name using a manifest"), + mcp.WithString("name", mcp.Description("Name of the ClusterExtension to upgrade"), mcp.Required()), + mcp.WithString("manifest", mcp.Description("Manifest to apply to the ClusterExtension (YAML or JSON)"), mcp.Required()), + // Tool annotations + mcp.WithTitleAnnotation("OLM: Upgrade"), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), Handler: s.olmUpgrade}, + } +} + +func (s *Server) olmInstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { + manifest, ok := ctr.GetArguments()["manifest"].(string) + if !ok || manifest == "" { + return NewTextResult("", fmt.Errorf("missing argument manifest")), nil + } + derived, err := s.k.Derived(ctx) + if err != nil { + return nil, err + } + ret, err := derived.NewOlm().Install(ctx, manifest) + if err != nil { + return NewTextResult("", fmt.Errorf("failed to install ClusterExtension: %w", err)), nil + } + return NewTextResult(ret, nil), nil +} + +func (s *Server) olmList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { + derived, err := s.k.Derived(ctx) + if err != nil { + return nil, err + } + ret, err := derived.NewOlm().List(ctx) + if err != nil { + return NewTextResult("", fmt.Errorf("failed to list ClusterExtensions: %w", err)), nil + } + return NewTextResult(ret, nil), nil +} + +func (s *Server) olmUninstall(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, ok := ctr.GetArguments()["name"].(string) + if !ok || name == "" { + return NewTextResult("", fmt.Errorf("missing argument name")), nil + } + derived, err := s.k.Derived(ctx) + if err != nil { + return nil, err + } + ret, err := derived.NewOlm().Uninstall(ctx, name) + if err != nil { + return NewTextResult("", fmt.Errorf("failed to uninstall ClusterExtension: %w", err)), nil + } + return NewTextResult(ret, nil), nil +} + +func (s *Server) olmUpgrade(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, ok := ctr.GetArguments()["name"].(string) + if !ok || name == "" { + return NewTextResult("", fmt.Errorf("missing argument name")), nil + } + manifest, ok := ctr.GetArguments()["manifest"].(string) + if !ok || manifest == "" { + return NewTextResult("", fmt.Errorf("missing argument manifest")), nil + } + derived, err := s.k.Derived(ctx) + if err != nil { + return nil, err + } + ret, err := derived.NewOlm().Upgrade(ctx, name, manifest) + if err != nil { + return NewTextResult("", fmt.Errorf("failed to upgrade ClusterExtension: %w", err)), nil + } + return NewTextResult(ret, nil), nil +} diff --git a/pkg/mcp/olm_test.go b/pkg/mcp/olm_test.go new file mode 100644 index 00000000..aa108fb7 --- /dev/null +++ b/pkg/mcp/olm_test.go @@ -0,0 +1,20 @@ +package mcp + +import ( + "testing" +) + +func TestInitOlmTools(t *testing.T) { + s := &Server{} + tools := s.initOlm() + if len(tools) != 4 { + t.Fatalf("expected 4 tools, got %d", len(tools)) + } + names := map[string]bool{} + for _, t := range tools { + names[t.Tool.Name] = true + } + if !names["olm_install"] || !names["olm_list"] || !names["olm_uninstall"] || !names["olm_upgrade"] { + t.Fatalf("missing expected olm tool names: %v", names) + } +} diff --git a/pkg/mcp/profiles.go b/pkg/mcp/profiles.go index 6c0d9741..48f80c4a 100644 --- a/pkg/mcp/profiles.go +++ b/pkg/mcp/profiles.go @@ -43,6 +43,7 @@ func (p *FullProfile) GetTools(s *Server) []server.ServerTool { s.initPods(), s.initResources(), s.initHelm(), + s.initOlm(), ) } diff --git a/pkg/olm/olm.go b/pkg/olm/olm.go new file mode 100644 index 00000000..d6058263 --- /dev/null +++ b/pkg/olm/olm.go @@ -0,0 +1,339 @@ +package olm + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "sigs.k8s.io/yaml" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" +) + +// Kubernetes exposes a small subset of the manager methods used by the OLM wrapper +type Kubernetes interface { + ToRESTConfig() (*rest.Config, error) + ToRESTMapper() (meta.RESTMapper, error) + ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) + NamespaceOrDefault(namespace string) string +} + +type Olm struct { + kubernetes Kubernetes +} + +func NewOlm(k Kubernetes) *Olm { + return &Olm{kubernetes: k} +} + +func parseManifest(manifest string) (*unstructured.Unstructured, error) { + // Try YAML -> JSON -> map + var obj map[string]interface{} + jsonBytes, err := yaml.YAMLToJSON([]byte(manifest)) + if err != nil { + // try raw JSON + if err := json.Unmarshal([]byte(manifest), &obj); err != nil { + return nil, fmt.Errorf("failed to decode manifest: %w", err) + } + } else { + if err := json.Unmarshal(jsonBytes, &obj); err != nil { + return nil, fmt.Errorf("failed to decode manifest JSON: %w", err) + } + } + return &unstructured.Unstructured{Object: obj}, nil +} + +// findGVR attempts to resolve a GroupVersionResource for the provided unstructured object. +// It first tries the RESTMapper (preferred), then falls back to scanning discovery resources. +func (o *Olm) findGVR(u *unstructured.Unstructured) (schema.GroupVersionResource, error) { + // Try RESTMapper first + if mapper, err := o.kubernetes.ToRESTMapper(); err == nil { + gvk := u.GroupVersionKind() + if gvk.Empty() { + gvk = schema.FromAPIVersionAndKind(u.GetAPIVersion(), u.GetKind()) + } + if !gvk.Empty() { + if mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version); err == nil { + return mapping.Resource, nil + } + } + } + + // Fallback: scan discovery for a resource with the same Kind + disc, err := o.kubernetes.ToDiscoveryClient() + if err != nil { + return schema.GroupVersionResource{}, fmt.Errorf("failed to get discovery client: %w", err) + } + lists, _ := disc.ServerPreferredResources() + for _, apiList := range lists { + gv, _ := schema.ParseGroupVersion(apiList.GroupVersion) + for _, r := range apiList.APIResources { + if strings.EqualFold(r.Kind, u.GetKind()) || r.Name == strings.ToLower(u.GetKind()) || r.Name == strings.ToLower(u.GetKind())+"s" { + return gv.WithResource(r.Name), nil + } + } + } + return schema.GroupVersionResource{}, fmt.Errorf("resource for kind '%s' not found", u.GetKind()) +} + +// Install creates or updates a ClusterExtension (or other OLMv1-managed) resource from a manifest (YAML/JSON) +func (o *Olm) Install(ctx context.Context, manifest string) (string, error) { + cfg, err := o.kubernetes.ToRESTConfig() + if err != nil { + return "", err + } + dyn, err := dynamic.NewForConfig(cfg) + if err != nil { + return "", err + } + u, err := parseManifest(manifest) + if err != nil { + return "", err + } + if u.GetName() == "" { + return "", fmt.Errorf("manifest must include metadata.name") + } + gvr, err := o.findGVR(u) + if err != nil { + return "", err + } + + // Determine if the resource is namespaced using the RESTMapper if possible + namespaced := false + if mapper, err := o.kubernetes.ToRESTMapper(); err == nil { + gvk := u.GroupVersionKind() + if gvk.Empty() { + gvk = schema.FromAPIVersionAndKind(u.GetAPIVersion(), u.GetKind()) + } + if !gvk.Empty() { + if mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version); err == nil { + namespaced = mapping.Scope.Name() == meta.RESTScopeNameNamespace + } + } + } + + var created *unstructured.Unstructured + if namespaced { + ns := u.GetNamespace() + if ns == "" { + ns = o.kubernetes.NamespaceOrDefault("") + } + created, err = dyn.Resource(gvr).Namespace(ns).Create(ctx, u, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + existing, getErr := dyn.Resource(gvr).Namespace(ns).Get(ctx, u.GetName(), metav1.GetOptions{}) + if getErr != nil { + return "", getErr + } + u.SetResourceVersion(existing.GetResourceVersion()) + created, err = dyn.Resource(gvr).Namespace(ns).Update(ctx, u, metav1.UpdateOptions{}) + } + } else { + created, err = dyn.Resource(gvr).Create(ctx, u, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + existing, getErr := dyn.Resource(gvr).Get(ctx, u.GetName(), metav1.GetOptions{}) + if getErr != nil { + return "", getErr + } + u.SetResourceVersion(existing.GetResourceVersion()) + created, err = dyn.Resource(gvr).Update(ctx, u, metav1.UpdateOptions{}) + } + } + if err != nil { + return "", err + } + out, err := yaml.Marshal(created.Object) + if err != nil { + // fallback to JSON string + b, _ := json.Marshal(created.Object) + return string(b), nil + } + return string(out), nil +} + +// List lists ClusterExtension resources (or other discovered kinds with the ClusterExtension kind) +func (o *Olm) List(ctx context.Context) (string, error) { + cfg, err := o.kubernetes.ToRESTConfig() + if err != nil { + return "", err + } + dyn, err := dynamic.NewForConfig(cfg) + if err != nil { + return "", err + } + // Discover the GVR for ClusterExtension + disc, err := o.kubernetes.ToDiscoveryClient() + if err != nil { + return "", err + } + lists, err := disc.ServerPreferredResources() + if err != nil { + // continue if partial success + } + var gvr schema.GroupVersionResource + found := false + for _, apiList := range lists { + gv, _ := schema.ParseGroupVersion(apiList.GroupVersion) + for _, r := range apiList.APIResources { + if strings.EqualFold(r.Kind, "ClusterExtension") || r.Name == "clusterextensions" { + gvr = gv.WithResource(r.Name) + found = true + break + } + } + if found { + break + } + } + if !found { + return "", fmt.Errorf("ClusterExtension resource not found on the cluster") + } + list, err := dyn.Resource(gvr).List(ctx, metav1.ListOptions{}) + if err != nil { + return "", err + } + // Convert items to a slice of plain maps for nicer YAML output + items := make([]map[string]interface{}, 0, len(list.Items)) + for _, it := range list.Items { + items = append(items, it.Object) + } + out, err := yaml.Marshal(items) + if err != nil { + b, _ := json.Marshal(items) + return string(b), nil + } + return string(out), nil +} + +// Uninstall deletes a ClusterExtension by name (cluster-scoped) +func (o *Olm) Uninstall(ctx context.Context, name string) (string, error) { + if name == "" { + return "", fmt.Errorf("name is required") + } + cfg, err := o.kubernetes.ToRESTConfig() + if err != nil { + return "", err + } + dyn, err := dynamic.NewForConfig(cfg) + if err != nil { + return "", err + } + // Find the ClusterExtension GVR + disc, err := o.kubernetes.ToDiscoveryClient() + if err != nil { + return "", err + } + lists, err := disc.ServerPreferredResources() + if err != nil { + // continue if partial + } + var gvr schema.GroupVersionResource + found := false + for _, apiList := range lists { + gv, _ := schema.ParseGroupVersion(apiList.GroupVersion) + for _, r := range apiList.APIResources { + if strings.EqualFold(r.Kind, "ClusterExtension") || r.Name == "clusterextensions" { + gvr = gv.WithResource(r.Name) + found = true + break + } + } + if found { + break + } + } + if !found { + return "", fmt.Errorf("ClusterExtension resource not found on the cluster") + } + err = dyn.Resource(gvr).Delete(ctx, name, metav1.DeleteOptions{}) + if apierrors.IsNotFound(err) { + return fmt.Sprintf("ClusterExtension %s not found", name), nil + } else if err != nil { + return "", err + } + return fmt.Sprintf("ClusterExtension %s deleted", name), nil +} + +// Upgrade updates an existing ClusterExtension resource by name using the provided manifest. +// It fails if the named resource does not exist. +func (o *Olm) Upgrade(ctx context.Context, name string, manifest string) (string, error) { + if name == "" { + return "", fmt.Errorf("name is required") + } + cfg, err := o.kubernetes.ToRESTConfig() + if err != nil { + return "", err + } + dyn, err := dynamic.NewForConfig(cfg) + if err != nil { + return "", err + } + // Find the ClusterExtension GVR + disc, err := o.kubernetes.ToDiscoveryClient() + if err != nil { + return "", err + } + lists, err := disc.ServerPreferredResources() + if err != nil { + // continue if partial + } + var gvr schema.GroupVersionResource + found := false + for _, apiList := range lists { + gv, _ := schema.ParseGroupVersion(apiList.GroupVersion) + for _, r := range apiList.APIResources { + if strings.EqualFold(r.Kind, "ClusterExtension") || r.Name == "clusterextensions" { + gvr = gv.WithResource(r.Name) + found = true + break + } + } + if found { + break + } + } + if !found { + return "", fmt.Errorf("ClusterExtension resource not found on the cluster") + } + + existing, err := dyn.Resource(gvr).Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + return "", fmt.Errorf("ClusterExtension %s not found", name) + } else if err != nil { + return "", err + } + + u, err := parseManifest(manifest) + if err != nil { + return "", err + } + // Ensure name and resourceVersion are set from existing to perform update + u.SetName(name) + if existing.GetNamespace() != "" { + u.SetNamespace(existing.GetNamespace()) + } + u.SetResourceVersion(existing.GetResourceVersion()) + + var updated *unstructured.Unstructured + if existing.GetNamespace() != "" { + updated, err = dyn.Resource(gvr).Namespace(existing.GetNamespace()).Update(ctx, u, metav1.UpdateOptions{}) + } else { + updated, err = dyn.Resource(gvr).Update(ctx, u, metav1.UpdateOptions{}) + } + if err != nil { + return "", err + } + out, err := yaml.Marshal(updated.Object) + if err != nil { + b, _ := json.Marshal(updated.Object) + return string(b), nil + } + return string(out), nil +} diff --git a/pkg/olm/olm_test.go b/pkg/olm/olm_test.go new file mode 100644 index 00000000..2babec19 --- /dev/null +++ b/pkg/olm/olm_test.go @@ -0,0 +1,46 @@ +package olm + +import ( + "testing" +) + +func TestParseManifestYAML(t *testing.T) { + manifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: value +` + u, err := parseManifest(manifest) + if err != nil { + t.Fatalf("parseManifest failed: %v", err) + } + if u.GetName() != "test-cm" { + t.Fatalf("unexpected name: %s", u.GetName()) + } + if u.GetKind() != "ConfigMap" { + t.Fatalf("unexpected kind: %s", u.GetKind()) + } + data, ok := u.Object["data"].(map[string]interface{}) + if !ok { + t.Fatalf("data not found or wrong type") + } + if data["key"] != "value" { + t.Fatalf("unexpected data value: %v", data["key"]) + } +} + +func TestParseManifestJSON(t *testing.T) { + manifest := `{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"json-cm"},"data":{"a":"b"}}` + u, err := parseManifest(manifest) + if err != nil { + t.Fatalf("parseManifest failed: %v", err) + } + if u.GetName() != "json-cm" { + t.Fatalf("unexpected name: %s", u.GetName()) + } + if u.GetKind() != "ConfigMap" { + t.Fatalf("unexpected kind: %s", u.GetKind()) + } +}