From 224414a69e85aae01e5adf700939caa6894732f3 Mon Sep 17 00:00:00 2001 From: hardikl Date: Wed, 8 Apr 2026 18:00:29 +0530 Subject: [PATCH 01/10] feat: implement igroup crud tools --- descriptions/descriptions.go | 6 + docs/examples.md | 34 +++++ integration/test/igroup_test.go | 87 +++++++++++ ontap/ontap.go | 19 +++ rest/igroup.go | 182 ++++++++++++++++++++++ server/igroup.go | 261 ++++++++++++++++++++++++++++++++ server/server.go | 7 + tool/tool.go | 21 +++ 8 files changed, 617 insertions(+) create mode 100644 integration/test/igroup_test.go create mode 100644 rest/igroup.go create mode 100644 server/igroup.go diff --git a/descriptions/descriptions.go b/descriptions/descriptions.go index 0da2bd7..74b02cb 100644 --- a/descriptions/descriptions.go +++ b/descriptions/descriptions.go @@ -76,6 +76,12 @@ const CreateNVMeService = `Create NVMe service on a cluster by cluster name.` const UpdateNVMeService = `Update NVMe service on a cluster by cluster name.` const DeleteNVMeService = `Delete NVMe service on a cluster by cluster name.` +const CreateIGroup = `Create an igroup (initiator group) on a cluster by cluster name.` +const UpdateIGroup = `Update an igroup on a cluster by cluster name.` +const DeleteIGroup = `Delete an igroup on a cluster by cluster name.` +const AddIGroupInitiator = `Add an initiator to an igroup on a cluster by cluster name.` +const RemoveIGroupInitiator = `Remove an initiator from an igroup on a cluster by cluster name.` + const ListOntapEndpoints = `List ONTAP REST collection endpoints in the catalog. The catalog contains all endpoints — can be large. Prefer search_ontap_endpoints for targeted discovery. Use the optional 'match' parameter to filter by substring or regex pattern (e.g. "snapshot", "lun", ".*nfs.*export.*"). diff --git a/docs/examples.md b/docs/examples.md index 4a9dde7..3d93fb2 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -279,6 +279,40 @@ Expected Response: A summary of aggregate free space, followed by a recommendati --- +### Manage iGroups (SAN) + +**Create an iGroup** + +- On the umeng-aff300-05-06 cluster, create an igroup named igroupFin with OS type linux and protocol iscsi on the vs_test4 svm + +Expected Response: igroup created successfully. + +**Rename an iGroup** + +- On the umeng-aff300-05-06 cluster, rename igroup igroupFin to igroupFinNew on the vs_test4 svm + +Expected Response: igroup updated successfully. + +**Add an Initiator to an iGroup** + +- On the umeng-aff300-05-06 cluster, add initiator iqn.2021-01.com.example:test to igroup igroupFinNew on the vs_test4 svm + +Expected Response: initiator added to igroup successfully. + +**Remove an Initiator from an iGroup** + +- On the umeng-aff300-05-06 cluster, remove initiator iqn.2021-01.com.example:test from igroup igroupFinNew on the vs_test4 svm + +Expected Response: initiator removed from igroup successfully. + +**Delete an iGroup** + +- On the umeng-aff300-05-06 cluster, delete igroup igroupFinNew on the vs_test4 svm + +Expected Response: igroup deleted successfully. + +--- + ## MCP Clients Common MCP clients that work with ONTAP MCP Server: diff --git a/integration/test/igroup_test.go b/integration/test/igroup_test.go new file mode 100644 index 0000000..f600396 --- /dev/null +++ b/integration/test/igroup_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "crypto/tls" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/netapp/ontap-mcp/config" +) + +func TestIGroup(t *testing.T) { + SkipIfMissing(t, CheckTools) + + tests := []struct { + name string + input string + expectedOntapErr string + verifyAPI ontapVerifier + }{ + { + name: "Clean igroup", + input: ClusterStr + "delete igroup " + rn("igroupFin") + " on the vs_test4 svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=vs_test4", validationFunc: deleteObject}, + }, + { + name: "Create igroup", + input: ClusterStr + "create an igroup named " + rn("igroupFin") + " with OS type linux and protocol iscsi on the vs_test4 svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=vs_test4", validationFunc: createObject}, + }, + { + name: "Update igroup", + input: ClusterStr + "rename igroup " + rn("igroupFin") + " to " + rn("igroupFinNew") + " on the vs_test4 svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=vs_test4", validationFunc: createObject}, + }, + { + name: "Add initiator to igroup", + input: ClusterStr + "add initiator iqn.2021-01.com.example:test to igroup " + rn("igroupFinNew") + " on the vs_test4 svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{}, + }, + { + name: "Remove initiator from igroup", + input: ClusterStr + "remove initiator iqn.2021-01.com.example:test from igroup " + rn("igroupFinNew") + " on the vs_test4 svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{}, + }, + { + name: "Clean igroup", + input: ClusterStr + "delete igroup " + rn("igroupFinNew") + " on the vs_test4 svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=vs_test4", validationFunc: deleteObject}, + }, + } + + cfg, err := config.ReadConfig(ConfigFile) + if err != nil { + t.Fatalf("Error parsing the config: %v", err) + } + + poller := cfg.Pollers[Cluster] + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: poller.UseInsecureTLS, // #nosec G402 + }, + } + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + slog.Debug("", slog.String("Input", tt.input)) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + if _, err := testAgent.ChatWithResponse(ctx, t, tt.input, tt.expectedOntapErr); err != nil { + t.Fatalf("Error processing input %q: %v", tt.input, err) + } + if tt.verifyAPI.api != "" && !tt.verifyAPI.validationFunc(t, tt.verifyAPI.api, poller, client) { + t.Errorf("Error while accessing the object via prompt %q", tt.input) + } + }) + } +} diff --git a/ontap/ontap.go b/ontap/ontap.go index 958fd3e..9495b99 100644 --- a/ontap/ontap.go +++ b/ontap/ontap.go @@ -194,6 +194,25 @@ type NVMeService struct { Enabled string `json:"enabled,omitzero" jsonschema:"admin state of the NVMe service"` } +type InitiatorName struct { + Name string `json:"name,omitzero" jsonschema:"The FC WWPN, iSCSI IQN, or iSCSI EUI that identifies the host initiator."` +} + +type IGroupInitiator struct { + Name string `json:"name,omitzero"` + Comment string `json:"comment,omitzero"` + Records []InitiatorName `json:"records,omitzero" jsonschema:"An array of initiators specified to add multiple initiators to an initiator group in a single API call. Not allowed when the name property is used."` +} + +type IGroup struct { + SVM NameAndUUID `json:"svm,omitzero"` + Name string `json:"name,omitzero"` + OSType string `json:"os_type,omitzero"` + Protocol string `json:"protocol,omitzero"` + Comment string `json:"comment,omitzero"` + Initiators []IGroupInitiator `json:"initiators,omitzero"` +} + const ( ASAr2 = "asar2" CDOT = "cdot" diff --git a/rest/igroup.go b/rest/igroup.go new file mode 100644 index 0000000..6f4e5e5 --- /dev/null +++ b/rest/igroup.go @@ -0,0 +1,182 @@ +package rest + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/netapp/ontap-mcp/ontap" +) + +func (c *Client) CreateIGroup(ctx context.Context, igroup ontap.IGroup) error { + var ( + statusCode int + ) + responseHeaders := http.Header{} + + builder := c.baseRequestBuilder(`/api/protocols/san/igroups`, &statusCode, responseHeaders). + BodyJSON(igroup) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.checkStatus(statusCode) +} + +func (c *Client) UpdateIGroup(ctx context.Context, igroup ontap.IGroup, igroupName, svmName string) error { + var ( + statusCode int + ig ontap.GetData + ) + responseHeaders := http.Header{} + + params := url.Values{} + params.Set("fields", "uuid") + params.Set("name", igroupName) + params.Set("svm.name", svmName) + + builder := c.baseRequestBuilder(`/api/protocols/san/igroups`, &statusCode, responseHeaders). + Params(params). + ToJSON(&ig) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + if ig.NumRecords == 0 { + return fmt.Errorf("failed to update igroup=%s on svm=%s because it does not exist", igroupName, svmName) + } + if ig.NumRecords != 1 { + return fmt.Errorf("failed to update igroup=%s on svm=%s because there are %d matching records", igroupName, svmName, ig.NumRecords) + } + + builder = c.baseRequestBuilder(`/api/protocols/san/igroups/`+ig.Records[0].UUID, &statusCode, responseHeaders). + Patch(). + BodyJSON(igroup) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.checkStatus(statusCode) +} + +func (c *Client) DeleteIGroup(ctx context.Context, igroup ontap.IGroup, allowDeleteWhileMapped bool) error { + var ( + statusCode int + ig ontap.GetData + ) + responseHeaders := http.Header{} + + params := url.Values{} + params.Set("fields", "uuid") + params.Set("name", igroup.Name) + params.Set("svm.name", igroup.SVM.Name) + + builder := c.baseRequestBuilder(`/api/protocols/san/igroups`, &statusCode, responseHeaders). + Params(params). + ToJSON(&ig) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + if ig.NumRecords == 0 { + return fmt.Errorf("failed to delete igroup=%s on svm=%s because it does not exist", igroup.Name, igroup.SVM.Name) + } + if ig.NumRecords != 1 { + return fmt.Errorf("failed to delete igroup=%s on svm=%s because there are %d matching records", igroup.Name, igroup.SVM.Name, ig.NumRecords) + } + + deleteParams := url.Values{} + deleteParams.Set("allow_delete_while_mapped", strconv.FormatBool(allowDeleteWhileMapped)) + builder = c.baseRequestBuilder(`/api/protocols/san/igroups/`+ig.Records[0].UUID, &statusCode, responseHeaders). + Params(deleteParams). + Delete() + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.checkStatus(statusCode) +} + +func (c *Client) AddIGroupInitiator(ctx context.Context, igroupName, svmName string, initiator ontap.IGroupInitiator) error { + var ( + statusCode int + ig ontap.GetData + ) + responseHeaders := http.Header{} + + params := url.Values{} + params.Set("fields", "uuid") + params.Set("name", igroupName) + params.Set("svm.name", svmName) + + builder := c.baseRequestBuilder(`/api/protocols/san/igroups`, &statusCode, responseHeaders). + Params(params). + ToJSON(&ig) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + if ig.NumRecords == 0 { + return fmt.Errorf("failed to add initiator to igroup=%s on svm=%s because the igroup does not exist", igroupName, svmName) + } + if ig.NumRecords != 1 { + return fmt.Errorf("failed to add initiator to igroup=%s on svm=%s because there are %d matching records", igroupName, svmName, ig.NumRecords) + } + + builder = c.baseRequestBuilder(`/api/protocols/san/igroups/`+ig.Records[0].UUID+`/initiators`, &statusCode, responseHeaders). + BodyJSON(initiator) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.checkStatus(statusCode) +} + +func (c *Client) RemoveIGroupInitiator(ctx context.Context, igroupName, svmName string, initiator ontap.IGroupInitiator, allowDeleteWhileMapped bool) error { + var ( + statusCode int + ig ontap.GetData + ) + responseHeaders := http.Header{} + + params := url.Values{} + params.Set("fields", "uuid") + params.Set("name", igroupName) + params.Set("svm.name", svmName) + + builder := c.baseRequestBuilder(`/api/protocols/san/igroups`, &statusCode, responseHeaders). + Params(params). + ToJSON(&ig) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + if ig.NumRecords == 0 { + return fmt.Errorf("failed to remove initiator from igroup=%s on svm=%s because the igroup does not exist", igroupName, svmName) + } + if ig.NumRecords != 1 { + return fmt.Errorf("failed to remove initiator from igroup=%s on svm=%s because there are %d matching records", igroupName, svmName, ig.NumRecords) + } + + deleteParams := url.Values{} + deleteParams.Set("allow_delete_while_mapped", strconv.FormatBool(allowDeleteWhileMapped)) + builder = c.baseRequestBuilder(`/api/protocols/san/igroups/`+ig.Records[0].UUID+`/initiators/`+url.PathEscape(initiator.Name), &statusCode, responseHeaders). + Params(deleteParams). + Delete() + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.checkStatus(statusCode) +} diff --git a/server/igroup.go b/server/igroup.go new file mode 100644 index 0000000..44136ad --- /dev/null +++ b/server/igroup.go @@ -0,0 +1,261 @@ +package server + +import ( + "context" + "errors" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/netapp/ontap-mcp/ontap" + "github.com/netapp/ontap-mcp/tool" +) + +func (a *App) CreateIGroup(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.IGroup) (*mcp.CallToolResult, any, error) { + if !a.locks.TryLock(parameters.Cluster) { + return errorResult(fmt.Errorf("another write operation is in progress on cluster %s, please try again", parameters.Cluster)), nil, nil + } + defer a.locks.Unlock(parameters.Cluster) + + igroupCreate, err := newCreateIGroup(parameters) + if err != nil { + return nil, nil, err + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + err = client.CreateIGroup(ctx, igroupCreate) + + if err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "igroup created successfully"}, + }, + }, nil, nil +} + +func (a *App) UpdateIGroup(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.IGroup) (*mcp.CallToolResult, any, error) { + if !a.locks.TryLock(parameters.Cluster) { + return errorResult(fmt.Errorf("another write operation is in progress on cluster %s, please try again", parameters.Cluster)), nil, nil + } + defer a.locks.Unlock(parameters.Cluster) + + igroupUpdate, err := newUpdateIGroup(parameters) + if err != nil { + return nil, nil, err + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + err = client.UpdateIGroup(ctx, igroupUpdate, parameters.Name, parameters.SVM) + + if err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "igroup updated successfully"}, + }, + }, nil, nil +} + +func (a *App) DeleteIGroup(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.IGroup) (*mcp.CallToolResult, any, error) { + if !a.locks.TryLock(parameters.Cluster) { + return errorResult(fmt.Errorf("another write operation is in progress on cluster %s, please try again", parameters.Cluster)), nil, nil + } + defer a.locks.Unlock(parameters.Cluster) + + igroupDelete, err := newDeleteIGroup(parameters) + if err != nil { + return nil, nil, err + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + err = client.DeleteIGroup(ctx, igroupDelete, parameters.AllowDeleteWhileMapped) + + if err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "igroup deleted successfully"}, + }, + }, nil, nil +} + +func (a *App) AddIGroupInitiator(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.IGroupInitiator) (*mcp.CallToolResult, any, error) { + if !a.locks.TryLock(parameters.Cluster) { + return errorResult(fmt.Errorf("another write operation is in progress on cluster %s, please try again", parameters.Cluster)), nil, nil + } + defer a.locks.Unlock(parameters.Cluster) + + initiatorAdd, err := addIGroupInitiator(parameters) + if err != nil { + return nil, nil, err + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + + err = client.AddIGroupInitiator(ctx, parameters.IGroupName, parameters.SVM, initiatorAdd) + if err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "initiator added to igroup successfully"}, + }, + }, nil, nil +} + +func (a *App) RemoveIGroupInitiator(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.IGroupInitiator) (*mcp.CallToolResult, any, error) { + if !a.locks.TryLock(parameters.Cluster) { + return errorResult(fmt.Errorf("another write operation is in progress on cluster %s, please try again", parameters.Cluster)), nil, nil + } + defer a.locks.Unlock(parameters.Cluster) + + initiatorRemove, err := removeIGroupInitiator(parameters) + if err != nil { + return nil, nil, err + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + + err = client.RemoveIGroupInitiator(ctx, parameters.IGroupName, parameters.SVM, initiatorRemove, parameters.AllowDeleteWhileMapped) + if err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "initiator removed from igroup successfully"}, + }, + }, nil, nil +} + +func newCreateIGroup(in tool.IGroup) (ontap.IGroup, error) { + out := ontap.IGroup{} + if in.SVM == "" { + return out, errors.New("SVM name is required") + } + if in.Name == "" { + return out, errors.New("igroup name is required") + } + if in.OSType == "" { + return out, errors.New("OS type is required") + } + if in.Protocol == "" { + return out, errors.New("protocol is required") + } + + out.SVM = ontap.NameAndUUID{Name: in.SVM} + out.Name = in.Name + out.OSType = in.OSType + out.Protocol = in.Protocol + out.Comment = in.Comment + return out, nil +} + +func newUpdateIGroup(in tool.IGroup) (ontap.IGroup, error) { + out := ontap.IGroup{} + if in.SVM == "" { + return out, errors.New("SVM name is required") + } + if in.Name == "" { + return out, errors.New("igroup name is required") + } + + if in.NewName != "" { + out.Name = in.NewName + } + if in.Comment != "" { + out.Comment = in.Comment + } + if in.OSType != "" { + out.OSType = in.OSType + } + return out, nil +} + +func newDeleteIGroup(in tool.IGroup) (ontap.IGroup, error) { + out := ontap.IGroup{} + if in.SVM == "" { + return out, errors.New("SVM name is required") + } + if in.Name == "" { + return out, errors.New("igroup name is required") + } + + out.SVM = ontap.NameAndUUID{Name: in.SVM} + out.Name = in.Name + return out, nil +} + +func addIGroupInitiator(in tool.IGroupInitiator) (ontap.IGroupInitiator, error) { + out := ontap.IGroupInitiator{} + if in.SVM == "" { + return out, errors.New("SVM name is required") + } + if in.IGroupName == "" { + return out, errors.New("igroup name is required") + } + + if in.Comment != "" { + out.Comment = in.Comment + } + + if in.InitiatorName == "" && len(in.Records) == 0 { + return out, errors.New("either initiator name OR one or more initiator names (records) must be provided") + } + + // Enforce mutual exclusivity: cannot specify both a single initiator name and an array + if in.InitiatorName != "" && len(in.Records) > 0 { + return out, errors.New("specify either a single initiator name or an array of initiator names, but not both") + } + + if in.InitiatorName != "" { + out.Name = in.InitiatorName + return out, nil + } + + for _, iName := range in.Records { + if iName == "" { + return out, errors.New("all initiator names in the array must be non-empty") + } + out.Records = append(out.Records, ontap.InitiatorName{Name: iName}) + } + + return out, nil +} + +func removeIGroupInitiator(in tool.IGroupInitiator) (ontap.IGroupInitiator, error) { + out := ontap.IGroupInitiator{} + if in.SVM == "" { + return out, errors.New("SVM name is required") + } + if in.IGroupName == "" { + return out, errors.New("igroup name is required") + } + if in.InitiatorName == "" { + return out, errors.New("initiator name is required") + } + out.Name = in.InitiatorName + return out, nil +} diff --git a/server/server.go b/server/server.go index 1e9d0b2..cce9c3b 100644 --- a/server/server.go +++ b/server/server.go @@ -133,6 +133,13 @@ func (a *App) createMCPServer() *mcp.Server { addTool(a, server, "update_nvme_service", descriptions.UpdateNVMeService, updateAnnotation, a.UpdateNVMeService) addTool(a, server, "delete_nvme_service", descriptions.DeleteNVMeService, deleteAnnotation, a.DeleteNVMeService) + // operation on igroup object + addTool(a, server, "create_igroup", descriptions.CreateIGroup, createAnnotation, a.CreateIGroup) + addTool(a, server, "update_igroup", descriptions.UpdateIGroup, updateAnnotation, a.UpdateIGroup) + addTool(a, server, "delete_igroup", descriptions.DeleteIGroup, deleteAnnotation, a.DeleteIGroup) + addTool(a, server, "add_igroup_initiator", descriptions.AddIGroupInitiator, createAnnotation, a.AddIGroupInitiator) + addTool(a, server, "remove_igroup_initiator", descriptions.RemoveIGroupInitiator, deleteAnnotation, a.RemoveIGroupInitiator) + if a.catalog != nil { addTool(a, server, "list_ontap_endpoints", descriptions.ListOntapEndpoints, readOnlyAnnotation, a.ListOntapEndpoints) addTool(a, server, "search_ontap_endpoints", descriptions.SearchOntapEndpoints, readOnlyAnnotation, a.SearchOntapEndpoints) diff --git a/tool/tool.go b/tool/tool.go index 2a82c12..d9b60b0 100644 --- a/tool/tool.go +++ b/tool/tool.go @@ -115,6 +115,27 @@ type NVMeService struct { Enabled string `json:"enabled,omitzero" jsonschema:"admin state of the NVMe service"` } +type IGroup struct { + Cluster string `json:"cluster_name" jsonschema:"cluster name"` + SVM string `json:"svm_name" jsonschema:"SVM name"` + Name string `json:"name,omitzero" jsonschema:"igroup name"` + NewName string `json:"new_name,omitzero" jsonschema:"new igroup name"` + OSType string `json:"os_type,omitzero" jsonschema:"OS type (aix, hpux, hyper_v, linux, netware, openvms, solaris, vmware, windows, xen)"` + Protocol string `json:"protocol,omitzero" jsonschema:"protocol (fcp, iscsi, mixed)"` + Comment string `json:"comment,omitzero" jsonschema:"comment"` + AllowDeleteWhileMapped bool `json:"allow_delete_while_mapped,omitzero" jsonschema:"Allows the deletion of a mapped initiator group. This parameter should be used with caution"` +} + +type IGroupInitiator struct { + Cluster string `json:"cluster_name" jsonschema:"cluster name"` + SVM string `json:"svm_name" jsonschema:"SVM name"` + IGroupName string `json:"igroup_name" jsonschema:"igroup name"` + InitiatorName string `json:"initiator_name" jsonschema:"initiator name (IQN for iSCSI or WWPN for FC)"` + Comment string `json:"comment,omitzero" jsonschema:"comment"` + Records []string `json:"records,omitzero" jsonschema:"An array of initiators specified to add multiple initiators to an initiator group in a single API call"` + AllowDeleteWhileMapped bool `json:"allow_delete_while_mapped,omitzero" jsonschema:"Allows the deletion of an initiator from of a mapped initiator group. This parameter should be used with caution."` +} + type OntapGetParams struct { Cluster string `json:"cluster_name" jsonschema:"cluster name, from list_registered_clusters"` Fields string `json:"fields,omitzero" jsonschema:"comma-separated dot-notation fields to return, e.g. \"name,svm.name,space.size\" — use space.* to expand all space sub-fields"` From cbdc4830f763b1b8e73db8a687548eece437e0b2 Mon Sep 17 00:00:00 2001 From: hardikl Date: Thu, 9 Apr 2026 14:45:09 +0530 Subject: [PATCH 02/10] feat: update igroup testcases --- docs/examples.md | 10 +++++----- integration/test/igroup_test.go | 26 ++++++++++++++++---------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 3d93fb2..dddd39a 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -283,31 +283,31 @@ Expected Response: A summary of aggregate free space, followed by a recommendati **Create an iGroup** -- On the umeng-aff300-05-06 cluster, create an igroup named igroupFin with OS type linux and protocol iscsi on the vs_test4 svm +- On the umeng-aff300-05-06 cluster, create an igroup named igroupFin with OS type linux and protocol iscsi on the marketing svm Expected Response: igroup created successfully. **Rename an iGroup** -- On the umeng-aff300-05-06 cluster, rename igroup igroupFin to igroupFinNew on the vs_test4 svm +- On the umeng-aff300-05-06 cluster, rename igroup igroupFin to igroupFinNew and os type as windows on the marketing svm Expected Response: igroup updated successfully. **Add an Initiator to an iGroup** -- On the umeng-aff300-05-06 cluster, add initiator iqn.2021-01.com.example:test to igroup igroupFinNew on the vs_test4 svm +- On the umeng-aff300-05-06 cluster, add initiator iqn.2021-01.com.example:test to igroup igroupFinNew on the marketing svm Expected Response: initiator added to igroup successfully. **Remove an Initiator from an iGroup** -- On the umeng-aff300-05-06 cluster, remove initiator iqn.2021-01.com.example:test from igroup igroupFinNew on the vs_test4 svm +- On the umeng-aff300-05-06 cluster, remove initiator iqn.2021-01.com.example:test from igroup igroupFinNew on the marketing svm Expected Response: initiator removed from igroup successfully. **Delete an iGroup** -- On the umeng-aff300-05-06 cluster, delete igroup igroupFinNew on the vs_test4 svm +- On the umeng-aff300-05-06 cluster, delete igroup igroupFinNew on the marketing svm Expected Response: igroup deleted successfully. diff --git a/integration/test/igroup_test.go b/integration/test/igroup_test.go index f600396..f04e69a 100644 --- a/integration/test/igroup_test.go +++ b/integration/test/igroup_test.go @@ -22,39 +22,45 @@ func TestIGroup(t *testing.T) { }{ { name: "Clean igroup", - input: ClusterStr + "delete igroup " + rn("igroupFin") + " on the vs_test4 svm", + input: ClusterStr + "delete igroup " + rn("igroupFin") + " on the marketing svm", expectedOntapErr: "because it does not exist", - verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=vs_test4", validationFunc: deleteObject}, + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=marketing", validationFunc: deleteObject}, + }, + { + name: "Clean igroup", + input: ClusterStr + "delete igroup " + rn("igroupFinNew") + " on the marketing svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=marketing", validationFunc: deleteObject}, }, { name: "Create igroup", - input: ClusterStr + "create an igroup named " + rn("igroupFin") + " with OS type linux and protocol iscsi on the vs_test4 svm", + input: ClusterStr + "create an igroup named " + rn("igroupFin") + " with OS type linux and protocol iscsi on the marketing svm", expectedOntapErr: "", - verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=vs_test4", validationFunc: createObject}, + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=marketing", validationFunc: createObject}, }, { name: "Update igroup", - input: ClusterStr + "rename igroup " + rn("igroupFin") + " to " + rn("igroupFinNew") + " on the vs_test4 svm", + input: ClusterStr + "rename igroup " + rn("igroupFin") + " to " + rn("igroupFinNew") + " and os type as windows on the marketing svm", expectedOntapErr: "", - verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=vs_test4", validationFunc: createObject}, + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=marketing", validationFunc: createObject}, }, { name: "Add initiator to igroup", - input: ClusterStr + "add initiator iqn.2021-01.com.example:test to igroup " + rn("igroupFinNew") + " on the vs_test4 svm", + input: ClusterStr + "add initiator iqn.2021-01.com.example:test to igroup " + rn("igroupFinNew") + " on the marketing svm", expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, { name: "Remove initiator from igroup", - input: ClusterStr + "remove initiator iqn.2021-01.com.example:test from igroup " + rn("igroupFinNew") + " on the vs_test4 svm", + input: ClusterStr + "remove initiator iqn.2021-01.com.example:test from igroup " + rn("igroupFinNew") + " on the marketing svm", expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, { name: "Clean igroup", - input: ClusterStr + "delete igroup " + rn("igroupFinNew") + " on the vs_test4 svm", + input: ClusterStr + "delete igroup " + rn("igroupFinNew") + " on the marketing svm", expectedOntapErr: "because it does not exist", - verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=vs_test4", validationFunc: deleteObject}, + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=marketing", validationFunc: deleteObject}, }, } From 291c6c093161b8b8420f19001f9669f757ad0385 Mon Sep 17 00:00:00 2001 From: hardikl Date: Thu, 9 Apr 2026 14:51:29 +0530 Subject: [PATCH 03/10] feat: minor changes --- rest/igroup.go | 16 ++++++++-------- tool/tool.go | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/rest/igroup.go b/rest/igroup.go index 6f4e5e5..5157dc7 100644 --- a/rest/igroup.go +++ b/rest/igroup.go @@ -47,10 +47,10 @@ func (c *Client) UpdateIGroup(ctx context.Context, igroup ontap.IGroup, igroupNa } if ig.NumRecords == 0 { - return fmt.Errorf("failed to update igroup=%s on svm=%s because it does not exist", igroupName, svmName) + return fmt.Errorf("failed to get igroup=%s on svm=%s because it does not exist", igroupName, svmName) } if ig.NumRecords != 1 { - return fmt.Errorf("failed to update igroup=%s on svm=%s because there are %d matching records", igroupName, svmName, ig.NumRecords) + return fmt.Errorf("failed to get igroup=%s on svm=%s because there are %d matching records", igroupName, svmName, ig.NumRecords) } builder = c.baseRequestBuilder(`/api/protocols/san/igroups/`+ig.Records[0].UUID, &statusCode, responseHeaders). @@ -85,10 +85,10 @@ func (c *Client) DeleteIGroup(ctx context.Context, igroup ontap.IGroup, allowDel } if ig.NumRecords == 0 { - return fmt.Errorf("failed to delete igroup=%s on svm=%s because it does not exist", igroup.Name, igroup.SVM.Name) + return fmt.Errorf("failed to get igroup=%s on svm=%s because it does not exist", igroup.Name, igroup.SVM.Name) } if ig.NumRecords != 1 { - return fmt.Errorf("failed to delete igroup=%s on svm=%s because there are %d matching records", igroup.Name, igroup.SVM.Name, ig.NumRecords) + return fmt.Errorf("failed to get igroup=%s on svm=%s because there are %d matching records", igroup.Name, igroup.SVM.Name, ig.NumRecords) } deleteParams := url.Values{} @@ -125,10 +125,10 @@ func (c *Client) AddIGroupInitiator(ctx context.Context, igroupName, svmName str } if ig.NumRecords == 0 { - return fmt.Errorf("failed to add initiator to igroup=%s on svm=%s because the igroup does not exist", igroupName, svmName) + return fmt.Errorf("failed to get igroup=%s on svm=%s because the igroup does not exist", igroupName, svmName) } if ig.NumRecords != 1 { - return fmt.Errorf("failed to add initiator to igroup=%s on svm=%s because there are %d matching records", igroupName, svmName, ig.NumRecords) + return fmt.Errorf("failed to get igroup=%s on svm=%s because there are %d matching records", igroupName, svmName, ig.NumRecords) } builder = c.baseRequestBuilder(`/api/protocols/san/igroups/`+ig.Records[0].UUID+`/initiators`, &statusCode, responseHeaders). @@ -162,10 +162,10 @@ func (c *Client) RemoveIGroupInitiator(ctx context.Context, igroupName, svmName } if ig.NumRecords == 0 { - return fmt.Errorf("failed to remove initiator from igroup=%s on svm=%s because the igroup does not exist", igroupName, svmName) + return fmt.Errorf("failed to get igroup=%s on svm=%s because the igroup does not exist", igroupName, svmName) } if ig.NumRecords != 1 { - return fmt.Errorf("failed to remove initiator from igroup=%s on svm=%s because there are %d matching records", igroupName, svmName, ig.NumRecords) + return fmt.Errorf("failed to get igroup=%s on svm=%s because there are %d matching records", igroupName, svmName, ig.NumRecords) } deleteParams := url.Values{} diff --git a/tool/tool.go b/tool/tool.go index d9b60b0..261f5cc 100644 --- a/tool/tool.go +++ b/tool/tool.go @@ -118,7 +118,7 @@ type NVMeService struct { type IGroup struct { Cluster string `json:"cluster_name" jsonschema:"cluster name"` SVM string `json:"svm_name" jsonschema:"SVM name"` - Name string `json:"name,omitzero" jsonschema:"igroup name"` + Name string `json:"name" jsonschema:"igroup name"` NewName string `json:"new_name,omitzero" jsonschema:"new igroup name"` OSType string `json:"os_type,omitzero" jsonschema:"OS type (aix, hpux, hyper_v, linux, netware, openvms, solaris, vmware, windows, xen)"` Protocol string `json:"protocol,omitzero" jsonschema:"protocol (fcp, iscsi, mixed)"` @@ -130,7 +130,7 @@ type IGroupInitiator struct { Cluster string `json:"cluster_name" jsonschema:"cluster name"` SVM string `json:"svm_name" jsonschema:"SVM name"` IGroupName string `json:"igroup_name" jsonschema:"igroup name"` - InitiatorName string `json:"initiator_name" jsonschema:"initiator name (IQN for iSCSI or WWPN for FC)"` + InitiatorName string `json:"initiator_name,omitzero" jsonschema:"initiator name (IQN for iSCSI or WWPN for FC)"` Comment string `json:"comment,omitzero" jsonschema:"comment"` Records []string `json:"records,omitzero" jsonschema:"An array of initiators specified to add multiple initiators to an initiator group in a single API call"` AllowDeleteWhileMapped bool `json:"allow_delete_while_mapped,omitzero" jsonschema:"Allows the deletion of an initiator from of a mapped initiator group. This parameter should be used with caution."` From 613ae18f6e36fdc630e4b5e79a18de70f53def0f Mon Sep 17 00:00:00 2001 From: hardikl Date: Thu, 9 Apr 2026 17:34:03 +0530 Subject: [PATCH 04/10] feat: adding lun-map tools --- descriptions/descriptions.go | 3 + docs/examples.md | 8 +++ integration/test/igroup_test.go | 30 +++++++--- integration/test/tools_test.go | 2 +- ontap/ontap.go | 8 +++ rest/lunmap.go | 65 ++++++++++++++++++++++ server/lunmap.go | 97 +++++++++++++++++++++++++++++++++ server/server.go | 4 ++ tool/tool.go | 7 +++ 9 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 rest/lunmap.go create mode 100644 server/lunmap.go diff --git a/descriptions/descriptions.go b/descriptions/descriptions.go index 3a9d7da..9b9be54 100644 --- a/descriptions/descriptions.go +++ b/descriptions/descriptions.go @@ -90,6 +90,9 @@ const DeleteIGroup = `Delete an igroup on a cluster by cluster name.` const AddIGroupInitiator = `Add an initiator to an igroup on a cluster by cluster name.` const RemoveIGroupInitiator = `Remove an initiator from an igroup on a cluster by cluster name.` +const CreateLunMap = `Create a LUN map on a cluster by cluster name. Maps a LUN to an igroup, making the LUN accessible to the initiators in the igroup.` +const DeleteLunMap = `Delete a LUN map on a cluster by cluster name. Removes the mapping between a LUN and an igroup.` + const ListOntapEndpoints = `List ONTAP REST collection endpoints in the catalog. The catalog contains all endpoints — can be large. Prefer search_ontap_endpoints for targeted discovery. Use the optional 'match' parameter to filter by substring or regex pattern (e.g. "snapshot", "lun", ".*nfs.*export.*"). diff --git a/docs/examples.md b/docs/examples.md index 9bae2cd..8a515c6 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -315,6 +315,14 @@ Expected Response: A summary of aggregate free space, followed by a recommendati Expected Response: igroup created successfully. +- On the umeng-aff300-05-06 cluster, create lun map of lun named lunpayroll and an igroup named igroupFin on the marketing svm + +Expected Response: lun map created successfully. + +- On the umeng-aff300-05-06 cluster, delete lun map of lun named lunpayroll and an igroup named igroupFin on the marketing svm + +Expected Response: lun map deleted successfully. + **Rename an iGroup** - On the umeng-aff300-05-06 cluster, rename igroup igroupFin to igroupFinNew and os type as windows on the marketing svm diff --git a/integration/test/igroup_test.go b/integration/test/igroup_test.go index f04e69a..bc9589e 100644 --- a/integration/test/igroup_test.go +++ b/integration/test/igroup_test.go @@ -11,7 +11,7 @@ import ( "github.com/netapp/ontap-mcp/config" ) -func TestIGroup(t *testing.T) { +func TestIGroupLUNMap(t *testing.T) { SkipIfMissing(t, CheckTools) tests := []struct { @@ -38,24 +38,36 @@ func TestIGroup(t *testing.T) { expectedOntapErr: "", verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=marketing", validationFunc: createObject}, }, - { - name: "Update igroup", - input: ClusterStr + "rename igroup " + rn("igroupFin") + " to " + rn("igroupFinNew") + " and os type as windows on the marketing svm", - expectedOntapErr: "", - verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=marketing", validationFunc: createObject}, - }, { name: "Add initiator to igroup", - input: ClusterStr + "add initiator iqn.2021-01.com.example:test to igroup " + rn("igroupFinNew") + " on the marketing svm", + input: ClusterStr + "add initiator iqn.2021-01.com.example:test to igroup " + rn("igroupFin") + " on the marketing svm", expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, { name: "Remove initiator from igroup", - input: ClusterStr + "remove initiator iqn.2021-01.com.example:test from igroup " + rn("igroupFinNew") + " on the marketing svm", + input: ClusterStr + "remove initiator iqn.2021-01.com.example:test from igroup " + rn("igroupFin") + " on the marketing svm", expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, + { + name: "Rename igroup", + input: ClusterStr + "rename igroup from " + rn("igroupFin") + " to " + rn("igroupFinNew") + " on the marketing svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=marketing", validationFunc: createObject}, + }, + { + name: "Create lun map", + input: ClusterStr + "create lun map of lun named " + "/vol/vol1/lunpayroll" + " and an igroup named " + rn("igroupFinNew") + " on the marketing svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/protocols/san/lun-maps?igroup.name=" + rn("igroupFinNew") + "&lun.name=" + "/vol/vol1/lunpayroll" + "&svm.name=marketing", validationFunc: createObject}, + }, + { + name: "Clean lun map", + input: ClusterStr + "delete lun map of lun named " + "/vol/vol1/lunpayroll" + " and an igroup named " + rn("igroupFinNew") + " on the marketing svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/protocols/san/lun-maps?igroup.name=" + rn("igroupFinNew") + "&lun.name=" + "/vol/vol1/lunpayroll" + "&svm.name=marketing", validationFunc: deleteObject}, + }, { name: "Clean igroup", input: ClusterStr + "delete igroup " + rn("igroupFinNew") + " on the marketing svm", diff --git a/integration/test/tools_test.go b/integration/test/tools_test.go index 817f5b8..cbc51d5 100644 --- a/integration/test/tools_test.go +++ b/integration/test/tools_test.go @@ -207,7 +207,7 @@ func (a *Agent) ChatWithResponse(ctx context.Context, t *testing.T, userMessage if expectedOntapErrorStr != "" && strings.Contains(err.Error(), expectedOntapErrorStr) { slog.Debug("Expected tool error", slog.String("tool", toolName), slog.Any("error", err)) } else { - t.Errorf("Tool %q returned error LLM will retry: %v", toolName, err) + t.Errorf("Tool %q args %v returned error LLM will retry: %v", toolName, args, err) } result = "Error: " + err.Error() } diff --git a/ontap/ontap.go b/ontap/ontap.go index e476604..cc67b52 100644 --- a/ontap/ontap.go +++ b/ontap/ontap.go @@ -56,6 +56,8 @@ type GetData struct { RwRule []string `json:"rw_rule,omitzero"` Clients []ClientData `json:"clients,omitzero"` Nas NAS `json:"nas,omitzero"` + Lun NameAndUUID `json:"lun,omitzero"` + IGroup NameAndUUID `json:"igroup,omitzero"` } `json:"records"` NumRecords int `json:"num_records"` } @@ -245,6 +247,12 @@ type IGroup struct { Initiators []IGroupInitiator `json:"initiators,omitzero"` } +type LunMap struct { + SVM NameAndUUID `json:"svm,omitzero"` + Lun NameAndUUID `json:"lun,omitzero"` + IGroup NameAndUUID `json:"igroup,omitzero"` +} + const ( ASAr2 = "asar2" CDOT = "cdot" diff --git a/rest/lunmap.go b/rest/lunmap.go new file mode 100644 index 0000000..74192c4 --- /dev/null +++ b/rest/lunmap.go @@ -0,0 +1,65 @@ +package rest + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/netapp/ontap-mcp/ontap" +) + +func (c *Client) CreateLunMap(ctx context.Context, lunMap ontap.LunMap) error { + var statusCode int + responseHeaders := http.Header{} + + builder := c.baseRequestBuilder(`/api/protocols/san/lun-maps`, &statusCode, responseHeaders). + BodyJSON(lunMap) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.checkStatus(statusCode) +} + +func (c *Client) DeleteLunMap(ctx context.Context, svmName, lunName, igroupName string) error { + var ( + statusCode int + lm ontap.GetData + ) + responseHeaders := http.Header{} + + params := url.Values{} + params.Set("fields", "lun.uuid,igroup.uuid") + params.Set("svm.name", svmName) + params.Set("lun.name", lunName) + params.Set("igroup.name", igroupName) + + builder := c.baseRequestBuilder(`/api/protocols/san/lun-maps`, &statusCode, responseHeaders). + Params(params). + ToJSON(&lm) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + if lm.NumRecords == 0 { + return fmt.Errorf("failed to find lun map for lun=%s igroup=%s on svm=%s because it does not exist", lunName, igroupName, svmName) + } + if lm.NumRecords != 1 { + return fmt.Errorf("failed to find lun map for lun=%s igroup=%s on svm=%s because there are %d matching records", lunName, igroupName, svmName, lm.NumRecords) + } + + lunUUID := lm.Records[0].Lun.UUID + igroupUUID := lm.Records[0].IGroup.UUID + + builder = c.baseRequestBuilder(`/api/protocols/san/lun-maps/`+url.PathEscape(lunUUID)+`/`+url.PathEscape(igroupUUID), &statusCode, responseHeaders). + Delete() + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.checkStatus(statusCode) +} diff --git a/server/lunmap.go b/server/lunmap.go new file mode 100644 index 0000000..31a6dd6 --- /dev/null +++ b/server/lunmap.go @@ -0,0 +1,97 @@ +package server + +import ( + "context" + "errors" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/netapp/ontap-mcp/ontap" + "github.com/netapp/ontap-mcp/tool" +) + +func (a *App) CreateLunMap(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.LunMap) (*mcp.CallToolResult, any, error) { + if !a.locks.TryLock(parameters.Cluster) { + return errorResult(fmt.Errorf("another write operation is in progress on cluster %s, please try again", parameters.Cluster)), nil, nil + } + defer a.locks.Unlock(parameters.Cluster) + + lunMapCreate, err := newCreateLunMap(parameters) + if err != nil { + return nil, nil, err + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + + err = client.CreateLunMap(ctx, lunMapCreate) + if err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "lun map created successfully"}, + }, + }, nil, nil +} + +func (a *App) DeleteLunMap(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.LunMap) (*mcp.CallToolResult, any, error) { + if !a.locks.TryLock(parameters.Cluster) { + return errorResult(fmt.Errorf("another write operation is in progress on cluster %s, please try again", parameters.Cluster)), nil, nil + } + defer a.locks.Unlock(parameters.Cluster) + + if err := validateDeleteLunMap(parameters); err != nil { + return nil, nil, err + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + + err = client.DeleteLunMap(ctx, parameters.SVM, parameters.LunName, parameters.IGroupName) + if err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "lun map deleted successfully"}, + }, + }, nil, nil +} + +func newCreateLunMap(in tool.LunMap) (ontap.LunMap, error) { + out := ontap.LunMap{} + if in.SVM == "" { + return out, errors.New("SVM name is required") + } + if in.LunName == "" { + return out, errors.New("LUN name is required") + } + if in.IGroupName == "" { + return out, errors.New("igroup name is required") + } + + out.SVM = ontap.NameAndUUID{Name: in.SVM} + out.Lun = ontap.NameAndUUID{Name: in.LunName} + out.IGroup = ontap.NameAndUUID{Name: in.IGroupName} + return out, nil +} + +func validateDeleteLunMap(in tool.LunMap) error { + if in.SVM == "" { + return errors.New("SVM name is required") + } + if in.LunName == "" { + return errors.New("LUN name is required") + } + if in.IGroupName == "" { + return errors.New("igroup name is required") + } + return nil +} diff --git a/server/server.go b/server/server.go index 875ae68..69f5fb6 100644 --- a/server/server.go +++ b/server/server.go @@ -150,6 +150,10 @@ func (a *App) createMCPServer() *mcp.Server { addTool(a, server, "add_igroup_initiator", descriptions.AddIGroupInitiator, createAnnotation, a.AddIGroupInitiator) addTool(a, server, "remove_igroup_initiator", descriptions.RemoveIGroupInitiator, deleteAnnotation, a.RemoveIGroupInitiator) + // operation on LUN map object + addTool(a, server, "create_lun_map", descriptions.CreateLunMap, createAnnotation, a.CreateLunMap) + addTool(a, server, "delete_lun_map", descriptions.DeleteLunMap, deleteAnnotation, a.DeleteLunMap) + if a.catalog != nil { addTool(a, server, "list_ontap_endpoints", descriptions.ListOntapEndpoints, readOnlyAnnotation, a.ListOntapEndpoints) addTool(a, server, "search_ontap_endpoints", descriptions.SearchOntapEndpoints, readOnlyAnnotation, a.SearchOntapEndpoints) diff --git a/tool/tool.go b/tool/tool.go index ba3e5bc..ecd5448 100644 --- a/tool/tool.go +++ b/tool/tool.go @@ -158,6 +158,13 @@ type IGroupInitiator struct { AllowDeleteWhileMapped bool `json:"allow_delete_while_mapped,omitzero" jsonschema:"Allows the deletion of an initiator from of a mapped initiator group. This parameter should be used with caution."` } +type LunMap struct { + Cluster string `json:"cluster_name" jsonschema:"cluster name"` + SVM string `json:"svm_name" jsonschema:"SVM name"` + LunName string `json:"lun_name" jsonschema:"LUN name (full path, e.g. /vol/vol1/lun1)"` + IGroupName string `json:"igroup_name" jsonschema:"igroup name to map the LUN to"` +} + type OntapGetParams struct { Cluster string `json:"cluster_name" jsonschema:"cluster name, from list_registered_clusters"` Fields string `json:"fields,omitzero" jsonschema:"comma-separated dot-notation fields to return, e.g. \"name,svm.name,space.size\" — use space.* to expand all space sub-fields"` From 22df5d92660dddaed3331b6104f522b0f7b56421 Mon Sep 17 00:00:00 2001 From: hardikl Date: Mon, 13 Apr 2026 14:42:29 +0530 Subject: [PATCH 05/10] feat: adding svm clean in tests --- integration/test/igroup_test.go | 50 ++++++++++++++++++++++----------- integration/test/tools_test.go | 1 + 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/integration/test/igroup_test.go b/integration/test/igroup_test.go index bc9589e..3ecbb4f 100644 --- a/integration/test/igroup_test.go +++ b/integration/test/igroup_test.go @@ -20,59 +20,77 @@ func TestIGroupLUNMap(t *testing.T) { expectedOntapErr string verifyAPI ontapVerifier }{ + { + name: "Clean SVM", + input: ClusterStr + "delete " + rn("marketing") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("marketing"), validationFunc: deleteObject}, + }, + { + name: "Create SVM", + input: ClusterStr + "create " + rn("marketing") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("marketing"), validationFunc: createObject}, + }, { name: "Clean igroup", - input: ClusterStr + "delete igroup " + rn("igroupFin") + " on the marketing svm", + input: ClusterStr + "delete igroup " + rn("igroupFin") + " on the " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", - verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=marketing", validationFunc: deleteObject}, + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Clean igroup", - input: ClusterStr + "delete igroup " + rn("igroupFinNew") + " on the marketing svm", + input: ClusterStr + "delete igroup " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", - verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=marketing", validationFunc: deleteObject}, + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Create igroup", - input: ClusterStr + "create an igroup named " + rn("igroupFin") + " with OS type linux and protocol iscsi on the marketing svm", + input: ClusterStr + "create an igroup named " + rn("igroupFin") + " with OS type linux and protocol iscsi on the " + rn("marketing") + " svm", expectedOntapErr: "", - verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=marketing", validationFunc: createObject}, + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=" + rn("marketing"), validationFunc: createObject}, }, { name: "Add initiator to igroup", - input: ClusterStr + "add initiator iqn.2021-01.com.example:test to igroup " + rn("igroupFin") + " on the marketing svm", + input: ClusterStr + "add initiator iqn.2021-01.com.example:test to igroup " + rn("igroupFin") + " on the " + rn("marketing") + " svm", expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, { name: "Remove initiator from igroup", - input: ClusterStr + "remove initiator iqn.2021-01.com.example:test from igroup " + rn("igroupFin") + " on the marketing svm", + input: ClusterStr + "remove initiator iqn.2021-01.com.example:test from igroup " + rn("igroupFin") + " on the " + rn("marketing") + " svm", expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, { name: "Rename igroup", - input: ClusterStr + "rename igroup from " + rn("igroupFin") + " to " + rn("igroupFinNew") + " on the marketing svm", + input: ClusterStr + "rename igroup from " + rn("igroupFin") + " to " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", expectedOntapErr: "", - verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=marketing", validationFunc: createObject}, + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=" + rn("marketing"), validationFunc: createObject}, }, { name: "Create lun map", - input: ClusterStr + "create lun map of lun named " + "/vol/vol1/lunpayroll" + " and an igroup named " + rn("igroupFinNew") + " on the marketing svm", + input: ClusterStr + "create lun map of lun named " + rn("/vol/vol1/lunpayroll") + " and an igroup named " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", expectedOntapErr: "", - verifyAPI: ontapVerifier{api: "api/protocols/san/lun-maps?igroup.name=" + rn("igroupFinNew") + "&lun.name=" + "/vol/vol1/lunpayroll" + "&svm.name=marketing", validationFunc: createObject}, + verifyAPI: ontapVerifier{api: "api/protocols/san/lun-maps?igroup.name=" + rn("igroupFinNew") + "&lun.name=" + rn("/vol/vol1/lunpayroll") + "&svm.name=" + rn("marketing"), validationFunc: createObject}, }, { name: "Clean lun map", - input: ClusterStr + "delete lun map of lun named " + "/vol/vol1/lunpayroll" + " and an igroup named " + rn("igroupFinNew") + " on the marketing svm", + input: ClusterStr + "delete lun map of lun named " + rn("/vol/vol1/lunpayroll") + " and an igroup named " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", - verifyAPI: ontapVerifier{api: "api/protocols/san/lun-maps?igroup.name=" + rn("igroupFinNew") + "&lun.name=" + "/vol/vol1/lunpayroll" + "&svm.name=marketing", validationFunc: deleteObject}, + verifyAPI: ontapVerifier{api: "api/protocols/san/lun-maps?igroup.name=" + rn("igroupFinNew") + "&lun.name=" + rn("/vol/vol1/lunpayroll") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Clean igroup", - input: ClusterStr + "delete igroup " + rn("igroupFinNew") + " on the marketing svm", + input: ClusterStr + "delete igroup " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, + }, + { + name: "Clean SVM", + input: ClusterStr + "delete " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", - verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=marketing", validationFunc: deleteObject}, + verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("marketing"), validationFunc: deleteObject}, }, } diff --git a/integration/test/tools_test.go b/integration/test/tools_test.go index 906f42b..89abd32 100644 --- a/integration/test/tools_test.go +++ b/integration/test/tools_test.go @@ -223,6 +223,7 @@ func (a *Agent) ChatWithResponse(ctx context.Context, t *testing.T, userMessage } } + t.Errorf("Tool %q args %v returned error %v", failedTool, argsUsed, errFound) return "", fmt.Errorf("max iterations (%d) reached; last tool %q args %v error: %w", maxIterations, failedTool, argsUsed, errFound) } From fd143c3775337b869ccfa40bd84c0842d53c53a4 Mon Sep 17 00:00:00 2001 From: hardikl Date: Tue, 14 Apr 2026 18:34:01 +0530 Subject: [PATCH 06/10] feat: update test cases --- integration/test/igroup_test.go | 86 +++++++++++++++++++++++++++++++-- integration/test/lun_test.go | 6 +++ ontap/ontap.go | 2 +- server/iscsiService.go | 3 ++ tool/tool.go | 2 +- 5 files changed, 93 insertions(+), 6 deletions(-) diff --git a/integration/test/igroup_test.go b/integration/test/igroup_test.go index 3ecbb4f..b174d63 100644 --- a/integration/test/igroup_test.go +++ b/integration/test/igroup_test.go @@ -32,6 +32,24 @@ func TestIGroupLUNMap(t *testing.T) { expectedOntapErr: "", verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("marketing"), validationFunc: createObject}, }, + { + name: "Create iSCSI service", + input: ClusterStr + "create iscsi service on the " + rn("marketing") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/protocols/san/iscsi/services?svm.name=" + rn("marketing"), validationFunc: createObject}, + }, + { + name: "Create svm scope network interface with ip 1", + input: ClusterStr + "create network interface named " + rn("svg1") + " in " + rn("marketing") + " svm with ip address 10.63.41.117 and netmask 18 on node umeng-aff300-05 with service policy as blocks", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/network/ip/interfaces?name=" + rn("svg1") + "&scope=svm", validationFunc: createObject}, + }, + { + name: "Create svm scope network interface with ip 2", + input: ClusterStr + "create network interface named " + rn("svg2") + " in " + rn("marketing") + " svm with ip address 10.63.41.118 and netmask 18 on node umeng-aff300-06 with service policy as blocks", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/network/ip/interfaces?name=" + rn("svg2") + "&scope=svm", validationFunc: createObject}, + }, { name: "Clean igroup", input: ClusterStr + "delete igroup " + rn("igroupFin") + " on the " + rn("marketing") + " svm", @@ -44,6 +62,18 @@ func TestIGroupLUNMap(t *testing.T) { expectedOntapErr: "because it does not exist", verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, + { + name: "Clean volume", + input: ClusterStr + "delete volume " + rn("doc") + " in " + rn("marketing") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("doc") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, + }, + { + name: "Clean LUN", + input: ClusterStr + "delete lun " + rn("lundoc") + " in volume " + rn("doc") + " in " + rn("marketing") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/storage/luns?name=/vol/" + rn("doc") + "/" + rn("lundoc") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, + }, { name: "Create igroup", input: ClusterStr + "create an igroup named " + rn("igroupFin") + " with OS type linux and protocol iscsi on the " + rn("marketing") + " svm", @@ -68,17 +98,41 @@ func TestIGroupLUNMap(t *testing.T) { expectedOntapErr: "", verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=" + rn("marketing"), validationFunc: createObject}, }, + { + name: "Create volume", + input: ClusterStr + "create a 100MB volume named " + rn("doc") + " on the " + rn("marketing") + " svm and the harvest_vc_aggr aggregate", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("doc") + "&svm.name=" + rn("marketing"), validationFunc: createObject}, + }, + { + name: "Create LUN", + input: ClusterStr + "create a 20MB lun named " + rn("lundoc") + " in volume " + rn("doc") + " on the " + rn("marketing") + " svm with os type linux", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/storage/luns?name=/vol/" + rn("doc") + "/" + rn("lundoc") + "&svm.name=" + rn("marketing"), validationFunc: createObject}, + }, { name: "Create lun map", - input: ClusterStr + "create lun map of lun named " + rn("/vol/vol1/lunpayroll") + " and an igroup named " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", + input: ClusterStr + "create lun map of lun named " + "/vol/" + rn("doc") + "/" + rn("lundoc") + " and an igroup named " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", expectedOntapErr: "", - verifyAPI: ontapVerifier{api: "api/protocols/san/lun-maps?igroup.name=" + rn("igroupFinNew") + "&lun.name=" + rn("/vol/vol1/lunpayroll") + "&svm.name=" + rn("marketing"), validationFunc: createObject}, + verifyAPI: ontapVerifier{api: "api/protocols/san/lun-maps?igroup.name=" + rn("igroupFinNew") + "&lun.name=" + "/vol/" + rn("doc") + "/" + rn("lundoc") + "&svm.name=" + rn("marketing"), validationFunc: createObject}, }, { name: "Clean lun map", - input: ClusterStr + "delete lun map of lun named " + rn("/vol/vol1/lunpayroll") + " and an igroup named " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", + input: ClusterStr + "delete lun map of lun named " + "/vol/" + rn("doc") + "/" + rn("lundoc") + " and an igroup named " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/protocols/san/lun-maps?igroup.name=" + rn("igroupFinNew") + "&lun.name=" + "/vol/" + rn("doc") + "/" + rn("lundoc") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, + }, + { + name: "Clean volume", + input: ClusterStr + "delete volume " + rn("doc") + " in " + rn("marketing") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("doc") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, + }, + { + name: "Clean LUN", + input: ClusterStr + "delete lun " + rn("lundoc") + " in volume " + rn("doc") + " in " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", - verifyAPI: ontapVerifier{api: "api/protocols/san/lun-maps?igroup.name=" + rn("igroupFinNew") + "&lun.name=" + rn("/vol/vol1/lunpayroll") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, + verifyAPI: ontapVerifier{api: "api/storage/luns?name=/vol/" + rn("doc") + "/" + rn("lundoc") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Clean igroup", @@ -86,6 +140,30 @@ func TestIGroupLUNMap(t *testing.T) { expectedOntapErr: "because it does not exist", verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, + { + name: "Clean svm scope network interface with ip 1", + input: ClusterStr + "delete svm scope network interface named " + rn("svg1") + " in " + rn("marketing") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/network/ip/interfaces?name=" + rn("svg1") + "&scope=svm", validationFunc: deleteObject}, + }, + { + name: "Clean svm scope network interface with ip 2", + input: ClusterStr + "delete svm scope network interface named " + rn("svg2") + " in " + rn("marketing") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/network/ip/interfaces?name=" + rn("svg2") + "&scope=svm", validationFunc: deleteObject}, + }, + { + name: "Update iSCSI service", + input: ClusterStr + "disabled iscsi service on the " + rn("marketing") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{}, + }, + { + name: "Clean iSCSI service", + input: ClusterStr + "delete iscsi service in " + rn("marketing") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/protocols/san/iscsi/services?svm.name=" + rn("marketing"), validationFunc: deleteObject}, + }, { name: "Clean SVM", input: ClusterStr + "delete " + rn("marketing") + " svm", diff --git a/integration/test/lun_test.go b/integration/test/lun_test.go index b5f5a6d..baae47f 100644 --- a/integration/test/lun_test.go +++ b/integration/test/lun_test.go @@ -44,6 +44,12 @@ func TestLUN(t *testing.T) { expectedOntapErr: "because it does not exist", verifyAPI: ontapVerifier{api: "api/storage/luns?name=/vol/" + rn("doc") + "/" + rn("lundocnew") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, + { + name: "Clean volume", + input: ClusterStr + "delete volume " + rn("doc") + " in " + rn("marketing") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("doc") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, + }, { name: "Create volume", input: ClusterStr + "create a 100MB volume named " + rn("doc") + " on the " + rn("marketing") + " svm and the harvest_vc_aggr aggregate", diff --git a/ontap/ontap.go b/ontap/ontap.go index 51c3396..ed13b9d 100644 --- a/ontap/ontap.go +++ b/ontap/ontap.go @@ -241,7 +241,7 @@ type NetworkIPInterface struct { IP IP `json:"ip,omitzero" jsonschema:"ip address"` Subnet NameAndUUID `json:"subnet,omitzero" jsonschema:"subnet name"` Location Location `json:"location,omitzero" jsonschema:"location name"` - ServicePolicy NameAndUUID `json:"service_policy,omitzero" jsonschema:"service policy"` + ServicePolicy NameAndUUID `json:"service_policy,omitzero" jsonschema:"service policy"` // default-data-files , default-data-blocks, default-data-iscsi, default-management, default-intercluster, default-management, default-route-announce } type NVMeSubsystem struct { diff --git a/server/iscsiService.go b/server/iscsiService.go index 97f43e0..7d669d7 100644 --- a/server/iscsiService.go +++ b/server/iscsiService.go @@ -261,6 +261,9 @@ func newCreateNetworkIPInterface(in tool.NetworkIPInterface) (ontap.NetworkIPInt if in.IPSpace != "" { out.IPSpace = ontap.NameAndUUID{Name: in.IPSpace} } + if in.ServicePolicy != "" { + out.ServicePolicy.Name = in.ServicePolicy + } return out, nil } diff --git a/tool/tool.go b/tool/tool.go index 48328e1..932623a 100644 --- a/tool/tool.go +++ b/tool/tool.go @@ -154,7 +154,7 @@ type NetworkIPInterface struct { HomeNode string `json:"location.home_node,omitzero" jsonschema:"home node"` BroadcastDomain string `json:"location.broadcast_domain,omitzero" jsonschema:"broadcast domain"` AutoRevert string `json:"location.auto_revert,omitzero" jsonschema:"auto_revert"` - ServicePolicy string `json:"service_policy,omitzero" jsonschema:"service policy"` + ServicePolicy string `json:"service_policy,omitzero" jsonschema:"service policy (e.g., default-data-files , default-data-blocks, default-data-iscsi, default-management, default-intercluster, default-management, default-route-announce)"` } type NVMeSubsystem struct { From 95e501e523ecab632e109ad865c096b7763bcc83 Mon Sep 17 00:00:00 2001 From: hardikl Date: Tue, 14 Apr 2026 18:35:30 +0530 Subject: [PATCH 07/10] feat: minor change --- docs/examples.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index ad47330..4b78178 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -369,11 +369,11 @@ Expected Response: A summary of aggregate free space, followed by a recommendati Expected Response: igroup created successfully. -- On the umeng-aff300-05-06 cluster, create lun map of lun named lunpayroll and an igroup named igroupFin on the marketing svm +- On the umeng-aff300-05-06 cluster, create lun map of lun named /vol/docs/lunpayroll and an igroup named igroupFin on the marketing svm Expected Response: lun map created successfully. -- On the umeng-aff300-05-06 cluster, delete lun map of lun named lunpayroll and an igroup named igroupFin on the marketing svm +- On the umeng-aff300-05-06 cluster, delete lun map of lun named /vol/docs/lunpayroll and an igroup named igroupFin on the marketing svm Expected Response: lun map deleted successfully. From 730a82761907fb9b89b2fd9b7486bae6ec50a2d1 Mon Sep 17 00:00:00 2001 From: hardikl Date: Wed, 15 Apr 2026 15:32:54 +0530 Subject: [PATCH 08/10] feat: handled copilot comments --- ontap/ontap.go | 2 +- server/igroup.go | 15 ++++++++++++--- tool/tool.go | 9 ++++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/ontap/ontap.go b/ontap/ontap.go index ed13b9d..c5e96da 100644 --- a/ontap/ontap.go +++ b/ontap/ontap.go @@ -241,7 +241,7 @@ type NetworkIPInterface struct { IP IP `json:"ip,omitzero" jsonschema:"ip address"` Subnet NameAndUUID `json:"subnet,omitzero" jsonschema:"subnet name"` Location Location `json:"location,omitzero" jsonschema:"location name"` - ServicePolicy NameAndUUID `json:"service_policy,omitzero" jsonschema:"service policy"` // default-data-files , default-data-blocks, default-data-iscsi, default-management, default-intercluster, default-management, default-route-announce + ServicePolicy NameAndUUID `json:"service_policy,omitzero" jsonschema:"service policy"` // default-data-files, default-data-blocks, default-data-iscsi, default-management, default-intercluster, default-route-announce } type NVMeSubsystem struct { diff --git a/server/igroup.go b/server/igroup.go index 44136ad..4693a39 100644 --- a/server/igroup.go +++ b/server/igroup.go @@ -10,7 +10,7 @@ import ( "github.com/netapp/ontap-mcp/tool" ) -func (a *App) CreateIGroup(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.IGroup) (*mcp.CallToolResult, any, error) { +func (a *App) CreateIGroup(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.IGroupCreate) (*mcp.CallToolResult, any, error) { if !a.locks.TryLock(parameters.Cluster) { return errorResult(fmt.Errorf("another write operation is in progress on cluster %s, please try again", parameters.Cluster)), nil, nil } @@ -150,7 +150,7 @@ func (a *App) RemoveIGroupInitiator(ctx context.Context, _ *mcp.CallToolRequest, }, nil, nil } -func newCreateIGroup(in tool.IGroup) (ontap.IGroup, error) { +func newCreateIGroup(in tool.IGroupCreate) (ontap.IGroup, error) { out := ontap.IGroup{} if in.SVM == "" { return out, errors.New("SVM name is required") @@ -169,7 +169,9 @@ func newCreateIGroup(in tool.IGroup) (ontap.IGroup, error) { out.Name = in.Name out.OSType = in.OSType out.Protocol = in.Protocol - out.Comment = in.Comment + if in.Comment != "" { + out.Comment = in.Comment + } return out, nil } @@ -182,14 +184,21 @@ func newUpdateIGroup(in tool.IGroup) (ontap.IGroup, error) { return out, errors.New("igroup name is required") } + hasUpdate := false if in.NewName != "" { out.Name = in.NewName + hasUpdate = true } if in.Comment != "" { out.Comment = in.Comment + hasUpdate = true } if in.OSType != "" { out.OSType = in.OSType + hasUpdate = true + } + if !hasUpdate { + return out, errors.New("at least one updatable field must be provided: new_name, comment, or os_type") } return out, nil } diff --git a/tool/tool.go b/tool/tool.go index 932623a..ca70875 100644 --- a/tool/tool.go +++ b/tool/tool.go @@ -192,13 +192,20 @@ type NVMeSubsystemMap struct { Namespace string `json:"namespace_name" jsonschema:"name for NVMe namespace"` } +type IGroupCreate struct { + Cluster string `json:"cluster_name" jsonschema:"cluster name"` + SVM string `json:"svm_name" jsonschema:"SVM name"` + Name string `json:"name" jsonschema:"igroup name"` + OSType string `json:"os_type" jsonschema:"OS type (aix, hpux, hyper_v, linux, netware, openvms, solaris, vmware, windows, xen)"` + Protocol string `json:"protocol" jsonschema:"protocol (fcp, iscsi, mixed)"` + Comment string `json:"comment,omitzero" jsonschema:"comment"` +} type IGroup struct { Cluster string `json:"cluster_name" jsonschema:"cluster name"` SVM string `json:"svm_name" jsonschema:"SVM name"` Name string `json:"name" jsonschema:"igroup name"` NewName string `json:"new_name,omitzero" jsonschema:"new igroup name"` OSType string `json:"os_type,omitzero" jsonschema:"OS type (aix, hpux, hyper_v, linux, netware, openvms, solaris, vmware, windows, xen)"` - Protocol string `json:"protocol,omitzero" jsonschema:"protocol (fcp, iscsi, mixed)"` Comment string `json:"comment,omitzero" jsonschema:"comment"` AllowDeleteWhileMapped bool `json:"allow_delete_while_mapped,omitzero" jsonschema:"Allows the deletion of a mapped initiator group. This parameter should be used with caution"` } From aaf56da51f0849710c84c642e62f3a7cfd01da9e Mon Sep 17 00:00:00 2001 From: hardikl Date: Thu, 16 Apr 2026 17:44:42 +0530 Subject: [PATCH 09/10] feat: update test cases --- integration/test/igroup_test.go | 68 ++++++++++++++++++++++++++++----- ontap/ontap.go | 11 +++--- tool/tool.go | 2 +- 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/integration/test/igroup_test.go b/integration/test/igroup_test.go index b174d63..601cc68 100644 --- a/integration/test/igroup_test.go +++ b/integration/test/igroup_test.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/tls" + "github.com/carlmjohnson/requests" "log/slog" "net/http" "testing" @@ -84,13 +85,13 @@ func TestIGroupLUNMap(t *testing.T) { name: "Add initiator to igroup", input: ClusterStr + "add initiator iqn.2021-01.com.example:test to igroup " + rn("igroupFin") + " on the " + rn("marketing") + " svm", expectedOntapErr: "", - verifyAPI: ontapVerifier{}, + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=" + rn("marketing") + "&fields=initiators", validationFunc: verifyInitiator(true, "iqn.2021-01.com.example:test")}, }, { name: "Remove initiator from igroup", input: ClusterStr + "remove initiator iqn.2021-01.com.example:test from igroup " + rn("igroupFin") + " on the " + rn("marketing") + " svm", expectedOntapErr: "", - verifyAPI: ontapVerifier{}, + verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=" + rn("marketing") + "&fields=initiators", validationFunc: verifyInitiator(false, "iqn.2021-01.com.example:test")}, }, { name: "Rename igroup", @@ -119,37 +120,37 @@ func TestIGroupLUNMap(t *testing.T) { { name: "Clean lun map", input: ClusterStr + "delete lun map of lun named " + "/vol/" + rn("doc") + "/" + rn("lundoc") + " and an igroup named " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", - expectedOntapErr: "because it does not exist", + expectedOntapErr: "", verifyAPI: ontapVerifier{api: "api/protocols/san/lun-maps?igroup.name=" + rn("igroupFinNew") + "&lun.name=" + "/vol/" + rn("doc") + "/" + rn("lundoc") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Clean volume", input: ClusterStr + "delete volume " + rn("doc") + " in " + rn("marketing") + " svm", - expectedOntapErr: "because it does not exist", + expectedOntapErr: "", verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("doc") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Clean LUN", input: ClusterStr + "delete lun " + rn("lundoc") + " in volume " + rn("doc") + " in " + rn("marketing") + " svm", - expectedOntapErr: "because it does not exist", + expectedOntapErr: "", verifyAPI: ontapVerifier{api: "api/storage/luns?name=/vol/" + rn("doc") + "/" + rn("lundoc") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Clean igroup", input: ClusterStr + "delete igroup " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", - expectedOntapErr: "because it does not exist", + expectedOntapErr: "", verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Clean svm scope network interface with ip 1", input: ClusterStr + "delete svm scope network interface named " + rn("svg1") + " in " + rn("marketing") + " svm", - expectedOntapErr: "because it does not exist", + expectedOntapErr: "", verifyAPI: ontapVerifier{api: "api/network/ip/interfaces?name=" + rn("svg1") + "&scope=svm", validationFunc: deleteObject}, }, { name: "Clean svm scope network interface with ip 2", input: ClusterStr + "delete svm scope network interface named " + rn("svg2") + " in " + rn("marketing") + " svm", - expectedOntapErr: "because it does not exist", + expectedOntapErr: "", verifyAPI: ontapVerifier{api: "api/network/ip/interfaces?name=" + rn("svg2") + "&scope=svm", validationFunc: deleteObject}, }, { @@ -161,13 +162,13 @@ func TestIGroupLUNMap(t *testing.T) { { name: "Clean iSCSI service", input: ClusterStr + "delete iscsi service in " + rn("marketing") + " svm", - expectedOntapErr: "because it does not exist", + expectedOntapErr: "", verifyAPI: ontapVerifier{api: "api/protocols/san/iscsi/services?svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Clean SVM", input: ClusterStr + "delete " + rn("marketing") + " svm", - expectedOntapErr: "because it does not exist", + expectedOntapErr: "", verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("marketing"), validationFunc: deleteObject}, }, } @@ -199,3 +200,50 @@ func TestIGroupLUNMap(t *testing.T) { }) } } + +func verifyInitiator(exist bool, expectedInitiatorName string) func(t *testing.T, api string, poller *config.Poller, client *http.Client) bool { + return func(t *testing.T, api string, poller *config.Poller, client *http.Client) bool { + type InitiatorName struct { + Name string `json:"name"` + } + type IGroup struct { + Initiators []InitiatorName `json:"initiators"` + } + type response struct { + NumRecords int `json:"num_records"` + Records []IGroup `json:"records"` + } + + var data response + var initiatorFound bool + err := requests.URL("https://"+poller.Addr+"/"+api). + BasicAuth(poller.Username, poller.Password). + Client(client). + ToJSON(&data). + Fetch(context.Background()) + if err != nil { + t.Errorf("verifyInitiator: request failed: %v", err) + return false + } + if data.NumRecords != 1 { + t.Errorf("verifyInitiator: expected 1 record, got %d", data.NumRecords) + return false + } + gotIgroup := data.Records[0] + for _, initiator := range gotIgroup.Initiators { + if initiator.Name != expectedInitiatorName { + continue + } + if !exist { + t.Errorf("verifyInitiator: initiator should not be exist") + return false + } + initiatorFound = true + } + if !initiatorFound && exist { + t.Errorf("verifyInitiator: initiator must be exist") + return false + } + return true + } +} diff --git a/ontap/ontap.go b/ontap/ontap.go index c5e96da..34d161c 100644 --- a/ontap/ontap.go +++ b/ontap/ontap.go @@ -292,12 +292,11 @@ type IGroupInitiator struct { } type IGroup struct { - SVM NameAndUUID `json:"svm,omitzero"` - Name string `json:"name,omitzero"` - OSType string `json:"os_type,omitzero"` - Protocol string `json:"protocol,omitzero"` - Comment string `json:"comment,omitzero"` - Initiators []IGroupInitiator `json:"initiators,omitzero"` + SVM NameAndUUID `json:"svm,omitzero"` + Name string `json:"name,omitzero"` + OSType string `json:"os_type,omitzero"` + Protocol string `json:"protocol,omitzero"` + Comment string `json:"comment,omitzero"` } type LunMap struct { diff --git a/tool/tool.go b/tool/tool.go index ca70875..d710e92 100644 --- a/tool/tool.go +++ b/tool/tool.go @@ -217,7 +217,7 @@ type IGroupInitiator struct { InitiatorName string `json:"initiator_name,omitzero" jsonschema:"initiator name (IQN for iSCSI or WWPN for FC)"` Comment string `json:"comment,omitzero" jsonschema:"comment"` Records []string `json:"records,omitzero" jsonschema:"An array of initiators specified to add multiple initiators to an initiator group in a single API call"` - AllowDeleteWhileMapped bool `json:"allow_delete_while_mapped,omitzero" jsonschema:"Allows the deletion of an initiator from of a mapped initiator group. This parameter should be used with caution."` + AllowDeleteWhileMapped bool `json:"allow_delete_while_mapped,omitzero" jsonschema:"Allows the deletion of an initiator from a mapped initiator group. This parameter should be used with caution."` } type LunMap struct { From b826d2a2c7a60954cff182aa9ff443b3d4435aa5 Mon Sep 17 00:00:00 2001 From: hardikl Date: Fri, 17 Apr 2026 14:33:48 +0530 Subject: [PATCH 10/10] feat: update test cases --- integration/test/igroup_test.go | 10 +++++----- tool/tool.go | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/integration/test/igroup_test.go b/integration/test/igroup_test.go index 601cc68..df2f1b4 100644 --- a/integration/test/igroup_test.go +++ b/integration/test/igroup_test.go @@ -3,12 +3,12 @@ package main import ( "context" "crypto/tls" - "github.com/carlmjohnson/requests" "log/slog" "net/http" "testing" "time" + "github.com/carlmjohnson/requests" "github.com/netapp/ontap-mcp/config" ) @@ -52,25 +52,25 @@ func TestIGroupLUNMap(t *testing.T) { verifyAPI: ontapVerifier{api: "api/network/ip/interfaces?name=" + rn("svg2") + "&scope=svm", validationFunc: createObject}, }, { - name: "Clean igroup", + name: "Clean igroup igroupFin", input: ClusterStr + "delete igroup " + rn("igroupFin") + " on the " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFin") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { - name: "Clean igroup", + name: "Clean igroup igroupFinNew", input: ClusterStr + "delete igroup " + rn("igroupFinNew") + " on the " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", verifyAPI: ontapVerifier{api: "api/protocols/san/igroups?name=" + rn("igroupFinNew") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { - name: "Clean volume", + name: "Clean volume doc", input: ClusterStr + "delete volume " + rn("doc") + " in " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("doc") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, }, { - name: "Clean LUN", + name: "Clean LUN lundoc", input: ClusterStr + "delete lun " + rn("lundoc") + " in volume " + rn("doc") + " in " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", verifyAPI: ontapVerifier{api: "api/storage/luns?name=/vol/" + rn("doc") + "/" + rn("lundoc") + "&svm.name=" + rn("marketing"), validationFunc: deleteObject}, diff --git a/tool/tool.go b/tool/tool.go index 485f0b4..b3897bf 100644 --- a/tool/tool.go +++ b/tool/tool.go @@ -154,7 +154,7 @@ type NetworkIPInterface struct { HomeNode string `json:"location.home_node,omitzero" jsonschema:"home node"` BroadcastDomain string `json:"location.broadcast_domain,omitzero" jsonschema:"broadcast domain"` AutoRevert string `json:"location.auto_revert,omitzero" jsonschema:"auto_revert"` - ServicePolicy string `json:"service_policy,omitzero" jsonschema:"service policy (e.g., default-data-files , default-data-blocks, default-data-iscsi, default-management, default-intercluster, default-management, default-route-announce)"` + ServicePolicy string `json:"service_policy,omitzero" jsonschema:"service policy (e.g., default-data-files, default-data-blocks, default-data-iscsi, default-management, default-intercluster, default-route-announce)"` } type NVMeSubsystem struct {