diff --git a/descriptions/descriptions.go b/descriptions/descriptions.go index ced0a0f..5e946da 100644 --- a/descriptions/descriptions.go +++ b/descriptions/descriptions.go @@ -128,6 +128,14 @@ const RemoveIGroupInitiator = `Remove an initiator from an igroup on a cluster b 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 CreateSnapMirror = `Create a SnapMirror relationship on a cluster by cluster name.` +const UpdateSnapMirror = `Update a SnapMirror relationship on a cluster by cluster name. Supports updating the policy and transfer schedule of an existing relationship identified by its destination SVM and volume.` +const DeleteSnapMirror = `Delete a SnapMirror relationship on a cluster by cluster name. Identifies the relationship by destination SVM and volume names.` +const InitializeSnapMirror = `Initialize a SnapMirror relationship on a cluster by cluster name. Starts the baseline transfer from source to destination. Identifies the relationship by destination SVM and volume names.` +const UpdateSnapMirrorTransfer = `Trigger a SnapMirror update transfer on a cluster by cluster name. Transfers new data from source to destination to bring the relationship up to date. Identifies the relationship by destination SVM and volume names.` +const BreakSnapMirror = `Break a SnapMirror relationship on a cluster by cluster name. Sets the relationship state to broken_off, making the destination volume read-write. Identifies the relationship by destination SVM and volume names.` +const ResyncSnapMirror = `Resync a SnapMirror relationship on a cluster by cluster name. Re-establishes replication by setting the state back to snapmirrored. Identifies the relationship by destination SVM and volume names.` + 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.*"). @@ -141,6 +149,7 @@ Pass cluster_name to automatically filter out fields and filters not available i const CreateSVM = `Create an SVM on a cluster by cluster name.` const UpdateSVM = `Update an SVM on a cluster by cluster name.` const DeleteSVM = `Delete an SVM on a cluster by cluster name.` +const DeleteSVMPeer = `Delete an SVM peer on a cluster by cluster name and local SVM name. The peer relationship UUID is looked up internally using the svm.name filter.` const OntapGet = `Execute a read-only GET against any ONTAP REST endpoint. diff --git a/docs/examples.md b/docs/examples.md index d2cd471..63c5d8e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -379,6 +379,38 @@ Below are example questions that work well with the ONTAP MCP Server: --- +### Manage SnapMirror Relationships + +**Create a SnapMirror relationship** + +- On the umeng-aff300-05-06 cluster, create a snapmirror relationship from source svm srsvm and source volume srvol to destination svm dtsvm and destination volume dtvol with policy name XDPDefault + +Expected Response: SnapMirror relationship created successfully + +**Update a SnapMirror relationship** + +- On the umeng-aff300-05-06 cluster, update a snapmirror relationship of destination svm dtsvm and destination volume dtvol with transfer schedule name to hourly + +Expected Response: SnapMirror relationship updated successfully + +**Update a SnapMirror relationship state** + +- On the umeng-aff300-05-06 cluster, break a snapmirror relationship of destination svm dtsvm and destination volume dtvol + +Expected Response: SnapMirror relationship broken successfully + +- On the umeng-aff300-05-06 cluster, resync a snapmirror relationship of destination svm dtsvm and destination volume dtvol + +Expected Response: SnapMirror relationship resynced successfully + +**Delete a SnapMirror relationship** + +- On the umeng-aff300-05-06 cluster, delete a snapmirror relationship of destination svm dtsvm and destination volume dtvol + +Expected Response: SnapMirror relationship deleted successfully + +--- + ## MCP Clients Common MCP clients that work with ONTAP MCP Server: diff --git a/integration/test/snapmirror_test.go b/integration/test/snapmirror_test.go new file mode 100644 index 0000000..b017b43 --- /dev/null +++ b/integration/test/snapmirror_test.go @@ -0,0 +1,223 @@ +package main + +import ( + "context" + "crypto/tls" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/carlmjohnson/requests" + "github.com/netapp/ontap-mcp/config" +) + +func TestSnapMirror(t *testing.T) { + SkipIfMissing(t, CheckTools) + + tests := []struct { + name string + input string + expectedOntapErr string + verifyAPI ontapVerifier + }{ + { + name: "Clean source SVM", + input: ClusterStr + "delete " + rn("srsvm") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("srsvm"), validationFunc: deleteObject}, + }, + { + name: "Clean destination SVM", + input: ClusterStr + "delete " + rn("dtsvm") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("dtsvm"), validationFunc: deleteObject}, + }, + { + name: "Create source SVM", + input: ClusterStr + "create " + rn("srsvm") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("srsvm"), validationFunc: createObject}, + }, + { + name: "Create destination SVM", + input: ClusterStr + "create " + rn("dtsvm") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("dtsvm"), validationFunc: createObject}, + }, + { + name: "Clean source volume", + input: ClusterStr + "delete volume " + rn("srvol") + " in " + rn("srsvm") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("srvol") + "&svm.name=" + rn("srsvm"), validationFunc: deleteObject}, + }, + { + name: "Clean destination volume", + input: ClusterStr + "delete volume " + rn("dtvol") + " in " + rn("dtsvm") + " svm", + expectedOntapErr: "because it does not exist", + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("dtvol") + "&svm.name=" + rn("dtsvm"), validationFunc: deleteObject}, + }, + { + name: "Create source volume", + input: ClusterStr + "create a 100MB volume named " + rn("srvol") + " on the " + rn("srsvm") + " svm and the harvest_vc_aggr aggregate", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("srvol") + "&svm.name=" + rn("srsvm"), validationFunc: createObject}, + }, + { + name: "Create destination volume", + input: ClusterStr + "create a 100MB volume named " + rn("dtvol") + " on the " + rn("dtsvm") + " svm and the harvest_vc_aggr aggregate with dp type", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("dtvol") + "&svm.name=" + rn("dtsvm"), validationFunc: createObject}, + }, + { + name: "Create SnapMirror relationship", + input: ClusterStr + "create a snapmirror relationship from source svm " + rn("srsvm") + " and source volume " + rn("srvol") + " to destination svm " + rn("dtsvm") + " and destination volume " + rn("dtvol") + " with policy name Asynchronous", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/snapmirror/relationships?destination.path=" + rn("dtsvm") + ":" + rn("dtvol") + "&fields=state,policy.name", validationFunc: verifySnapMirror(true, "Asynchronous", "uninitialized")}, + }, + { + name: "Update SnapMirror relationship", + input: ClusterStr + "update a snapmirror relationship of destination svm " + rn("dtsvm") + " and destination volume " + rn("dtvol") + " with transfer schedule name to hourly", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/snapmirror/relationships?destination.path=" + rn("dtsvm") + ":" + rn("dtvol") + "&fields=state,policy.name", validationFunc: verifySnapMirror(true, "Asynchronous", "uninitialized")}, + }, + { + name: "Update SnapMirror relationship 2", + input: ClusterStr + "update a snapmirror relationship of destination svm " + rn("dtsvm") + " and destination volume " + rn("dtvol") + " with policy name MirrorAndVault", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/snapmirror/relationships?destination.path=" + rn("dtsvm") + ":" + rn("dtvol") + "&fields=state,policy.name", validationFunc: verifySnapMirror(true, "MirrorAndVault", "uninitialized")}, + }, + { + name: "Initialize SnapMirror relationship", + input: ClusterStr + "initialize a snapmirror relationship of destination svm " + rn("dtsvm") + " and destination volume " + rn("dtvol"), + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/snapmirror/relationships?destination.path=" + rn("dtsvm") + ":" + rn("dtvol") + "&fields=state,policy.name", validationFunc: verifySnapMirror(true, "MirrorAndVault", "snapmirrored")}, + }, + { + name: "Break SnapMirror relationship", + input: ClusterStr + "break a snapmirror relationship of destination svm " + rn("dtsvm") + " and destination volume " + rn("dtvol"), + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/snapmirror/relationships?destination.path=" + rn("dtsvm") + ":" + rn("dtvol") + "&fields=state,policy.name", validationFunc: verifySnapMirror(true, "MirrorAndVault", "broken_off")}, + }, + { + name: "Resync SnapMirror relationship", + input: ClusterStr + "resync a snapmirror relationship of destination svm " + rn("dtsvm") + " and destination volume " + rn("dtvol"), + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/snapmirror/relationships?destination.path=" + rn("dtsvm") + ":" + rn("dtvol") + "&fields=state,policy.name", validationFunc: verifySnapMirror(true, "MirrorAndVault", "snapmirrored")}, + }, + { + name: "Delete SnapMirror relationship", + input: ClusterStr + "delete a snapmirror relationship of destination svm " + rn("dtsvm") + " and destination volume " + rn("dtvol"), + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/snapmirror/relationships?destination.path=" + rn("dtsvm") + ":" + rn("dtvol") + "&fields=state,policy.name", validationFunc: verifySnapMirror(false, "", "")}, + }, + { + name: "Clean source volume", + input: ClusterStr + "delete volume " + rn("srvol") + " in " + rn("srsvm") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("srvol") + "&svm.name=" + rn("srsvm"), validationFunc: deleteObject}, + }, + { + name: "Clean destination volume", + input: ClusterStr + "delete volume " + rn("dtvol") + " in " + rn("dtsvm") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("dtvol") + "&svm.name=" + rn("dtsvm"), validationFunc: deleteObject}, + }, + { + name: "Clean SVM peer", + input: ClusterStr + "delete svm peer of " + rn("srsvm") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/svm/peers?svm.name=" + rn("srsvm"), validationFunc: deleteObject}, + }, + { + name: "Clean source SVM", + input: ClusterStr + "delete " + rn("srsvm") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("srsvm"), validationFunc: deleteObject}, + }, + { + name: "Clean destination SVM", + input: ClusterStr + "delete " + rn("dtsvm") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("dtsvm"), 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) + } + }) + } +} + +func verifySnapMirror(exist bool, expectedPolicyName string, expectedState 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 NameData struct { + Name string `json:"name,omitempty"` + } + type SnapMirrorRelationship struct { + Policy NameData `json:"policy"` + State string `json:"state"` + } + type response struct { + NumRecords int `json:"num_records"` + Records []SnapMirrorRelationship `json:"records"` + } + + var data response + err := requests.URL("https://"+poller.Addr+"/"+api). + BasicAuth(poller.Username, poller.Password). + Client(client). + ToJSON(&data). + Fetch(context.Background()) + if err != nil { + t.Errorf("verifySnapMirror: request failed: %v", err) + return false + } + + if exist { + if data.NumRecords != 1 { + t.Errorf("verifySnapMirror: expected 1 record, got %d", data.NumRecords) + return false + } + + gotSnapMirror := data.Records[0] + if gotSnapMirror.Policy.Name != expectedPolicyName { + t.Errorf("verifySnapMirror: expected policy name %s, got %s", expectedPolicyName, gotSnapMirror.Policy.Name) + return false + } + if gotSnapMirror.State != expectedState { + t.Errorf("verifySnapMirror: expected state %s, got %s", expectedState, gotSnapMirror.State) + return false + } + return true + } + + if !exist && data.NumRecords > 0 { + t.Errorf("verifySnapMirror: expected 0 record, got %d", data.NumRecords) + return false + } + return true + } +} diff --git a/integration/test/tools_test.go b/integration/test/tools_test.go index 4355547..f78a21c 100644 --- a/integration/test/tools_test.go +++ b/integration/test/tools_test.go @@ -374,15 +374,3 @@ func deleteObject(t *testing.T, api string, poller *config.Poller, client *http. } return true } - -func listObject(t *testing.T, api string, poller *config.Poller, client *http.Client) bool { - err := requests.URL("https://"+poller.Addr+"/"+api). - BasicAuth(poller.Username, poller.Password). - Client(client). - Fetch(context.Background()) - if err != nil { - t.Errorf("listObject: request failed: %v", err) - return false - } - return true -} diff --git a/integration/test/volume_test.go b/integration/test/volume_test.go index e9cb4c0..60051a2 100644 --- a/integration/test/volume_test.go +++ b/integration/test/volume_test.go @@ -21,88 +21,94 @@ func TestVolume(t *testing.T) { verifyAPI ontapVerifier }{ { - name: "List all volumes in one cluster in one svm with given fields", - input: ClusterStr + "for every volume on the marketing svm, show me the name, used size, available size, and snapshot policy", + 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/storage/volumes?svm=marketing", validationFunc: listObject}, + verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("marketing"), validationFunc: createObject}, }, { name: "Clean volume", - input: ClusterStr + "delete volume " + rn("docs") + " in marketing svm", + input: ClusterStr + "delete volume " + rn("docs") + " in " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", - verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docs") + "&svm=marketing", validationFunc: deleteObject}, + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docs") + "&svm=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Clean volume", - input: ClusterStr + "delete volume " + rn("docsnew") + " in marketing svm", + input: ClusterStr + "delete volume " + rn("docsnew") + " in " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", - verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docsnew") + "&svm=marketing", validationFunc: deleteObject}, + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docsnew") + "&svm=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Create volume", - input: ClusterStr + "create a 20MB volume named " + rn("docs") + " on the marketing svm and the harvest_vc_aggr aggregate", + input: ClusterStr + "create a 20MB volume named " + rn("docs") + " on the " + rn("marketing") + " svm and the harvest_vc_aggr aggregate", expectedOntapErr: "", - verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docs") + "&svm=marketing", validationFunc: createObject}, + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docs") + "&svm=" + rn("marketing"), validationFunc: createObject}, }, { name: "Update volume size", - input: ClusterStr + "resize the " + rn("docs") + " volume on the marketing svm to 25MB", + input: ClusterStr + "resize the " + rn("docs") + " volume on the " + rn("marketing") + " svm to 25MB", expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, { name: "Update volume size", - input: ClusterStr + "update junction path of the " + rn("docs") + " volume on the marketing svm to empty", + input: ClusterStr + "update junction path of the " + rn("docs") + " volume on the " + rn("marketing") + " svm to empty", expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, { name: "Enable volume autogrowth", - input: ClusterStr + "enable autogrowth and grow percent to 62 on the " + rn("docs") + " volume in the marketing svm", + input: ClusterStr + "enable autogrowth and grow percent to 62 on the " + rn("docs") + " volume in the " + rn("marketing") + " svm", expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, { name: "Rename volume", - input: ClusterStr + "rename the " + rn("docs") + " volume on the marketing svm to " + rn("docsnew"), + input: ClusterStr + "rename the " + rn("docs") + " volume on the " + rn("marketing") + " svm to " + rn("docsnew"), expectedOntapErr: "", - verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docsnew") + "&svm=marketing", validationFunc: createObject}, + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docsnew") + "&svm=" + rn("marketing"), validationFunc: createObject}, }, { name: "Update volume state", - input: ClusterStr + "update state of the " + rn("docsnew") + " volume on the marketing svm to offline", + input: ClusterStr + "update state of the " + rn("docsnew") + " volume on the " + rn("marketing") + " svm to offline", expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, { name: "Update volume state", - input: ClusterStr + "update state of the " + rn("docsnew") + " volume on the marketing svm to online", + input: ClusterStr + "update state of the " + rn("docsnew") + " volume on the " + rn("marketing") + " svm to online", expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, { name: "Update volume junction path", - input: ClusterStr + "update junction path of the " + rn("docsnew") + " volume on the marketing svm to /" + rn("docsnew"), + input: ClusterStr + "update junction path of the " + rn("docsnew") + " volume on the " + rn("marketing") + " svm to /" + rn("docsnew"), expectedOntapErr: "", verifyAPI: ontapVerifier{}, }, - { - name: "List one volume in one cluster in one svm with specific field", - input: ClusterStr + "for " + rn("docsnew") + " volume on the marketing svm, show me the name and junction path", - expectedOntapErr: "", - verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docsnew") + "&svm=marketing", validationFunc: listObject}, - }, { name: "Clean volume", - input: ClusterStr + "delete volume " + rn("docs") + " in marketing svm", + input: ClusterStr + "delete volume " + rn("docs") + " in " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", - verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docs") + "&svm=marketing", validationFunc: deleteObject}, + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docs") + "&svm=" + rn("marketing"), validationFunc: deleteObject}, }, { name: "Clean volume", - input: ClusterStr + "delete volume " + rn("docsnew") + " in marketing svm", + input: ClusterStr + "delete volume " + rn("docsnew") + " in " + rn("marketing") + " svm", expectedOntapErr: "because it does not exist", - verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docsnew") + "&svm=marketing", validationFunc: deleteObject}, + verifyAPI: ontapVerifier{api: "api/storage/volumes?name=" + rn("docsnew") + "&svm=" + rn("marketing"), validationFunc: deleteObject}, + }, + { + name: "Clean SVM", + input: ClusterStr + "delete " + rn("marketing") + " svm", + expectedOntapErr: "", + verifyAPI: ontapVerifier{api: "api/svm/svms?name=" + rn("marketing"), validationFunc: deleteObject}, }, } diff --git a/ontap/ontap.go b/ontap/ontap.go index ea6c861..01f8129 100644 --- a/ontap/ontap.go +++ b/ontap/ontap.go @@ -57,8 +57,8 @@ type GetData struct { Clients []ClientData `json:"clients,omitzero"` Nas NAS `json:"nas,omitzero"` Schedule NameAndUUID `json:"schedule,omitzero"` - Lun NameAndUUID `json:"lun,omitzero"` - IGroup NameAndUUID `json:"igroup,omitzero"` + Lun NameAndUUID `json:"lun,omitzero"` + IGroup NameAndUUID `json:"igroup,omitzero"` } `json:"records"` NumRecords int `json:"num_records"` } @@ -117,6 +117,7 @@ type Volume struct { Nas NAS `json:"nas,omitzero"` Autosize Autosize `json:"autosize,omitzero"` QoS VolumeQoS `json:"qos,omitzero"` + Type string `json:"type,omitzero"` // enum: rw, dp, ls } type NameAndUUID struct { @@ -353,6 +354,18 @@ type LunMap struct { IGroup NameAndUUID `json:"igroup,omitzero"` } +type SnapMirrorEndpoint struct { + Path string `json:"path,omitzero" jsonschema:"SnapMirror endpoint path in the format 'svm:volume'"` +} + +type SnapMirrorRelationship struct { + Source SnapMirrorEndpoint `json:"source,omitzero"` + Destination SnapMirrorEndpoint `json:"destination,omitzero"` + Policy NameAndUUID `json:"policy,omitzero"` + TransferSchedule NameAndUUID `json:"transfer_schedule,omitzero"` + State string `json:"state,omitzero"` // enum: broken_off, paused, snapmirrored, uninitialized, in_sync, out_of_sync, synchronizing, expanding +} + const ( ASAr2 = "asar2" CDOT = "cdot" diff --git a/rest/snapmirror.go b/rest/snapmirror.go new file mode 100644 index 0000000..90355df --- /dev/null +++ b/rest/snapmirror.go @@ -0,0 +1,118 @@ +package rest + +import ( + "bytes" + "context" + "fmt" + "net/url" + + "github.com/netapp/ontap-mcp/ontap" +) + +// getSnapMirrorUUID returns the UUID of a SnapMirror relationship identified by its destination path. +func (c *Client) getSnapMirrorUUID(ctx context.Context, destSVM, destVolume string) (string, error) { + var data ontap.GetData + + params := url.Values{} + params.Set("destination.path", fmt.Sprintf("%s:%s", destSVM, destVolume)) + params.Set("fields", "uuid") + + builder := c.baseRequestBuilder(`/api/snapmirror/relationships`, nil, nil). + Params(params). + ToJSON(&data) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return "", err + } + + if data.NumRecords == 0 { + return "", fmt.Errorf("SnapMirror relationship with destination %s:%s not found", destSVM, destVolume) + } + if data.NumRecords != 1 { + return "", fmt.Errorf("found %d SnapMirror relationships with destination %s:%s, expected 1", data.NumRecords, destSVM, destVolume) + } + + return data.Records[0].UUID, nil +} + +func (c *Client) CreateSnapMirror(ctx context.Context, rel ontap.SnapMirrorRelationship) error { + var ( + buf bytes.Buffer + statusCode int + ) + + builder := c.baseRequestBuilder(`/api/snapmirror/relationships`, &statusCode, nil). + BodyJSON(rel). + ToBytesBuffer(&buf) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.handleJob(ctx, statusCode, buf) +} + +func (c *Client) UpdateSnapMirror(ctx context.Context, destSVM, destVolume string, rel ontap.SnapMirrorRelationship) error { + var ( + buf bytes.Buffer + statusCode int + ) + + uuid, err := c.getSnapMirrorUUID(ctx, destSVM, destVolume) + if err != nil { + return err + } + + builder := c.baseRequestBuilder(`/api/snapmirror/relationships/`+uuid, &statusCode, nil). + Patch(). + BodyJSON(rel). + ToBytesBuffer(&buf) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.handleJob(ctx, statusCode, buf) +} + +func (c *Client) DeleteSnapMirror(ctx context.Context, destSVM, destVolume string) error { + var ( + buf bytes.Buffer + statusCode int + ) + + uuid, err := c.getSnapMirrorUUID(ctx, destSVM, destVolume) + if err != nil { + return err + } + + builder := c.baseRequestBuilder(`/api/snapmirror/relationships/`+uuid, &statusCode, nil). + Delete(). + ToBytesBuffer(&buf) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.handleJob(ctx, statusCode, buf) +} + +func (c *Client) UpdateSnapMirrorTransfer(ctx context.Context, destSVM, destVolume string) error { + var ( + statusCode int + ) + + uuid, err := c.getSnapMirrorUUID(ctx, destSVM, destVolume) + if err != nil { + return err + } + + builder := c.baseRequestBuilder(`/api/snapmirror/relationships/`+uuid+`/transfers`, &statusCode, nil). + BodyJSON(struct{}{}) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + return c.checkStatus(statusCode) +} diff --git a/rest/svm.go b/rest/svm.go index bf87e5d..79450a1 100644 --- a/rest/svm.go +++ b/rest/svm.go @@ -51,7 +51,7 @@ func (c *Client) UpdateSVM(ctx context.Context, svm ontap.SVM, svmName string) e return fmt.Errorf("failed to get details of SVM %s because it does not exist", svmName) } if svmData.NumRecords != 1 { - return fmt.Errorf("failed to get detail of SVM %s because there are %d matching records", + return fmt.Errorf("failed to get details of SVM %s because there are %d matching records", svmName, svmData.NumRecords) } @@ -91,7 +91,7 @@ func (c *Client) DeleteSVM(ctx context.Context, svmName string) error { return fmt.Errorf("failed to get details of SVM %s because it does not exist", svmName) } if svmData.NumRecords != 1 { - return fmt.Errorf("failed to get detail of SVM %s because there are %d matching records", + return fmt.Errorf("failed to get details of SVM %s because there are %d matching records", svmName, svmData.NumRecords) } @@ -105,3 +105,42 @@ func (c *Client) DeleteSVM(ctx context.Context, svmName string) error { return c.handleJob(ctx, statusCode, buf) } + +func (c *Client) DeleteSVMPeer(ctx context.Context, svmName string) error { + var ( + buf bytes.Buffer + statusCode int + svmPeerData ontap.GetData + ) + responseHeaders := http.Header{} + + params := url.Values{} + params.Set("svm.name", svmName) + params.Set("fields", "uuid") + + builder := c.baseRequestBuilder(`/api/svm/peers`, &statusCode, responseHeaders). + Params(params). + ToJSON(&svmPeerData) + + if err := c.buildAndExecuteRequest(ctx, builder); err != nil { + return err + } + + if svmPeerData.NumRecords == 0 { + return fmt.Errorf("failed to get details of SVM peer %s because it does not exist", svmName) + } + if svmPeerData.NumRecords != 1 { + return fmt.Errorf("failed to get details of SVM peer %s because there are %d matching records", + svmName, svmPeerData.NumRecords) + } + + builder2 := c.baseRequestBuilder(`/api/svm/peers/`+svmPeerData.Records[0].UUID, &statusCode, responseHeaders). + Delete(). + ToBytesBuffer(&buf) + + if err := c.buildAndExecuteRequest(ctx, builder2); err != nil { + return err + } + + return c.handleJob(ctx, statusCode, buf) +} diff --git a/server/server.go b/server/server.go index 1c44842..d94ad7a 100644 --- a/server/server.go +++ b/server/server.go @@ -131,6 +131,8 @@ func (a *App) createMCPServer() *mcp.Server { addTool(a, server, "create_svm", descriptions.CreateSVM, createAnnotation, a.CreateSVM) addTool(a, server, "update_svm", descriptions.UpdateSVM, updateAnnotation, a.UpdateSVM) addTool(a, server, "delete_svm", descriptions.DeleteSVM, deleteAnnotation, a.DeleteSVM) + // operation on SVM peer object + addTool(a, server, "delete_svm_peer", descriptions.DeleteSVMPeer, deleteAnnotation, a.DeleteSVMPeer) // operation on CIFS share object addTool(a, server, "create_cifs_share", descriptions.CreateCIFSShare, createAnnotation, a.CreateCIFSShare) @@ -201,6 +203,15 @@ func (a *App) createMCPServer() *mcp.Server { addTool(a, server, "create_lun_map", descriptions.CreateLunMap, createAnnotation, a.CreateLunMap) addTool(a, server, "delete_lun_map", descriptions.DeleteLunMap, deleteAnnotation, a.DeleteLunMap) + // operation on SnapMirror relationship object + addTool(a, server, "create_snapmirror", descriptions.CreateSnapMirror, createAnnotation, a.CreateSnapMirror) + addTool(a, server, "update_snapmirror", descriptions.UpdateSnapMirror, updateAnnotation, a.UpdateSnapMirror) + addTool(a, server, "delete_snapmirror", descriptions.DeleteSnapMirror, deleteAnnotation, a.DeleteSnapMirror) + addTool(a, server, "initialize_snapmirror", descriptions.InitializeSnapMirror, updateAnnotation, a.InitializeSnapMirror) + addTool(a, server, "update_snapmirror_transfer", descriptions.UpdateSnapMirrorTransfer, createAnnotation, a.UpdateSnapMirrorTransfer) + addTool(a, server, "break_snapmirror", descriptions.BreakSnapMirror, updateAnnotation, a.BreakSnapMirror) + addTool(a, server, "resync_snapmirror", descriptions.ResyncSnapMirror, updateAnnotation, a.ResyncSnapMirror) + 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/server/snapmirror.go b/server/snapmirror.go new file mode 100644 index 0000000..a6cebe8 --- /dev/null +++ b/server/snapmirror.go @@ -0,0 +1,212 @@ +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) CreateSnapMirror(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.SnapMirrorCreate) (*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) + + rel, err := newCreateSnapMirror(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.CreateSnapMirror(ctx, rel); err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "SnapMirror relationship created successfully"}, + }, + }, nil, nil +} + +func (a *App) UpdateSnapMirror(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.SnapMirror) (*mcp.CallToolResult, any, error) { + rel, err := newUpdateSnapMirror(parameters) + if err != nil { + return nil, nil, err + } + return a.updateSnapMirrorState(ctx, parameters, rel, "SnapMirror relationship updated successfully") +} + +func (a *App) DeleteSnapMirror(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.SnapMirror) (*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 parameters.DestinationSVM == "" { + return nil, nil, errors.New("destination SVM name is required") + } + if parameters.DestinationVolume == "" { + return nil, nil, errors.New("destination volume name is required") + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + + if err := client.DeleteSnapMirror(ctx, parameters.DestinationSVM, parameters.DestinationVolume); err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "SnapMirror relationship deleted successfully"}, + }, + }, nil, nil +} + +func (a *App) InitializeSnapMirror(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.SnapMirror) (*mcp.CallToolResult, any, error) { + if err := validateDestination(parameters); err != nil { + return nil, nil, err + } + + rel := ontap.SnapMirrorRelationship{State: "snapmirrored"} + return a.updateSnapMirrorState(ctx, parameters, rel, "SnapMirror relationship initialized successfully") +} + +func (a *App) UpdateSnapMirrorTransfer(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.SnapMirror) (*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 parameters.DestinationSVM == "" { + return nil, nil, errors.New("destination SVM name is required") + } + if parameters.DestinationVolume == "" { + return nil, nil, errors.New("destination volume name is required") + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + + if err := client.UpdateSnapMirrorTransfer(ctx, parameters.DestinationSVM, parameters.DestinationVolume); err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "SnapMirror transfer updated successfully"}, + }, + }, nil, nil +} + +func (a *App) BreakSnapMirror(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.SnapMirror) (*mcp.CallToolResult, any, error) { + if err := validateDestination(parameters); err != nil { + return nil, nil, err + } + + rel := ontap.SnapMirrorRelationship{State: "broken_off"} + return a.updateSnapMirrorState(ctx, parameters, rel, "SnapMirror relationship broken successfully") +} + +func (a *App) ResyncSnapMirror(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.SnapMirror) (*mcp.CallToolResult, any, error) { + if err := validateDestination(parameters); err != nil { + return nil, nil, err + } + + rel := ontap.SnapMirrorRelationship{State: "snapmirrored"} + return a.updateSnapMirrorState(ctx, parameters, rel, "SnapMirror relationship resynced successfully") +} + +func newCreateSnapMirror(in tool.SnapMirrorCreate) (ontap.SnapMirrorRelationship, error) { + if in.SourceSVM == "" { + return ontap.SnapMirrorRelationship{}, errors.New("source SVM name is required") + } + if in.SourceVolume == "" { + return ontap.SnapMirrorRelationship{}, errors.New("source volume name is required") + } + if in.DestinationSVM == "" { + return ontap.SnapMirrorRelationship{}, errors.New("destination SVM name is required") + } + if in.DestinationVolume == "" { + return ontap.SnapMirrorRelationship{}, errors.New("destination volume name is required") + } + if in.PolicyName == "" { + return ontap.SnapMirrorRelationship{}, errors.New("policy name is required") + } + + return ontap.SnapMirrorRelationship{ + Source: ontap.SnapMirrorEndpoint{Path: fmt.Sprintf("%s:%s", in.SourceSVM, in.SourceVolume)}, + Destination: ontap.SnapMirrorEndpoint{Path: fmt.Sprintf("%s:%s", in.DestinationSVM, in.DestinationVolume)}, + Policy: ontap.NameAndUUID{Name: in.PolicyName}, + }, nil +} + +func newUpdateSnapMirror(in tool.SnapMirror) (ontap.SnapMirrorRelationship, error) { + out := ontap.SnapMirrorRelationship{} + if err := validateDestination(in); err != nil { + return out, err + } + + hasUpdate := false + if in.PolicyName != "" { + out.Policy = ontap.NameAndUUID{Name: in.PolicyName} + hasUpdate = true + } + if in.TransferScheduleName != "" { + out.TransferSchedule = ontap.NameAndUUID{Name: in.TransferScheduleName} + hasUpdate = true + } + if in.State != "" { + out.State = in.State + hasUpdate = true + } + if !hasUpdate { + return out, errors.New("at least one updatable field must be provided: policy_name, transfer_schedule_name or state") + } + return out, nil +} + +func validateDestination(in tool.SnapMirror) error { + if in.DestinationSVM == "" { + return errors.New("destination SVM name is required") + } + if in.DestinationVolume == "" { + return errors.New("destination volume name is required") + } + return nil +} + +func (a *App) updateSnapMirrorState(ctx context.Context, parameters tool.SnapMirror, rel ontap.SnapMirrorRelationship, returnText string) (*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) + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + + if err := client.UpdateSnapMirror(ctx, parameters.DestinationSVM, parameters.DestinationVolume, rel); err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: returnText}, + }, + }, nil, nil +} diff --git a/server/svm.go b/server/svm.go index d32ccc8..be01736 100644 --- a/server/svm.go +++ b/server/svm.go @@ -127,3 +127,30 @@ func newUpdateSVM(in tool.SVM) (ontap.SVM, error) { return out, nil } + +func (a *App) DeleteSVMPeer(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.SVMPeer) (*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 parameters.SVM == "" { + return nil, nil, errors.New("SVM name is required") + } + + client, err := a.getClient(parameters.Cluster) + if err != nil { + return errorResult(err), nil, err + } + + err = client.DeleteSVMPeer(ctx, parameters.SVM) + if err != nil { + return errorResult(err), nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "SVM peer deleted successfully"}, + }, + }, nil, nil +} diff --git a/server/volume.go b/server/volume.go index 4fc320d..8d1a766 100644 --- a/server/volume.go +++ b/server/volume.go @@ -129,6 +129,10 @@ func newCreateVolume(in tool.Volume) (ontap.Volume, error) { out.Size = size } + if in.Type != "" { + out.Type = in.Type + } + if in.ExportPolicy != "" || in.JunctionPath != "" { out.Nas = ontap.NAS{ ExportPolicy: ontap.NASExportPolicy{ diff --git a/tool/tool.go b/tool/tool.go index 7bf6954..6688081 100644 --- a/tool/tool.go +++ b/tool/tool.go @@ -19,6 +19,7 @@ type Volume struct { ExportPolicy string `json:"nas.export_policy.name,omitzero" jsonschema:"nfs export policy name. Will be created if it doesn't exist"` Autosize Autosize `json:"autosize,omitzero" jsonschema:"autosize"` QoS VolumeQoS `json:"qos,omitzero" jsonschema:"QoS settings: use policy_name to assign an existing policy, or max_iops/min_iops/max_mbps/min_mbps for inline limits (mutually exclusive)"` + Type string `json:"type,omitzero" jsonschema:"type of volume (e.g., 'rw', 'dp', 'ls')"` } type VolumeQoS struct { @@ -276,6 +277,23 @@ type LunMap struct { IGroupName string `json:"igroup_name" jsonschema:"igroup name to map the LUN to"` } +type SnapMirrorCreate struct { + Cluster string `json:"cluster_name" jsonschema:"cluster name"` + SourceSVM string `json:"source_svm" jsonschema:"source SVM name"` + SourceVolume string `json:"source_volume" jsonschema:"source volume name"` + DestinationSVM string `json:"destination_svm" jsonschema:"destination SVM name"` + DestinationVolume string `json:"destination_volume" jsonschema:"destination volume name"` + PolicyName string `json:"policy_name" jsonschema:"SnapMirror policy name"` +} +type SnapMirror struct { + Cluster string `json:"cluster_name" jsonschema:"cluster name"` + DestinationSVM string `json:"destination_svm" jsonschema:"destination SVM name"` + DestinationVolume string `json:"destination_volume" jsonschema:"destination volume name"` + PolicyName string `json:"policy_name,omitzero" jsonschema:"SnapMirror policy name"` + TransferScheduleName string `json:"transfer_schedule_name,omitzero" jsonschema:"SnapMirror transfer schedule name"` + State string `json:"state,omitzero" jsonschema:"State of the relationship (e.g., broken_off, paused, snapmirrored, uninitialized, in_sync, out_of_sync, synchronizing, expanding"` +} + 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"` @@ -310,3 +328,8 @@ type SVM struct { State string `json:"state,omitzero" jsonschema:"state of SVM (e.g., starting, running, stopping, stopped, deleting, initializing)"` Comment string `json:"comment,omitzero" jsonschema:"comment"` } + +type SVMPeer struct { + Cluster string `json:"cluster_name" jsonschema:"cluster name"` + SVM string `json:"svm_name" jsonschema:"SVM name"` +}