mirror of
https://github.com/openshift/openshift-mcp-server.git
synced 2025-10-17 14:27:48 +03:00
* feat: add cluster provider for kubeconfig Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: move server to use ClusterProvider interface Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: authentication middleware works with cluster provider Signed-off-by: Calum Murray <cmurray@redhat.com> * fix: unit tests work after cluster provider changes Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: add tool mutator to add cluster parameter Signed-off-by: Calum Murray <cmurray@redhat.com> * test: handle cluster parameter Signed-off-by: Calum Murray <cmurray@redhat.com> * fix: handle lazy init correctly Signed-off-by: Calum Murray <cmurray@redhat.com> * refactor: move to using multi-strategy ManagerProvider Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: add contexts_list tool Signed-off-by: Calum Murray <cmurray@redhat.com> * refactor: make tool mutator generic between cluster/context naming Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: introduce tool filter Signed-off-by: Calum Murray <cmurray@redhat.com> * refactor: use new ManagerProvider/mutator/filter within mcp server Signed-off-by: Calum Murray <cmurray@redhat.com> * fix(test): tests expect context parameter in tool defs Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: auth handles multi-cluster case correctly Signed-off-by: Calum Murray <cmurray@redhat.com> * fix: small changes from local testing Signed-off-by: Calum Murray <cmurray@redhat.com> * chore: fix enum test Signed-off-by: Calum Murray <cmurray@redhat.com> * review: Multi Cluster support (#1) * nit: rename contexts_list to configuration_contexts_list Besides the conventional naming, it helps LLMs understand the context of the tool by providing a certain level of hierarchy. Signed-off-by: Marc Nuri <marc@marcnuri.com> * fix(mcp): ToolMutator doesn't rely on magic strings Signed-off-by: Marc Nuri <marc@marcnuri.com> * refactor(api): don't expose ManagerProvider to toolsets Signed-off-by: Marc Nuri <marc@marcnuri.com> * test(mcp): configuration_contexts_list basic tests Signed-off-by: Marc Nuri <marc@marcnuri.com> * test(toolsets): revert edge-case test This test should not be touched. Signed-off-by: Marc Nuri <marc@marcnuri.com> * test(toolsets): add specific metadata tests for multi-cluster Signed-off-by: Marc Nuri <marc@marcnuri.com> * fix(mcp): ToolFilter doesn't rely on magic strings (partially) Signed-off-by: Marc Nuri <marc@marcnuri.com> * test(api): IsClusterAware and IsTargetListProvider default values Signed-off-by: Marc Nuri <marc@marcnuri.com> * test(mcp): revert unneeded changes in mcp_tools_test.go Signed-off-by: Marc Nuri <marc@marcnuri.com> --------- Signed-off-by: Marc Nuri <marc@marcnuri.com> * fix: always include configuration_contexts_list if contexts > 1 Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: include server urls in configuration_contexts_list Signed-off-by: Calum Murray <cmurray@redhat.com> --------- Signed-off-by: Calum Murray <cmurray@redhat.com> Signed-off-by: Marc Nuri <marc@marcnuri.com> Co-authored-by: Marc Nuri <marc@marcnuri.com>
219 lines
5.8 KiB
Go
219 lines
5.8 KiB
Go
package test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
v1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/serializer"
|
|
"k8s.io/apimachinery/pkg/util/httpstream"
|
|
"k8s.io/apimachinery/pkg/util/httpstream/spdy"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/clientcmd"
|
|
"k8s.io/client-go/tools/clientcmd/api"
|
|
)
|
|
|
|
type MockServer struct {
|
|
server *httptest.Server
|
|
config *rest.Config
|
|
restHandlers []http.HandlerFunc
|
|
}
|
|
|
|
func NewMockServer() *MockServer {
|
|
ms := &MockServer{}
|
|
scheme := runtime.NewScheme()
|
|
codecs := serializer.NewCodecFactory(scheme)
|
|
ms.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
for _, handler := range ms.restHandlers {
|
|
handler(w, req)
|
|
}
|
|
}))
|
|
ms.config = &rest.Config{
|
|
Host: ms.server.URL,
|
|
APIPath: "/api",
|
|
ContentConfig: rest.ContentConfig{
|
|
NegotiatedSerializer: codecs,
|
|
ContentType: runtime.ContentTypeJSON,
|
|
GroupVersion: &v1.SchemeGroupVersion,
|
|
},
|
|
}
|
|
ms.restHandlers = make([]http.HandlerFunc, 0)
|
|
return ms
|
|
}
|
|
|
|
func (m *MockServer) Close() {
|
|
if m.server != nil {
|
|
m.server.Close()
|
|
}
|
|
}
|
|
|
|
func (m *MockServer) Handle(handler http.Handler) {
|
|
m.restHandlers = append(m.restHandlers, handler.ServeHTTP)
|
|
}
|
|
|
|
func (m *MockServer) Config() *rest.Config {
|
|
return m.config
|
|
}
|
|
|
|
func (m *MockServer) Kubeconfig() *api.Config {
|
|
fakeConfig := KubeConfigFake()
|
|
fakeConfig.Clusters["fake"].Server = m.config.Host
|
|
fakeConfig.Clusters["fake"].CertificateAuthorityData = m.config.CAData
|
|
fakeConfig.AuthInfos["fake"].ClientKeyData = m.config.KeyData
|
|
fakeConfig.AuthInfos["fake"].ClientCertificateData = m.config.CertData
|
|
return fakeConfig
|
|
}
|
|
|
|
func (m *MockServer) KubeconfigFile(t *testing.T) string {
|
|
return KubeconfigFile(t, m.Kubeconfig())
|
|
}
|
|
|
|
func KubeconfigFile(t *testing.T, kubeconfig *api.Config) string {
|
|
kubeconfigFile := filepath.Join(t.TempDir(), "config")
|
|
err := clientcmd.WriteToFile(*kubeconfig, kubeconfigFile)
|
|
require.NoError(t, err, "Expected no error writing kubeconfig file")
|
|
return kubeconfigFile
|
|
}
|
|
|
|
func WriteObject(w http.ResponseWriter, obj runtime.Object) {
|
|
w.Header().Set("Content-Type", runtime.ContentTypeJSON)
|
|
if err := json.NewEncoder(w).Encode(obj); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type streamAndReply struct {
|
|
httpstream.Stream
|
|
replySent <-chan struct{}
|
|
}
|
|
|
|
type StreamContext struct {
|
|
Closer io.Closer
|
|
StdinStream io.ReadCloser
|
|
StdoutStream io.WriteCloser
|
|
StderrStream io.WriteCloser
|
|
writeStatus func(status *apierrors.StatusError) error
|
|
}
|
|
|
|
type StreamOptions struct {
|
|
Stdin io.Reader
|
|
Stdout io.Writer
|
|
Stderr io.Writer
|
|
}
|
|
|
|
func v4WriteStatusFunc(stream io.Writer) func(status *apierrors.StatusError) error {
|
|
return func(status *apierrors.StatusError) error {
|
|
bs, err := json.Marshal(status.Status())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = stream.Write(bs)
|
|
return err
|
|
}
|
|
}
|
|
func CreateHTTPStreams(w http.ResponseWriter, req *http.Request, opts *StreamOptions) (*StreamContext, error) {
|
|
_, err := httpstream.Handshake(req, w, []string{"v4.channel.k8s.io"})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
upgrader := spdy.NewResponseUpgrader()
|
|
streamCh := make(chan streamAndReply)
|
|
connection := upgrader.UpgradeResponse(w, req, func(stream httpstream.Stream, replySent <-chan struct{}) error {
|
|
streamCh <- streamAndReply{Stream: stream, replySent: replySent}
|
|
return nil
|
|
})
|
|
ctx := &StreamContext{
|
|
Closer: connection,
|
|
}
|
|
|
|
// wait for stream
|
|
replyChan := make(chan struct{}, 4)
|
|
defer close(replyChan)
|
|
receivedStreams := 0
|
|
expectedStreams := 1
|
|
if opts.Stdout != nil {
|
|
expectedStreams++
|
|
}
|
|
if opts.Stdin != nil {
|
|
expectedStreams++
|
|
}
|
|
if opts.Stderr != nil {
|
|
expectedStreams++
|
|
}
|
|
WaitForStreams:
|
|
for {
|
|
select {
|
|
case stream := <-streamCh:
|
|
streamType := stream.Headers().Get(v1.StreamType)
|
|
switch streamType {
|
|
case v1.StreamTypeError:
|
|
replyChan <- struct{}{}
|
|
ctx.writeStatus = v4WriteStatusFunc(stream)
|
|
case v1.StreamTypeStdout:
|
|
replyChan <- struct{}{}
|
|
ctx.StdoutStream = stream
|
|
case v1.StreamTypeStdin:
|
|
replyChan <- struct{}{}
|
|
ctx.StdinStream = stream
|
|
case v1.StreamTypeStderr:
|
|
replyChan <- struct{}{}
|
|
ctx.StderrStream = stream
|
|
default:
|
|
// add other stream ...
|
|
return nil, errors.New("unimplemented stream type")
|
|
}
|
|
case <-replyChan:
|
|
receivedStreams++
|
|
if receivedStreams == expectedStreams {
|
|
break WaitForStreams
|
|
}
|
|
}
|
|
}
|
|
|
|
return ctx, nil
|
|
}
|
|
|
|
type InOpenShiftHandler struct {
|
|
}
|
|
|
|
var _ http.Handler = (*InOpenShiftHandler)(nil)
|
|
|
|
func (h *InOpenShiftHandler) ServeHTTP(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":[],"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",
|
|
"groups":[{
|
|
"name":"project.openshift.io",
|
|
"versions":[{"groupVersion":"project.openshift.io/v1","version":"v1"}],
|
|
"preferredVersion":{"groupVersion":"project.openshift.io/v1","version":"v1"}
|
|
}]}`))
|
|
return
|
|
}
|
|
if req.URL.Path == "/apis/project.openshift.io/v1" {
|
|
_, _ = w.Write([]byte(`{
|
|
"kind":"APIResourceList",
|
|
"apiVersion":"v1",
|
|
"groupVersion":"project.openshift.io/v1",
|
|
"resources":[
|
|
{"name":"projects","singularName":"","namespaced":false,"kind":"Project","verbs":["create","delete","get","list","patch","update","watch"],"shortNames":["pr"]}
|
|
]}`))
|
|
return
|
|
}
|
|
}
|