Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cmd/cmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 5 additions & 2 deletions server/lock/clusterlock.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package lock

import "sync"
import (
"strings"
"sync"
)

type Map struct {
m sync.Map
Expand All @@ -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)
}

Expand Down
79 changes: 79 additions & 0 deletions server/newapp_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
})
}
}
41 changes: 31 additions & 10 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -57,12 +58,22 @@ 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 {
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
}
Comment thread
cgrinds marked this conversation as resolved.

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"
Expand All @@ -73,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() {
Expand Down Expand Up @@ -313,8 +324,18 @@ 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
Expand All @@ -330,7 +351,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
}

Expand Down Expand Up @@ -595,11 +616,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
}
Expand Down
Loading