Skip to content

Commit 80488ef

Browse files
committed
feat(resources): initial support for resource listing
1 parent 590f47c commit 80488ef

File tree

12 files changed

+368
-93
lines changed

12 files changed

+368
-93
lines changed

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ require (
77
github.com/spf13/afero v1.12.0
88
github.com/spf13/cobra v1.8.1
99
github.com/spf13/viper v1.19.0
10+
k8s.io/api v0.32.1
11+
k8s.io/apimachinery v0.32.1
1012
k8s.io/cli-runtime v0.32.1
1113
k8s.io/client-go v0.32.1
1214
k8s.io/component-base v0.32.1
@@ -87,9 +89,7 @@ require (
8789
gopkg.in/inf.v0 v0.9.1 // indirect
8890
gopkg.in/ini.v1 v1.67.0 // indirect
8991
gopkg.in/yaml.v3 v3.0.1 // indirect
90-
k8s.io/api v0.32.1 // indirect
9192
k8s.io/apiextensions-apiserver v0.32.0 // indirect
92-
k8s.io/apimachinery v0.32.1 // indirect
9393
k8s.io/klog/v2 v2.130.1 // indirect
9494
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
9595
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect

pkg/kubernetes/config.go renamed to pkg/kubernetes/configuration.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ package kubernetes
22

33
import (
44
"bytes"
5-
"k8s.io/cli-runtime/pkg/genericclioptions"
65
"k8s.io/cli-runtime/pkg/genericiooptions"
76
"k8s.io/client-go/tools/clientcmd"
87
"k8s.io/component-base/cli/flag"
98
"k8s.io/kubectl/pkg/cmd/config"
10-
"k8s.io/kubectl/pkg/scheme"
119
)
1210

1311
func ConfigurationView() (string, error) {
@@ -17,7 +15,7 @@ func ConfigurationView() (string, error) {
1715
o := &config.ViewOptions{
1816
IOStreams: ioStreams,
1917
ConfigAccess: pathOptions,
20-
PrintFlags: genericclioptions.NewPrintFlags("").WithTypeSetter(scheme.Scheme).WithDefaultOutput("yaml"),
18+
PrintFlags: defaultPrintFlags(),
2119
Flatten: true,
2220
Minify: true,
2321
Merge: flag.True,

pkg/kubernetes/kubernetes.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package kubernetes
2+
3+
import (
4+
"k8s.io/cli-runtime/pkg/genericclioptions"
5+
"k8s.io/client-go/rest"
6+
"k8s.io/client-go/restmapper"
7+
"k8s.io/client-go/tools/clientcmd"
8+
"k8s.io/kubectl/pkg/scheme"
9+
)
10+
11+
type Kubernetes struct {
12+
cfg *rest.Config
13+
deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper
14+
}
15+
16+
func NewKubernetes() (*Kubernetes, error) {
17+
cfg, err := resolveClientConfig()
18+
if err != nil {
19+
return nil, err
20+
}
21+
return &Kubernetes{cfg: cfg}, nil
22+
}
23+
24+
func defaultPrintFlags() *genericclioptions.PrintFlags {
25+
return genericclioptions.NewPrintFlags("").
26+
WithTypeSetter(scheme.Scheme).
27+
WithDefaultOutput("yaml")
28+
}
29+
30+
func resolveClientConfig() (*rest.Config, error) {
31+
inClusterConfig, err := rest.InClusterConfig()
32+
if err == nil && inClusterConfig != nil {
33+
return inClusterConfig, nil
34+
}
35+
pathOptions := clientcmd.NewDefaultPathOptions()
36+
return clientcmd.BuildConfigFromFlags("", pathOptions.GetDefaultFilename())
37+
}

pkg/kubernetes/pods.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package kubernetes
2+
3+
import (
4+
"context"
5+
"k8s.io/apimachinery/pkg/runtime/schema"
6+
)
7+
8+
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context) (string, error) {
9+
return k.ResourcesList(ctx, &schema.GroupVersionKind{
10+
Group: "", Version: "v1", Kind: "Pod",
11+
}, "")
12+
}
13+
14+
func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string) (string, error) {
15+
return k.ResourcesList(ctx, &schema.GroupVersionKind{
16+
Group: "", Version: "v1", Kind: "Pod",
17+
}, namespace)
18+
}

pkg/kubernetes/resources.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package kubernetes
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
7+
"k8s.io/apimachinery/pkg/runtime/schema"
8+
"k8s.io/client-go/discovery"
9+
memory "k8s.io/client-go/discovery/cached"
10+
"k8s.io/client-go/dynamic"
11+
"k8s.io/client-go/restmapper"
12+
)
13+
14+
// TODO: WIP
15+
func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string) (string, error) {
16+
client, err := dynamic.NewForConfig(k.cfg)
17+
if err != nil {
18+
return "", err
19+
}
20+
gvr, err := k.resourceFor(gvk)
21+
if err != nil {
22+
return "", err
23+
}
24+
rl, err := client.Resource(*gvr).Namespace("").List(ctx, metav1.ListOptions{})
25+
if err != nil {
26+
return "", err
27+
}
28+
return marshal(rl.Items)
29+
}
30+
31+
func marshal(v any) (string, error) {
32+
ret, err := json.Marshal(v)
33+
if err != nil {
34+
return "", err
35+
}
36+
return string(ret), nil
37+
}
38+
39+
func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {
40+
if k.deferredDiscoveryRESTMapper == nil {
41+
d, err := discovery.NewDiscoveryClientForConfig(k.cfg)
42+
if err != nil {
43+
return nil, err
44+
}
45+
k.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(d))
46+
}
47+
m, err := k.deferredDiscoveryRESTMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version)
48+
if err != nil {
49+
return nil, err
50+
}
51+
return &m.Resource, nil
52+
}

pkg/mcp/common_test.go

Lines changed: 75 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,81 +6,38 @@ import (
66
"github.com/mark3labs/mcp-go/mcp"
77
"github.com/mark3labs/mcp-go/server"
88
"github.com/spf13/afero"
9+
"k8s.io/client-go/kubernetes"
910
"k8s.io/client-go/rest"
1011
"k8s.io/client-go/tools/clientcmd"
1112
"k8s.io/client-go/tools/clientcmd/api"
1213
"net/http/httptest"
1314
"os"
1415
"path/filepath"
1516
"runtime"
16-
"testing"
17-
1817
"sigs.k8s.io/controller-runtime/pkg/envtest"
1918
"sigs.k8s.io/controller-runtime/tools/setup-envtest/env"
2019
"sigs.k8s.io/controller-runtime/tools/setup-envtest/remote"
2120
"sigs.k8s.io/controller-runtime/tools/setup-envtest/store"
2221
"sigs.k8s.io/controller-runtime/tools/setup-envtest/versions"
2322
"sigs.k8s.io/controller-runtime/tools/setup-envtest/workflows"
23+
"testing"
2424
)
2525

26-
func setupEnvTest() *envtest.Environment {
27-
envTestDir, err := store.DefaultStoreDir()
28-
if err != nil {
29-
panic(err)
30-
}
31-
envTest := &env.Env{
32-
FS: afero.Afero{Fs: afero.NewOsFs()},
33-
Out: os.Stdout,
34-
Client: &remote.HTTPClient{
35-
IndexURL: remote.DefaultIndexURL,
36-
},
37-
Platform: versions.PlatformItem{
38-
Platform: versions.Platform{
39-
OS: runtime.GOOS,
40-
Arch: runtime.GOARCH,
41-
},
42-
},
43-
Version: versions.AnyVersion,
44-
Store: store.NewAt(envTestDir),
45-
}
46-
envTest.CheckCoherence()
47-
workflows.Use{}.Do(envTest)
48-
versionDir := envTest.Platform.Platform.BaseName(*envTest.Version.AsConcrete())
49-
return &envtest.Environment{
50-
BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir),
51-
}
52-
}
53-
54-
func withKubeConfig(t *testing.T, c *rest.Config) *api.Config {
55-
fakeConfig := api.NewConfig()
56-
fakeConfig.CurrentContext = "fake-context"
57-
fakeConfig.Clusters["fake"] = api.NewCluster()
58-
fakeConfig.Clusters["fake"].Server = c.Host
59-
fakeConfig.Clusters["fake"].CertificateAuthorityData = c.TLSClientConfig.CAData
60-
fakeConfig.Contexts["fake-context"] = api.NewContext()
61-
fakeConfig.Contexts["fake-context"].Cluster = "fake"
62-
fakeConfig.Contexts["fake-context"].AuthInfo = "fake"
63-
fakeConfig.AuthInfos["fake"] = api.NewAuthInfo()
64-
fakeConfig.AuthInfos["fake"].ClientKeyData = c.TLSClientConfig.KeyData
65-
fakeConfig.AuthInfos["fake"].ClientCertificateData = c.TLSClientConfig.CertData
66-
dir := t.TempDir()
67-
kubeConfig := filepath.Join(dir, "config")
68-
clientcmd.WriteToFile(*fakeConfig, kubeConfig)
69-
os.Setenv("KUBECONFIG", kubeConfig)
70-
return fakeConfig
71-
}
72-
7326
type mcpContext struct {
7427
ctx context.Context
28+
tempDir string
7529
testServer *httptest.Server
7630
cancel context.CancelFunc
7731
mcpClient *client.SSEMCPClient
32+
envTest *envtest.Environment
7833
}
7934

8035
func (c *mcpContext) beforeEach(t *testing.T) {
8136
var err error
82-
c.testServer = server.NewTestServer(NewSever().server)
8337
c.ctx, c.cancel = context.WithCancel(context.Background())
38+
c.tempDir = t.TempDir()
39+
c.withKubeConfig(nil)
40+
c.testServer = server.NewTestServer(NewSever().server)
8441
if c.mcpClient, err = client.NewSSEMCPClient(c.testServer.URL + "/sse"); err != nil {
8542
t.Fatal(err)
8643
return
@@ -100,6 +57,9 @@ func (c *mcpContext) beforeEach(t *testing.T) {
10057
}
10158

10259
func (c *mcpContext) afterEach() {
60+
if c.envTest != nil {
61+
_ = c.envTest.Stop()
62+
}
10363
c.cancel()
10464
_ = c.mcpClient.Close()
10565
c.testServer.Close()
@@ -113,3 +73,68 @@ func testCase(test func(t *testing.T, c *mcpContext)) func(*testing.T) {
11373
test(t, mcpCtx)
11474
}
11575
}
76+
77+
func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config {
78+
fakeConfig := api.NewConfig()
79+
fakeConfig.CurrentContext = "fake-context"
80+
fakeConfig.Contexts["fake-context"] = api.NewContext()
81+
fakeConfig.Contexts["fake-context"].Cluster = "fake"
82+
fakeConfig.Contexts["fake-context"].AuthInfo = "fake"
83+
fakeConfig.Clusters["fake"] = api.NewCluster()
84+
fakeConfig.Clusters["fake"].Server = "https://example.com"
85+
fakeConfig.AuthInfos["fake"] = api.NewAuthInfo()
86+
if rc != nil {
87+
fakeConfig.Clusters["fake"].Server = rc.Host
88+
fakeConfig.Clusters["fake"].CertificateAuthorityData = rc.TLSClientConfig.CAData
89+
fakeConfig.AuthInfos["fake"].ClientKeyData = rc.TLSClientConfig.KeyData
90+
fakeConfig.AuthInfos["fake"].ClientCertificateData = rc.TLSClientConfig.CertData
91+
}
92+
kubeConfig := filepath.Join(c.tempDir, "config")
93+
_ = clientcmd.WriteToFile(*fakeConfig, kubeConfig)
94+
_ = os.Setenv("KUBECONFIG", kubeConfig)
95+
return fakeConfig
96+
}
97+
98+
func (c *mcpContext) withEnvTest() {
99+
if c.envTest != nil {
100+
return
101+
}
102+
envTestDir, err := store.DefaultStoreDir()
103+
if err != nil {
104+
panic(err)
105+
}
106+
envTest := &env.Env{
107+
FS: afero.Afero{Fs: afero.NewOsFs()},
108+
Out: os.Stdout,
109+
Client: &remote.HTTPClient{
110+
IndexURL: remote.DefaultIndexURL,
111+
},
112+
Platform: versions.PlatformItem{
113+
Platform: versions.Platform{
114+
OS: runtime.GOOS,
115+
Arch: runtime.GOARCH,
116+
},
117+
},
118+
Version: versions.AnyVersion,
119+
Store: store.NewAt(envTestDir),
120+
}
121+
envTest.CheckCoherence()
122+
workflows.Use{}.Do(envTest)
123+
versionDir := envTest.Platform.Platform.BaseName(*envTest.Version.AsConcrete())
124+
c.envTest = &envtest.Environment{
125+
BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir),
126+
}
127+
restConfig, _ := c.envTest.Start()
128+
c.withKubeConfig(restConfig)
129+
}
130+
131+
func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset {
132+
c.withEnvTest()
133+
pathOptions := clientcmd.NewDefaultPathOptions()
134+
cfg, _ := clientcmd.BuildConfigFromFlags("", pathOptions.GetDefaultFilename())
135+
kubernetesClient, err := kubernetes.NewForConfig(cfg)
136+
if err != nil {
137+
panic(err)
138+
}
139+
return kubernetesClient
140+
}

pkg/mcp/configuration.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@ package mcp
22

33
import (
44
"context"
5+
"fmt"
56
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
67
"github.com/mark3labs/mcp-go/mcp"
78
)
89

10+
func (s *Sever) initConfiguration() {
11+
s.server.AddTool(mcp.NewTool(
12+
"configuration_view",
13+
mcp.WithDescription("Get the current Kubernetes configuration content as a kubeconfig YAML"),
14+
), configurationView)
15+
}
16+
917
func configurationView(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
10-
cfg, err := kubernetes.ConfigurationView()
18+
ret, err := kubernetes.ConfigurationView()
1119
if err != nil {
12-
return nil, err
20+
err = fmt.Errorf("failed to get configuration view: %v", err)
1321
}
14-
return &mcp.CallToolResult{
15-
Content: []interface{}{
16-
mcp.TextContent{
17-
Type: "text",
18-
Text: cfg,
19-
},
20-
},
21-
}, nil
22+
return NewTextResult(ret, err), nil
2223
}

pkg/mcp/configuration_test.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,6 @@ import (
77
)
88

99
func TestConfigurationView(t *testing.T) {
10-
envTest := setupEnvTest()
11-
defer envTest.Stop()
12-
envTestConfig, err := envTest.Start()
13-
withKubeConfig(t, envTestConfig)
14-
if err != nil {
15-
t.Errorf("Error starting test environment: %s", err)
16-
return
17-
}
18-
defer func() {
19-
if stopErr := envTest.Stop(); stopErr != nil {
20-
panic(stopErr)
21-
}
22-
}()
2310
t.Run("configuration_view returns configuration", testCase(func(t *testing.T, c *mcpContext) {
2411
configurationGet := mcp.CallToolRequest{}
2512
configurationGet.Params.Name = "configuration_view"

0 commit comments

Comments
 (0)