From 2e0557379f7943c5dec5f3fd6a1599c873d643d8 Mon Sep 17 00:00:00 2001 From: Chris Grindstaff Date: Wed, 13 May 2026 08:50:44 -0400 Subject: [PATCH 1/2] feat: implement case-insensitive cluster name handling --- server/lock/clusterlock.go | 7 +++++-- server/server.go | 35 ++++++++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/server/lock/clusterlock.go b/server/lock/clusterlock.go index f39aaf0..cbe8ca1 100644 --- a/server/lock/clusterlock.go +++ b/server/lock/clusterlock.go @@ -1,6 +1,9 @@ package lock -import "sync" +import ( + "strings" + "sync" +) type Map struct { m sync.Map @@ -11,7 +14,7 @@ func New() *Map { } func (m *Map) getOrCreate(cluster string) *sync.RWMutex { - v, _ := m.m.LoadOrStore(cluster, &sync.RWMutex{}) + v, _ := m.m.LoadOrStore(strings.ToLower(cluster), &sync.RWMutex{}) return v.(*sync.RWMutex) } diff --git a/server/server.go b/server/server.go index ece8e23..b32e1a9 100644 --- a/server/server.go +++ b/server/server.go @@ -48,6 +48,7 @@ type App struct { locks *lock.Map catalog catalog.APICatalog versionCache sync.Map + clusterIndex map[string]string // lowercase name → canonical config name } type cachedVersion struct { @@ -58,11 +59,17 @@ type cachedVersion struct { const versionCacheTTL = 24 * time.Hour func NewApp(cfg *config.ONTAP, o Options, logger *slog.Logger) *App { + index := make(map[string]string, len(cfg.Pollers)) + for name := range cfg.Pollers { + index[strings.ToLower(name)] = name + } + app := &App{ - cfg: cfg, - logger: logger, - options: o, - locks: lock.New(), + cfg: cfg, + logger: logger, + options: o, + locks: lock.New(), + clusterIndex: index, } const catalogPath = "conf/ontap_api_catalog.json" @@ -313,15 +320,25 @@ type clusterInfo struct { ONTAPVersion string `json:"ontap_version"` } +func (a *App) resolveCluster(input string) (string, bool) { + canonical, ok := a.clusterIndex[strings.ToLower(input)] + return canonical, ok +} + func (a *App) getClusterVersion(ctx context.Context, cluster string) (string, error) { - if cached, ok := a.versionCache.Load(cluster); ok { + canonical, ok := a.resolveCluster(cluster) + if !ok { + return "", fmt.Errorf("cluster %s not found", cluster) + } + + if cached, ok := a.versionCache.Load(canonical); ok { cv := cached.(cachedVersion) if time.Since(cv.fetched) < versionCacheTTL { return cv.version, nil } } - client, err := a.getClient(cluster) + client, err := a.getClient(canonical) if err != nil { return "", err } @@ -330,7 +347,7 @@ func (a *App) getClusterVersion(ctx context.Context, cluster string) (string, er return "", err } ver := fmt.Sprintf("%d.%d", remote.Version.Generation, remote.Version.Major) - a.versionCache.Store(cluster, cachedVersion{version: ver, fetched: time.Now()}) + a.versionCache.Store(canonical, cachedVersion{version: ver, fetched: time.Now()}) return ver, nil } @@ -595,11 +612,11 @@ func stripLinksValue(v any) any { } func (a *App) getClient(cluster string) (*rest.Client, error) { - poller, ok := a.cfg.Pollers[cluster] + canonical, ok := a.resolveCluster(cluster) if !ok { return nil, fmt.Errorf("cluster %s not found", cluster) } - + poller := a.cfg.Pollers[canonical] if a.options.TestHTTPClient != nil { return rest.NewWithClient(poller, a.options.TestHTTPClient), nil } From c06110acbf24bb3d7f59e76dd6ff0546a36f3767 Mon Sep 17 00:00:00 2001 From: Chris Grindstaff Date: Wed, 13 May 2026 11:36:20 -0400 Subject: [PATCH 2/2] feat: implement case-insensitive cluster name handling --- cmd/cmds.go | 5 ++- server/newapp_test.go | 79 +++++++++++++++++++++++++++++++++++++++++++ server/server.go | 12 ++++--- 3 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 server/newapp_test.go diff --git a/cmd/cmds.go b/cmd/cmds.go index 9830e0c..642080d 100644 --- a/cmd/cmds.go +++ b/cmd/cmds.go @@ -54,7 +54,10 @@ func (a *StartCmd) Run(cli *CLI) error { JSONResponse: cli.Start.JSONResponse, } - app := server.NewApp(cfg, opts, logger) + app, err := server.NewApp(cfg, opts, logger) + if err != nil { + return err + } app.StartServer() return nil } diff --git a/server/newapp_test.go b/server/newapp_test.go new file mode 100644 index 0000000..b3d1b47 --- /dev/null +++ b/server/newapp_test.go @@ -0,0 +1,79 @@ +package server + +import ( + "log/slog" + "strings" + "testing" + + "github.com/netapp/ontap-mcp/config" +) + +func TestNewApp_CaseCollision(t *testing.T) { + tests := []struct { + name string + pollers map[string]*config.Poller + wantErr bool + errContains string + }{ + { + name: "no collision", + pollers: map[string]*config.Poller{ + "DC1": {}, + "DC2": {}, + }, + wantErr: false, + }, + { + name: "identical names", + pollers: map[string]*config.Poller{ + "dc1": {}, + }, + wantErr: false, + }, + { + name: "case collision upper vs lower", + pollers: map[string]*config.Poller{ + "DC1": {}, + "dc1": {}, + }, + wantErr: true, + errContains: "differ only by case", + }, + { + name: "case collision mixed case", + pollers: map[string]*config.Poller{ + "Cluster1": {}, + "CLUSTER1": {}, + }, + wantErr: true, + errContains: "differ only by case", + }, + } + + logger := slog.Default() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ONTAP{Pollers: tt.pollers} + app, err := NewApp(cfg, Options{}, logger) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.errContains) + } + if app != nil { + t.Fatal("expected nil *App on error, got non-nil") + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if app == nil { + t.Fatal("expected non-nil *App, got nil") + } + } + }) + } +} diff --git a/server/server.go b/server/server.go index b32e1a9..34b4c67 100644 --- a/server/server.go +++ b/server/server.go @@ -58,10 +58,14 @@ type cachedVersion struct { const versionCacheTTL = 24 * time.Hour -func NewApp(cfg *config.ONTAP, o Options, logger *slog.Logger) *App { +func NewApp(cfg *config.ONTAP, o Options, logger *slog.Logger) (*App, error) { index := make(map[string]string, len(cfg.Pollers)) for name := range cfg.Pollers { - index[strings.ToLower(name)] = name + key := strings.ToLower(name) + if existing, collision := index[key]; collision { + return nil, fmt.Errorf("poller names %q and %q differ only by case; rename one to avoid ambiguity", existing, name) + } + index[key] = name } app := &App{ @@ -80,7 +84,7 @@ func NewApp(cfg *config.ONTAP, o Options, logger *slog.Logger) *App { logger.Warn("API catalog not found — catalog tools disabled", slog.String("path", catalogPath)) } - return app + return app, nil } func (a *App) StartServer() { @@ -338,7 +342,7 @@ func (a *App) getClusterVersion(ctx context.Context, cluster string) (string, er } } - client, err := a.getClient(canonical) + client, err := a.getClient(cluster) if err != nil { return "", err }