diff --git a/descriptions/descriptions.go b/descriptions/descriptions.go index 2996119..e375a01 100644 --- a/descriptions/descriptions.go +++ b/descriptions/descriptions.go @@ -98,6 +98,10 @@ const DeleteNVMeNamespace = `Delete NVMe namespace on a cluster by cluster name. const CreateNVMeSubsystemMap = `Create NVMe subsystem map on a cluster by cluster name.` const DeleteNVMeSubsystemMap = `Delete NVMe subsystem map on a cluster by cluster name.` +const CreateLUN = `Create a LUN on a specified volume and SVM with a given size and OS type.` +const UpdateLUN = `Update a LUN: resize, rename, or toggle enabled/disabled state (online/offline).` +const DeleteLUN = `Delete a LUN from a specified volume and SVM.` + 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 a45fa42..ba85471 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -311,6 +311,40 @@ Expected Response: The Network IP interface deleted successfully. --- +### LUN Provisioning + +**Create a LUN** + +- On the umeng-aff300-05-06 cluster, create a 20MB lun named lundoc in volume doc on the marketing svm with os type linux + +Expected Response: LUN has been created successfully. + +**Resize a LUN** + +- On the umeng-aff300-05-06 cluster, update lun lundoc size to 50mb in volume doc on the marketing svm + +Expected Response: LUN has been updated successfully. + +**Rename a LUN** + +- On the umeng-aff300-05-06 cluster, rename the lun lundoc in volume doc on the marketing svm to lundocnew + +Expected Response: LUN has been updated successfully. + +**State change of a LUN** + +- On the umeng-aff300-05-06 cluster, disable the lun lundocnew in volume doc on the marketing svm + +Expected Response: LUN has been updated successfully. + +**Delete a LUN** + +- On the umeng-aff300-05-06 cluster, delete lun lundocnew in volume doc in marketing svm + +Expected Response: LUN has been deleted successfully. + +--- + ### Querying Specific Fields **Get volume space and protection details** diff --git a/integration/test/lun_test.go b/integration/test/lun_test.go new file mode 100644 index 0000000..b5f5a6d --- /dev/null +++ b/integration/test/lun_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "crypto/tls" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/netapp/ontap-mcp/config" +) + +func TestLUN(t *testing.T) { + SkipIfMissing(t, CheckTools) + + tests := []struct { + name string + input string + 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 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: "Clean LUN", + input: ClusterStr + "delete lun " + rn("lundocnew") + " 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("lundocnew") + "&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", + 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: "Update lun size", + input: ClusterStr + "update lun " + rn("lundoc") + " size to 50mb in volume " + rn("doc") + " on the " + rn("marketing") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{}, + }, + { + name: "Rename lun", + input: ClusterStr + "rename the lun " + rn("lundoc") + " in volume " + rn("doc") + " on the " + rn("marketing") + " svm to " + rn("lundocnew"), + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/storage/luns?name=/vol/" + rn("doc") + "/" + rn("lundocnew") + "&svm.name=" + rn("marketing"), validationFunc: createObject}, + }, + { + name: "Update lun state", + input: ClusterStr + "disable the lun " + rn("lundocnew") + " in volume " + rn("doc") + " on the " + rn("marketing") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{}, + }, + { + name: "Clean LUN", + input: ClusterStr + "delete lun " + rn("lundocnew") + " 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("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: "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}, + }, + } + + 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 00fc01f..85b4c62 100644 --- a/ontap/ontap.go +++ b/ontap/ontap.go @@ -202,6 +202,18 @@ type SVM struct { Name string `json:"name" jsonschema:"svm name"` } +type LUNSpace struct { + Size int64 `json:"size,omitempty" jsonschema:"size of the LUN"` +} + +type LUN struct { + SVM NameAndUUID `json:"svm,omitzero" jsonschema:"svm name"` + Name string `json:"name,omitempty" jsonschema:"LUN name"` + Space LUNSpace `json:"space,omitzero" jsonschema:"LUN space detail"` + OsType string `json:"os_type,omitempty" jsonschema:"os type of LUN"` + Enabled *bool `json:"enabled,omitempty" jsonschema:"LUN admin state"` +} + type Qtree struct { SVM NameAndUUID `json:"svm,omitzero" jsonschema:"svm name"` Volume NameAndUUID `json:"volume,omitzero" jsonschema:"volume name"` diff --git a/rest/lun.go b/rest/lun.go new file mode 100644 index 0000000..a584b5f --- /dev/null +++ b/rest/lun.go @@ -0,0 +1,104 @@ +package rest + +import ( + "context" + "fmt" + "github.com/netapp/ontap-mcp/ontap" + "net/http" + "net/url" + "strconv" +) + +func (c *Client) CreateLUN(ctx context.Context, lun ontap.LUN) error { + var ( + statusCode int + ) + responseHeaders := http.Header{} + + builder := c.baseRequestBuilder(`/api/storage/luns`, &statusCode, responseHeaders). + BodyJSON(lun) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.checkStatus(statusCode) +} + +func (c *Client) UpdateLUN(ctx context.Context, svmName, lunPath string, lun ontap.LUN) error { + var ( + statusCode int + lunData ontap.GetData + ) + responseHeaders := http.Header{} + + params := url.Values{} + params.Set("name", lunPath) + params.Set("svm.name", svmName) + params.Set("fields", "uuid") + + builder := c.baseRequestBuilder(`/api/storage/luns`, &statusCode, responseHeaders). + Params(params). + ToJSON(&lunData) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + if lunData.NumRecords == 0 { + return fmt.Errorf("failed to get lun=%s on svm=%s because it does not exist", lunPath, svmName) + } + if lunData.NumRecords != 1 { + return fmt.Errorf("failed to get lun=%s on svm=%s because there are %d matching records", lunPath, svmName, lunData.NumRecords) + } + + builder2 := c.baseRequestBuilder(`/api/storage/luns/`+lunData.Records[0].UUID, &statusCode, responseHeaders). + Patch(). + BodyJSON(lun) + + if err := c.buildAndExecuteRequest(ctx, builder2); err != nil { + return err + } + + return c.checkStatus(statusCode) +} + +func (c *Client) DeleteLUN(ctx context.Context, svmName, lunPath string, allowDeleteWhileMapped bool) error { + var ( + statusCode int + lunData ontap.GetData + ) + responseHeaders := http.Header{} + + params := url.Values{} + params.Set("name", lunPath) + params.Set("svm.name", svmName) + params.Set("fields", "uuid") + + builder := c.baseRequestBuilder(`/api/storage/luns`, &statusCode, responseHeaders). + Params(params). + ToJSON(&lunData) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + if lunData.NumRecords == 0 { + return fmt.Errorf("failed to get lun=%s on svm=%s because it does not exist", lunPath, svmName) + } + if lunData.NumRecords != 1 { + return fmt.Errorf("failed to get lun=%s on svm=%s because there are %d matching records", lunPath, svmName, lunData.NumRecords) + } + + deleteParams := url.Values{} + deleteParams.Set("allow_delete_while_mapped", strconv.FormatBool(allowDeleteWhileMapped)) + builder2 := c.baseRequestBuilder(`/api/storage/luns/`+lunData.Records[0].UUID, &statusCode, responseHeaders). + Params(deleteParams). + Delete() + + if err := c.buildAndExecuteRequest(ctx, builder2); err != nil { + return err + } + + return c.checkStatus(statusCode) +} diff --git a/server/lun.go b/server/lun.go new file mode 100644 index 0000000..1a8797a --- /dev/null +++ b/server/lun.go @@ -0,0 +1,182 @@ +package server + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/netapp/ontap-mcp/ontap" + "github.com/netapp/ontap-mcp/tool" +) + +func lunPath(volume, name string) string { + return "/vol/" + volume + "/" + name +} + +func (a *App) CreateLUN(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.LUNCreate) (*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) + + lunCreate, err := newCreateLUN(parameters) + if err != nil { + return nil, nil, err + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + + if err := client.CreateLUN(ctx, lunCreate); err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "LUN created successfully"}, + }, + }, nil, nil +} + +func (a *App) UpdateLUN(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.LUN) (*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) + + lunUpdate, err := newUpdateLUN(parameters) + if err != nil { + return nil, nil, err + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + + if err := client.UpdateLUN(ctx, parameters.SVM, lunPath(parameters.Volume, parameters.Name), lunUpdate); err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "LUN updated successfully"}, + }, + }, nil, nil +} + +func (a *App) DeleteLUN(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.LUN) (*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 := newDeleteLUN(parameters); err != nil { + return nil, nil, err + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + + if err := client.DeleteLUN(ctx, parameters.SVM, lunPath(parameters.Volume, parameters.Name), parameters.AllowDeleteWhileMapped); err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "LUN deleted successfully"}, + }, + }, nil, nil +} + +// newCreateLUN validates the customer provided arguments and converts them into +// the corresponding ONTAP object ready to use via the REST API +func newCreateLUN(in tool.LUNCreate) (ontap.LUN, error) { + out := ontap.LUN{} + if in.SVM == "" { + return out, errors.New("SVM name is required") + } + if in.Volume == "" { + return out, errors.New("volume name is required") + } + if in.Name == "" { + return out, errors.New("LUN name is required") + } + if in.Size == "" { + return out, errors.New("LUN size is required") + } + if in.OsType == "" { + return out, errors.New("OS type is required") + } + + size, err := parseSize(in.Size) + if err != nil { + return out, fmt.Errorf("invalid size: %w", err) + } + + out.SVM = ontap.NameAndUUID{Name: in.SVM} + out.Name = lunPath(in.Volume, in.Name) + out.Space = ontap.LUNSpace{Size: size} + out.OsType = in.OsType + return out, nil +} + +// newUpdateLUN validates the customer provided arguments and converts them into +// the corresponding ONTAP object ready to use via the REST API +func newUpdateLUN(in tool.LUN) (ontap.LUN, error) { + out := ontap.LUN{} + if in.SVM == "" { + return out, errors.New("SVM name is required") + } + if in.Volume == "" { + return out, errors.New("volume name is required") + } + if in.Name == "" { + return out, errors.New("LUN name is required") + } + if in.NewName == "" && in.Size == "" && in.Enabled == "" { + return out, errors.New("at least one of new_lun_name, size, or enabled must be provided") + } + + if in.NewName != "" { + out.Name = lunPath(in.Volume, in.NewName) + } + + if in.Size != "" { + size, err := parseSize(in.Size) + if err != nil { + return out, fmt.Errorf("invalid size: %w", err) + } + out.Space = ontap.LUNSpace{Size: size} + } + + if in.Enabled != "" { + enabled, err := strconv.ParseBool(in.Enabled) + if err != nil { + return out, fmt.Errorf("invalid enabled value %q: must be 'true' or 'false'", in.Enabled) + } + out.Enabled = &enabled + } + + return out, nil +} + +// newDeleteLUN validates the customer provided arguments for a LUN delete operation +func newDeleteLUN(in tool.LUN) error { + if in.SVM == "" { + return errors.New("SVM name is required") + } + if in.Volume == "" { + return errors.New("volume name is required") + } + if in.Name == "" { + return errors.New("LUN name is required") + } + return nil +} diff --git a/server/server.go b/server/server.go index 0109839..a44939b 100644 --- a/server/server.go +++ b/server/server.go @@ -142,6 +142,11 @@ func (a *App) createMCPServer() *mcp.Server { addTool(a, server, "update_iscsi_service", descriptions.UpdateIscsiService, updateAnnotation, a.UpdateIscsiService) addTool(a, server, "delete_iscsi_service", descriptions.DeleteIscsiService, deleteAnnotation, a.DeleteIscsiService) + // operation on LUN object + addTool(a, server, "create_lun", descriptions.CreateLUN, createAnnotation, a.CreateLUN) + addTool(a, server, "update_lun", descriptions.UpdateLUN, updateAnnotation, a.UpdateLUN) + addTool(a, server, "delete_lun", descriptions.DeleteLUN, deleteAnnotation, a.DeleteLUN) + // operation on Network Interface object addTool(a, server, "create_network_ip_interface", descriptions.CreateNetworkIPInterface, createAnnotation, a.CreateNetworkIPInterface) addTool(a, server, "update_network_ip_interface", descriptions.UpdateNetworkIPInterface, updateAnnotation, a.UpdateNetworkIPInterface) diff --git a/tool/tool.go b/tool/tool.go index 9c85fc1..55e5496 100644 --- a/tool/tool.go +++ b/tool/tool.go @@ -109,6 +109,26 @@ type Qtree struct { NewName string `json:"new_name,omitzero" jsonschema:"new qtree name"` } +type LUNCreate struct { + Cluster string `json:"cluster_name" jsonschema:"cluster name"` + SVM string `json:"svm_name" jsonschema:"SVM name"` + Volume string `json:"volume_name" jsonschema:"volume name where the LUN resides"` + Name string `json:"lun_name" jsonschema:"LUN name"` + Size string `json:"size" jsonschema:"size of the LUN (e.g., '10GB', '1TB')"` + OsType string `json:"os_type" jsonschema:"OS type (e.g., linux, windows, windows_2008, windows_gpt, aix, esxi, hyper_v, solaris, vmware, xen)"` +} + +type LUN struct { + Cluster string `json:"cluster_name" jsonschema:"cluster name"` + SVM string `json:"svm_name" jsonschema:"SVM name"` + Volume string `json:"volume_name" jsonschema:"volume name where the LUN resides"` + Name string `json:"lun_name" jsonschema:"LUN name"` + NewName string `json:"new_lun_name,omitzero" jsonschema:"new LUN name for rename operation"` + Size string `json:"size,omitzero" jsonschema:"size of the LUN (e.g., '10GB', '1TB')"` + Enabled string `json:"enabled,omitzero" jsonschema:"LUN state: 'true' to enable (online) or 'false' to disable (offline) the LUN"` + AllowDeleteWhileMapped bool `json:"allow_delete_while_mapped,omitzero" jsonschema:"Allows deletion of a mapped LUN. This parameter should be used with caution"` +} + type NVMeService struct { Cluster string `json:"cluster_name" jsonschema:"cluster name"` SVM string `json:"svm_name" jsonschema:"SVM name"`