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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion descriptions/descriptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Create/update/delete operations remain as dedicated typed tools
const ListClusters = `List all ONTAP clusters registered in the server configuration. Returns cluster names with ONTAP version and model (e.g. cdot, asar2, afx).
USE THIS FIRST: Always call this before any other tool to discover valid cluster names.`

const CreateVolume = `Create a volume on a cluster by cluster name.`
const CreateVolume = `Create a volume on a cluster by cluster name. FlexVol is the default: aggregate_name is required on CDOT and must be omitted on AFX. Set style=flexgroup with aggregate_names for a FlexGroup on Unified NAS (AFF/FAS) only. FlexGroup is not supported on classic ASA; AFX omits aggregates; ASA r2 does not use create_volume (provision a storage unit instead).`
const UpdateVolume = `Update volume name, size, state, nfs export policy of volume on a cluster by cluster name.`
const DeleteVolume = `Delete a volume on a cluster by cluster name.`
const ModifyVolume = `Update or delete a volume on a cluster by cluster name.`
Expand Down
7 changes: 7 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,13 @@ Below are example questions that work well with the ONTAP MCP Server:
- On the umeng-aff300-05-06 cluster, create a 20MB volume named docs on the marketing svm and the harvest_vc_aggr aggregate
- <span class="key">Expected Response</span>: Volume "docs" has been created successfully on the umeng-aff300-05-06 cluster with 20MB size on the marketing SVM using the harvest_vc_aggr aggregate.

**Create a FlexGroup Volume**

- On the umeng-aff300-05-06 cluster, create a 400MB FlexGroup volume named fgdocs on the marketing svm using aggregates harvest_vc_aggr1 and harvest_vc_aggr2 with 2 constituents per aggregate and junction path /fgdocs
- <span class="key">Expected Response</span>: Volume created successfully

FlexGroup create is for Unified NAS (AFF/FAS) only. Classic ASA rejects FlexGroup. AFX volume placement omits aggregates. ASA r2 does not provision volumes with `create_volume`.

**Resize a Volume**

- On the umeng-aff300-05-06 cluster, resize the docs volume on the marketing svm to 25MB.
Expand Down
2 changes: 1 addition & 1 deletion docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ To respond with `application/json` instead of `text/event-stream` (e.g. behind g

## Volume Management

- `create_volume`
- `create_volume` — FlexVol by default (`aggregate_name` on CDOT; omit on AFX). FlexGroup on Unified NAS (AFF/FAS) when `style=flexgroup` with `aggregate_names` (optional `constituents_per_aggregate`, `optimize_aggr_list`, `granular_data`). Not used to provision ASA r2 (storage units); FlexGroup is not supported on classic ASA.
- `update_volume`
- `delete_volume`

Expand Down
34 changes: 19 additions & 15 deletions ontap/ontap.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,21 +134,25 @@ type VolumeQoS struct {
}

type Volume struct {
SVM NameAndUUID `json:"svm,omitzero"`
Name string `json:"name,omitzero"`
Aggregates []NameAndUUID `json:"aggregates,omitzero"`
Files Files `json:"files,omitzero"`
State string `json:"state,omitempty"` // enum: error, mixed, offline, online, restricted
Style string `json:"style,omitempty"` // enum: flexvol, flexgroup, flexgroup_constituent
Size int64 `json:"size,omitempty"`
Nas NAS `json:"nas,omitzero"`
Autosize Autosize `json:"autosize,omitzero"`
Guarantee VolumeGuarantee `json:"guarantee,omitzero"`
SnapshotPolicy VolumeSnapshotPolicy `json:"snapshot_policy,omitzero"`
Space VolumeSpace `json:"space,omitzero"`
Efficiency VolumeEfficiency `json:"efficiency,omitzero"`
QoS VolumeQoS `json:"qos,omitzero"`
Type string `json:"type,omitzero"` // enum: rw, dp, ls
SVM NameAndUUID `json:"svm,omitzero"`
Name string `json:"name,omitzero"`
Aggregates []NameAndUUID `json:"aggregates,omitzero"`
Files Files `json:"files,omitzero"`
State string `json:"state,omitempty"` // enum: error, mixed, offline, online, restricted
Style string `json:"style,omitempty"` // enum: flexvol, flexgroup, flexgroup_constituent
Size int64 `json:"size,omitempty"`
Nas NAS `json:"nas,omitzero"`
Autosize Autosize `json:"autosize,omitzero"`
Guarantee VolumeGuarantee `json:"guarantee,omitzero"`
SnapshotPolicy VolumeSnapshotPolicy `json:"snapshot_policy,omitzero"`
Space VolumeSpace `json:"space,omitzero"`
Efficiency VolumeEfficiency `json:"efficiency,omitzero"`
QoS VolumeQoS `json:"qos,omitzero"`
Type string `json:"type,omitzero"` // enum: rw, dp, ls
ConstituentsPerAggregate *int `json:"constituents_per_aggregate,omitzero"`
OptimizeAggregates *bool `json:"optimize_aggregates,omitzero"`
GranularData *bool `json:"granular_data,omitzero"`
GranularDataMode string `json:"granular_data_mode,omitempty"`
}

type NameAndUUID struct {
Expand Down
25 changes: 16 additions & 9 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/netapp/ontap-mcp/catalog"
"github.com/netapp/ontap-mcp/config"
"github.com/netapp/ontap-mcp/descriptions"
"github.com/netapp/ontap-mcp/ontap"
"github.com/netapp/ontap-mcp/rest"
"github.com/netapp/ontap-mcp/server/lock"
"github.com/netapp/ontap-mcp/tool"
Expand Down Expand Up @@ -73,8 +74,7 @@ type App struct {
}

type cachedVersion struct {
version string
model string
remote ontap.Remote
fetched time.Time
}

Expand Down Expand Up @@ -490,31 +490,38 @@ func (a *App) resolveCluster(input string) (string, bool) {
return canonical, ok
}

func (a *App) getClusterVersion(ctx context.Context, cluster string) (string, string, error) {
func (a *App) getClusterRemote(ctx context.Context, cluster string) (ontap.Remote, error) {
canonical, ok := a.resolveCluster(cluster)
if !ok {
return "", "", fmt.Errorf("cluster %s not found", cluster)
return ontap.Remote{}, fmt.Errorf("cluster %s not found", cluster)
}

if cached, ok := a.versionCache.Load(canonical); ok {
cv := cached.(cachedVersion)
if time.Since(cv.fetched) < versionCacheTTL {
return cv.version, cv.model, nil
return cv.remote, nil
}
}

client, err := a.getClient(cluster)
if err != nil {
return "", "", err
return ontap.Remote{}, err
}
remote, err := client.GetClusterInfo(ctx)
if err != nil {
return ontap.Remote{}, err
}
a.versionCache.Store(canonical, cachedVersion{remote: remote, fetched: time.Now()})
return remote, nil
}

func (a *App) getClusterVersion(ctx context.Context, cluster string) (string, string, error) {
remote, err := a.getClusterRemote(ctx, cluster)
if err != nil {
return "", "", err
}
ver := fmt.Sprintf("%d.%d", remote.Version.Generation, remote.Version.Major)
model := remote.Model
a.versionCache.Store(canonical, cachedVersion{version: ver, model: model, fetched: time.Now()})
return ver, model, nil
return ver, remote.Model, nil
}

func (a *App) ListClusters(ctx context.Context, _ *mcp.CallToolRequest, _ tool.ListClusterParams) (*mcp.CallToolResult, any, error) {
Expand Down
109 changes: 92 additions & 17 deletions server/volume.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
)

func (a *App) CreateVolume(ctx context.Context, _ *mcp.CallToolRequest, parameters tool.VolumeCreate) (*mcp.CallToolResult, any, error) {
model := ontap.CDOT
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
}
Expand All @@ -24,13 +23,17 @@ func (a *App) CreateVolume(ctx context.Context, _ *mcp.CallToolRequest, paramete
return errorResult(err), nil, err
}

if _, m, err := a.getClusterVersion(ctx, parameters.Cluster); err == nil {
model = m
remote := ontap.Remote{Model: ontap.CDOT}
if info, err := a.getClusterRemote(ctx, parameters.Cluster); err == nil {
remote = info
if remote.Model == "" {
remote.Model = ontap.CDOT
}
} else {
a.logger.Warn("failed to determine cluster model, choosing default model as CDOT", slog.String("cluster", parameters.Cluster), slog.String("error", err.Error()))
}

Comment thread
Hardikl marked this conversation as resolved.
volumeCreate, err := newCreateVolume(parameters, model)
volumeCreate, err := newCreateVolumeRemote(parameters, remote)
if err != nil {
return errorResult(err), nil, err
}
Expand Down Expand Up @@ -289,31 +292,85 @@ func updateVolumeValidation(in tool.VolumeUpdate) (ontap.Volume, error) {
}

// newCreateVolume validates the customer provided arguments and converts them into
// the corresponding ONTAP object ready to use via the REST API
// the corresponding ONTAP object ready to use via the REST API.
// model is ontap.CDOT, ontap.AFX, ontap.ASAr2, or empty (treated as CDOT).
func newCreateVolume(in tool.VolumeCreate, model string) (ontap.Volume, error) {
return newCreateVolumeRemote(in, ontap.Remote{Model: model})
Comment thread
Hardikl marked this conversation as resolved.
}

func newCreateVolumeRemote(in tool.VolumeCreate, remote ontap.Remote) (ontap.Volume, error) {
out := ontap.Volume{}
if in.SVM == "" {
return out, errors.New("SVM name is required")
}
if in.Volume == "" {
return out, errors.New("volume name is required")
}
switch model {
case ontap.ASAr2:

model := remote.Model
if model == ontap.ASAr2 {
return out, errors.New("volume creation is not supported on ASAr2 clusters, use storage units instead")
case ontap.AFX:
if in.Aggregate != "" {
return out, errors.New("aggregate name must not be provided for AFX clusters")
}

style := strings.ToLower(strings.TrimSpace(in.Style))
switch style {
case "", "flexvol", "flexgroup":
case "flexgroup_constituent":
return out, errors.New("style flexgroup_constituent is not supported when creating a volume")
default:
return out, fmt.Errorf("unsupported volume style %q; use flexvol or flexgroup", in.Style)
}

isFlexGroup := style == "flexgroup"
if !isFlexGroup && hasFlexGroupPlacementFields(in) {
return out, errors.New("aggregate_names, constituents_per_aggregate, optimize_aggr_list, and granular_data are only valid when style is flexgroup")
}
if in.Aggregate != "" && len(in.AggregateNames) > 0 {
return out, errors.New("cannot set both aggregate_name and aggregate_names")
}

if isFlexGroup {
if model == ontap.AFX {
return out, errors.New("AFX volume placement omits aggregates; do not set style, aggregate_names, constituents_per_aggregate, optimize_aggr_list, or granular_data")
}
if in.GuaranteeType != "" {
return out, errors.New("space guarantee type must not be provided for AFX clusters")
if remote.IsSanOptimized {
return out, errors.New("FlexGroup is not supported on All SAN Arrays")
}
default:
if in.Aggregate == "" {
return out, errors.New("aggregate name is required")
if len(in.AggregateNames) == 0 {
return out, errors.New("aggregate_names is required when style is flexgroup")
}
out.Style = "flexgroup"
out.Aggregates = make([]ontap.NameAndUUID, 0, len(in.AggregateNames))
for _, name := range in.AggregateNames {
name = strings.TrimSpace(name)
if name == "" {
return out, errors.New("aggregate_names entries must not be empty")
}
Comment thread
Hardikl marked this conversation as resolved.
out.Aggregates = append(out.Aggregates, ontap.NameAndUUID{Name: name})
Comment thread
rahulguptajss marked this conversation as resolved.
}
out.Aggregates = []ontap.NameAndUUID{
{Name: in.Aggregate},
out.ConstituentsPerAggregate = in.ConstituentsPerAggregate
if in.OptimizeAggrList != nil && *in.OptimizeAggrList {
out.OptimizeAggregates = in.OptimizeAggrList
}
if err := applyGranularData(&out, in.GranularData); err != nil {
return out, err
}
} else {
switch model {
case ontap.AFX:
if in.Aggregate != "" {
return out, errors.New("aggregate name must not be provided for AFX clusters")
}
if in.GuaranteeType != "" {
return out, errors.New("space guarantee type must not be provided for AFX clusters")
}
default:
if in.Aggregate == "" {
return out, errors.New("aggregate name is required")
}
out.Aggregates = []ontap.NameAndUUID{
{Name: in.Aggregate},
}
}
}

Expand Down Expand Up @@ -386,6 +443,24 @@ func newCreateVolume(in tool.VolumeCreate, model string) (ontap.Volume, error) {
return out, nil
}

func hasFlexGroupPlacementFields(in tool.VolumeCreate) bool {
return len(in.AggregateNames) > 0 || in.ConstituentsPerAggregate != nil || in.OptimizeAggrList != nil || strings.TrimSpace(in.GranularData) != ""
}

func applyGranularData(out *ontap.Volume, mode string) error {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "", "disabled":
return nil
case "basic", "advanced":
enabled := true
out.GranularData = &enabled
out.GranularDataMode = strings.ToLower(strings.TrimSpace(mode))
return nil
default:
return fmt.Errorf("unsupported granular_data %q; use disabled, basic, or advanced", mode)
}
}

// newUpdateVolume validates the customer provided arguments and converts them into
// the corresponding ONTAP object ready to use via the REST API
func newUpdateVolume(in tool.Volume) (ontap.Volume, error) {
Expand Down
Loading
Loading