From 388f66273a84d00cf1ca909f5f0fd1eb6f2e274e Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Thu, 17 Sep 2026 14:06:21 -0400 Subject: [PATCH 01/16] feat: add netappfiles account create, get, update tools --- .github/CODEOWNERS | 6 + .vscode/cspell.json | 1 + Directory.Packages.props | 1 + Microsoft.Mcp.slnx | 7 + .../Azure.Mcp.Server/Azure.Mcp.Server.slnx | 7 + servers/Azure.Mcp.Server/README.md | 1 + .../b-cstump-netappfiles-account-create.yaml | 3 + .../Azure.Mcp.Server/docs/azmcp-commands.md | 32 ++ .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 11 + servers/Azure.Mcp.Server/src/Program.cs | 1 + .../src/Resources/consolidated-tools.json | 39 +- .../src/AssemblyInfo.cs | 6 + .../src/Azure.Mcp.Tools.NetAppFiles.csproj | 13 + .../Commands/Account/AccountCreateCommand.cs | 96 +++++ .../src/Commands/Account/AccountGetCommand.cs | 92 +++++ .../Commands/Account/AccountUpdateCommand.cs | 126 +++++++ .../src/Commands/NetAppFilesJsonContext.cs | 18 + .../src/Models/NetAppFilesAccount.cs | 10 + .../src/NetAppFilesSetup.cs | 41 +++ .../Options/Account/AccountCreateOptions.cs | 25 ++ .../src/Options/Account/AccountGetOptions.cs | 22 ++ .../Options/Account/AccountUpdateOptions.cs | 28 ++ .../Services/INetAppFilesAccountService.cs | 33 ++ .../src/Services/NetAppFilesAccountService.cs | 101 ++++++ .../src/Validation/AccountNameValidator.cs | 27 ++ .../Account/AccountCreateCommandTests.cs | 126 +++++++ .../Account/AccountGetCommandTests.cs | 119 ++++++ .../Account/AccountUpdateCommandTests.cs | 169 +++++++++ .../Azure.Mcp.Tools.NetAppFiles.Tests.csproj | 19 + .../NetAppFilesCommandTests.cs | 104 ++++++ .../assets.json | 6 + .../SnapshotPolicyUpdateCommandTests.cs | 342 ++++++++++++++++++ .../tests/test-resources-post.ps1 | 17 + .../tests/test-resources.bicep | 26 ++ 34 files changed, 1673 insertions(+), 2 deletions(-) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/b-cstump-netappfiles-account-create.yaml create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Azure.Mcp.Tools.NetAppFiles.csproj create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Azure.Mcp.Tools.NetAppFiles.Tests.csproj create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.UnitTests/SnapshotPolicy/SnapshotPolicyUpdateCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources-post.ps1 create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6689dd4ab8..be3666dc36 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -358,6 +358,12 @@ # ServiceLabel: %tools-MySQL # ServiceOwners: @mattkohnms @ramnov +# PRLabel: %tools-NetAppFiles +/tools/Azure.Mcp.Tools.NetAppFiles/ @stumpc @microsoft/azure-mcp + +# ServiceLabel: %tools-NetAppFiles +# ServiceOwners: @microsoft/azure-mcp + # PRLabel: %tools-Optimization /tools/Azure.Mcp.Tools.Optimization/ @sanjaytholanim @liangchenmicrosoft diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 540ead5c3d..1c3d89d82a 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -130,6 +130,7 @@ "mydb", "myop", "mystorageaccount", + "netappfiles", "noninteractive", "nugets", "pipefail", diff --git a/Directory.Packages.props b/Directory.Packages.props index d13ec70739..201e001946 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,6 +45,7 @@ + diff --git a/Microsoft.Mcp.slnx b/Microsoft.Mcp.slnx index b45b6a3212..65b0510417 100644 --- a/Microsoft.Mcp.slnx +++ b/Microsoft.Mcp.slnx @@ -393,6 +393,13 @@ + + + + + + + diff --git a/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx b/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx index 25f381e051..7487797c14 100644 --- a/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx +++ b/servers/Azure.Mcp.Server/Azure.Mcp.Server.slnx @@ -320,6 +320,13 @@ + + + + + + + diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 9a2120e9fc..221d243e5e 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -1439,6 +1439,7 @@ The Azure MCP Server provides tools for interacting with **45+ Azure service are - 🧮 **Azure Data Explorer** - Analytics queries and KQL - 🐬 **Azure Database for MySQL** - MySQL database management - 🐘 **Azure Database for PostgreSQL** - PostgreSQL database management +- **Azure NetApp Files** - Enterprise file storage account management - 🏭 **Azure Device Registry** - Device Registry namespace management - 📊 **Azure Event Grid** - Event routing and management - ⚡ **Azure Event Hubs** - Namespace, event hub, and consumer group management diff --git a/servers/Azure.Mcp.Server/changelog-entries/b-cstump-netappfiles-account-create.yaml b/servers/Azure.Mcp.Server/changelog-entries/b-cstump-netappfiles-account-create.yaml new file mode 100644 index 0000000000..4241742653 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/b-cstump-netappfiles-account-create.yaml @@ -0,0 +1,3 @@ +changes: + - section: Features Added + description: Added `netappfiles account create`, `netappfiles account get`, and `netappfiles account update` commands for creating, retrieving, and updating Azure NetApp Files accounts. \ No newline at end of file diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 8d346f24b2..d7ce060069 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -2664,6 +2664,38 @@ azmcp kusto query [--cluster-uri | --subscription - ``` +### Azure NetApp Files Operations + +```bash +# Gets an Azure NetApp Files account by name from a specified resource group. +# Returns the account name, resource ID, location, and provisioning state. +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp netappfiles account get --account \ + --resource-group \ + --subscription \ + [--tenant ] + +# Creates an Azure NetApp Files account in a specified resource group and region. +# Returns the account name, resource ID, location, and provisioning state. +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp netappfiles account create --account \ + --location \ + --resource-group \ + --subscription \ + [--tenant ] + +# Updates the tags or NFSv4 user ID mapping domain of an Azure NetApp Files account. +# Providing an empty JSON object for --tags removes all tags. +# Returns the updated account name, resource ID, location, and provisioning state. +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp netappfiles account update --account \ + --resource-group \ + --subscription \ + [--tags ] \ + [--nfs-v4-id-domain ] \ + [--tenant ] +``` + ### Azure Database for MySQL Operations ```bash diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 09f651077a..ca99a7f0ea 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -600,6 +600,17 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | mysql_server_param_set | Set connection timeout to 20 seconds for my MySQL server \ | none | | mysql_table_schema_get | Show me the schema of table \ in the MySQL database \ in server \ | none | +## Azure NetApp Files + +| Tool Name | Test Prompt | Interaction | +|:----------|:------------|:------------| +| netappfiles_account_create | Create an Azure NetApp Files account named \ in \ in resource group \ | none | +| netappfiles_account_create | Create a NetApp Files account called \ in resource group \ | none | +| netappfiles_account_get | Get the Azure NetApp Files account named \ in resource group \ | none | +| netappfiles_account_get | Show me details for NetApp Files account \ in resource group \ | none | +| netappfiles_account_update | Set the tags on Azure NetApp Files account \ in resource group \ to {"environment":"production"} | none | +| netappfiles_account_update | Update the NFSv4 ID mapping domain for NetApp Files account \ in resource group \ to example.com | none | + ## Azure Database for PostgreSQL | Tool Name | Test Prompt | Interaction | diff --git a/servers/Azure.Mcp.Server/src/Program.cs b/servers/Azure.Mcp.Server/src/Program.cs index 26ab7cfc9e..f59de4dc99 100644 --- a/servers/Azure.Mcp.Server/src/Program.cs +++ b/servers/Azure.Mcp.Server/src/Program.cs @@ -222,6 +222,7 @@ internal static IAreaSetup[] RegisterAreas() new Azure.Mcp.Tools.Monitor.MonitorSetup(), new Azure.Mcp.Tools.ApplicationInsights.ApplicationInsightsSetup(), new Azure.Mcp.Tools.MySql.MySqlSetup(), + new Azure.Mcp.Tools.NetAppFiles.NetAppFilesSetup(), new Azure.Mcp.Tools.Optimization.OptimizationSetup(), new Azure.Mcp.Tools.Policy.PolicySetup(), new Azure.Mcp.Tools.Postgres.PostgresSetup(), diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index f1e2e2374f..e4b8652049 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -2415,7 +2415,7 @@ }, { "name": "get_azure_storage_details", - "description": "Get details about Azure Storage resources including Storage accounts, blob containers, blob data, and tables. Also manage Azure Managed Lustre (AMLFS) filesystems: list filesystems with provisioning state and capacity, retrieve available SKUs with bandwidth and scale targets, calculate required subnet sizes for deployment planning, and validate subnet configurations against SKU and size requirements.", + "description": "Get details about Azure Storage resources including Storage accounts, Azure NetApp Files accounts, blob containers, blob data, and tables. Also manage Azure Managed Lustre (AMLFS) filesystems: list filesystems with provisioning state and capacity, retrieve available SKUs with bandwidth and scale targets, calculate required subnet sizes for deployment planning, and validate subnet configurations against SKU and size requirements.", "toolMetadata": { "destructive": { "value": false, @@ -2449,6 +2449,7 @@ "managedlustre_fs_blob_autoimport_get", "managedlustre_fs_blob_import_get", "managedlustre_fs_expansion_get", + "netappfiles_account_get", "storage_account_get", "storage_blob_container_get", "storage_blob_get", @@ -2662,7 +2663,7 @@ }, { "name": "create_azure_storage", - "description": "Create Azure Storage accounts, blob containers, and Azure Managed Lustre filesystems.", + "description": "Create Azure Storage accounts, blob containers, Azure Managed Lustre filesystems, and Azure NetApp Files accounts.", "toolMetadata": { "destructive": { "value": true, @@ -2692,6 +2693,7 @@ "mappedToolList": [ "storage_account_create", "storage_blob_container_create", + "netappfiles_account_create", "managedlustre_fs_create", "managedlustre_fs_blob_autoexport_create", "managedlustre_fs_blob_autoimport_create", @@ -2699,6 +2701,39 @@ "managedlustre_fs_expansion_create" ] }, + { + "name": "update_azure_netapp_files_accounts", + "description": "Update tags or the NFSv4 user ID mapping domain for Azure NetApp Files accounts.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by updating data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "netappfiles_account_update" + ] + }, { "name": "update_azure_managed_lustre_filesystems", "description": "Update existing Azure Managed Lustre filesystem configurations.", diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs new file mode 100644 index 0000000000..64899e5635 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Azure.Mcp.Tools.NetAppFiles.Tests")] \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Azure.Mcp.Tools.NetAppFiles.csproj b/tools/Azure.Mcp.Tools.NetAppFiles/src/Azure.Mcp.Tools.NetAppFiles.csproj new file mode 100644 index 0000000000..2b84c66201 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Azure.Mcp.Tools.NetAppFiles.csproj @@ -0,0 +1,13 @@ + + + true + + + + + + + + + + \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs new file mode 100644 index 0000000000..ab9bd3fd72 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Options.Account; +using Azure.Mcp.Tools.NetAppFiles.Services; +using Azure.Mcp.Tools.NetAppFiles.Validation; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Commands.Account; + +[CommandMetadata( + Id = "a63649c2-caaa-4744-a35b-6a67db0527b7", + Name = "create", + Title = "Create Azure NetApp Files Account", + Description = "Creates an Azure NetApp Files account in a specified resource group and region. Returns the account name, resource ID, location, and provisioning state.", + OperationPlane = ToolOperationPlane.Control, + Destructive = true, + Idempotent = true, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class AccountCreateCommand( + ILogger logger, + INetAppFilesAccountService service, + ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand(subscriptionResolver) +{ + private readonly ILogger _logger = logger; + private readonly INetAppFilesAccountService _service = service; + + public override async Task ExecuteAsync( + CommandContext context, + AccountCreateOptions options, + CancellationToken cancellationToken) + { + try + { + var account = await _service.CreateAccountAsync( + options.Account, + options.Location, + options.ResourceGroup, + options.Subscription!, + options.Tenant, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new AccountCreateResult(account), + NetAppFilesJsonContext.Default.AccountCreateResult); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error creating Azure NetApp Files account. Account: {Account}, ResourceGroup: {ResourceGroup}, Location: {Location}, Subscription: {Subscription}", + options.Account, + options.ResourceGroup, + options.Location, + options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + public override void ValidateOptions(AccountCreateOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + if (!AccountNameValidator.IsValid(options.Account)) + { + validationResult.Errors.Add(AccountNameValidator.ErrorMessage); + } + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + KeyNotFoundException => "Resource group not found. Verify the resource group exists and you have access.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.Conflict => + "The Azure NetApp Files account could not be created because of a resource conflict. Verify the account name and resource state.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed creating the Azure NetApp Files account. Verify that you have the required RBAC permissions.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.NotFound => + "Resource group not found. Verify the resource group exists and you have access.", + _ => base.GetErrorMessage(ex) + }; + + public record AccountCreateResult(NetAppFilesAccount Account); +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs new file mode 100644 index 0000000000..7d79ddd361 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Options.Account; +using Azure.Mcp.Tools.NetAppFiles.Services; +using Azure.Mcp.Tools.NetAppFiles.Validation; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Commands.Account; + +[CommandMetadata( + Id = "d46bb832-5b3e-4259-85c4-5d5f70e30a4c", + Name = "get", + Title = "Get Azure NetApp Files Account", + Description = "Gets an Azure NetApp Files account by name from a specified resource group. Returns the account name, resource ID, location, and provisioning state.", + OperationPlane = ToolOperationPlane.Control, + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false)] +public sealed class AccountGetCommand( + ILogger logger, + INetAppFilesAccountService service, + ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand(subscriptionResolver) +{ + private readonly ILogger _logger = logger; + private readonly INetAppFilesAccountService _service = service; + + public override async Task ExecuteAsync( + CommandContext context, + AccountGetOptions options, + CancellationToken cancellationToken) + { + try + { + var account = await _service.GetAccountAsync( + options.Account, + options.ResourceGroup, + options.Subscription!, + options.Tenant, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new AccountGetResult(account), + NetAppFilesJsonContext.Default.AccountGetResult); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error getting Azure NetApp Files account. Account: {Account}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}", + options.Account, + options.ResourceGroup, + options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + public override void ValidateOptions(AccountGetOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + if (!AccountNameValidator.IsValid(options.Account)) + { + validationResult.Errors.Add(AccountNameValidator.ErrorMessage); + } + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + KeyNotFoundException => "Resource group not found. Verify the resource group exists and you have access.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed getting the Azure NetApp Files account. Verify that you have the required RBAC permissions.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.NotFound => + "Azure NetApp Files account not found. Verify the account and resource group names.", + _ => base.GetErrorMessage(ex) + }; + + public record AccountGetResult(NetAppFilesAccount Account); +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs new file mode 100644 index 0000000000..abba77c2b2 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Options.Account; +using Azure.Mcp.Tools.NetAppFiles.Services; +using Azure.Mcp.Tools.NetAppFiles.Validation; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Commands.Account; + +[CommandMetadata( + Id = "3d2ccbae-3d80-4be3-8f48-d951abfb0bb5", + Name = "update", + Title = "Update Azure NetApp Files Account", + Description = "Updates the tags or NFSv4 user ID mapping domain of an Azure NetApp Files account. Returns the updated account name, resource ID, location, and provisioning state.", + OperationPlane = ToolOperationPlane.Control, + Destructive = true, + Idempotent = true, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class AccountUpdateCommand( + ILogger logger, + INetAppFilesAccountService service, + ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand(subscriptionResolver) +{ + private readonly ILogger _logger = logger; + private readonly INetAppFilesAccountService _service = service; + + public override async Task ExecuteAsync( + CommandContext context, + AccountUpdateOptions options, + CancellationToken cancellationToken) + { + try + { + var tags = options.Tags is null + ? null + : JsonSerializer.Deserialize(options.Tags, NetAppFilesJsonContext.Default.DictionaryStringString); + + var account = await _service.UpdateAccountAsync( + options.Account, + options.ResourceGroup, + options.Subscription!, + tags, + options.NfsV4IdDomain, + options.Tenant, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new AccountUpdateResult(account), + NetAppFilesJsonContext.Default.AccountUpdateResult); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error updating Azure NetApp Files account. Account: {Account}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}", + options.Account, + options.ResourceGroup, + options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + public override void ValidateOptions(AccountUpdateOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + if (!AccountNameValidator.IsValid(options.Account)) + { + validationResult.Errors.Add(AccountNameValidator.ErrorMessage); + } + + if (options.Tags is null && options.NfsV4IdDomain is null) + { + validationResult.Errors.Add("At least one update property must be provided: --tags or --nfs-v4-id-domain."); + } + + if (options.Tags is not null) + { + try + { + if (JsonSerializer.Deserialize(options.Tags, NetAppFilesJsonContext.Default.DictionaryStringString) is null) + { + validationResult.Errors.Add("--tags must be a JSON key-value object."); + } + } + catch (JsonException) + { + validationResult.Errors.Add("--tags must be a JSON key-value object with string values."); + } + } + + if (options.NfsV4IdDomain is not null && string.IsNullOrWhiteSpace(options.NfsV4IdDomain)) + { + validationResult.Errors.Add("--nfs-v4-id-domain cannot be empty or whitespace."); + } + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + KeyNotFoundException => "Resource group not found. Verify the resource group exists and you have access.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.Conflict => + "The Azure NetApp Files account could not be updated because of a resource conflict. Verify the account state and update values.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed updating the Azure NetApp Files account. Verify that you have the required RBAC permissions.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.NotFound => + "Azure NetApp Files account not found. Verify the account and resource group names and that you have access.", + _ => base.GetErrorMessage(ex) + }; + + public record AccountUpdateResult(NetAppFilesAccount Account); +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs new file mode 100644 index 0000000000..4ee43c2910 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Tools.NetAppFiles.Commands.Account; +using Azure.Mcp.Tools.NetAppFiles.Models; + +namespace Azure.Mcp.Tools.NetAppFiles.Commands; + +[JsonSerializable(typeof(AccountCreateCommand.AccountCreateResult))] +[JsonSerializable(typeof(AccountGetCommand.AccountGetResult))] +[JsonSerializable(typeof(AccountUpdateCommand.AccountUpdateResult))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(NetAppFilesAccount))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal partial class NetAppFilesJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs new file mode 100644 index 0000000000..c43a29ddf6 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.NetAppFiles.Models; + +public record NetAppFilesAccount( + string Name, + string Id, + string Location, + string? ProvisioningState); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs new file mode 100644 index 0000000000..aeee731e83 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.NetAppFiles.Commands.Account; +using Azure.Mcp.Tools.NetAppFiles.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Mcp.Core.Areas; +using Microsoft.Mcp.Core.Commands; + +namespace Azure.Mcp.Tools.NetAppFiles; + +public class NetAppFilesSetup : IAreaSetup +{ + public string Name => "netappfiles"; + + public string Title => "Azure NetApp Files"; + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + public CommandGroup RegisterCommands(IServiceProvider serviceProvider) + { + var root = new CommandGroup( + Name, + "Azure NetApp Files operations for managing enterprise file storage resources.", + Title); + + var account = new CommandGroup("account", "Azure NetApp Files account operations."); + root.AddSubGroup(account); + account.AddCommand(serviceProvider); + account.AddCommand(serviceProvider); + account.AddCommand(serviceProvider); + + return root; + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs new file mode 100644 index 0000000000..c32551463d --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Options.Account; + +public class AccountCreateOptions : ISubscriptionOption +{ + [Option(Description = "The name of the Azure NetApp Files account to create. Must be 1-128 characters, start with an alphanumeric character, and contain only alphanumerics, underscores, and hyphens.")] + public required string Account { get; set; } + + [Option(Description = "The Azure region where the Azure NetApp Files account will be created (for example, 'eastus' or 'westus2').")] + public required string Location { get; set; } + + [Option(Description = OptionDescriptions.ResourceGroup)] + public required string ResourceGroup { get; set; } + + [Option(Description = OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs new file mode 100644 index 0000000000..f96fb42f9f --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Options.Account; + +public class AccountGetOptions : ISubscriptionOption +{ + [Option(Description = "The name of the Azure NetApp Files account to retrieve. Must be 1-128 characters, start with an alphanumeric character, and contain only alphanumerics, underscores, and hyphens.")] + public required string Account { get; set; } + + [Option(Description = OptionDescriptions.ResourceGroup)] + public required string ResourceGroup { get; set; } + + [Option(Description = OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs new file mode 100644 index 0000000000..2c63e1d8b7 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Options.Account; + +public sealed class AccountUpdateOptions : ISubscriptionOption +{ + [Option(Description = "The name of the Azure NetApp Files account to update. Must be 1-128 characters, start with an alphanumeric character, and contain only alphanumerics, underscores, and hyphens.")] + public required string Account { get; set; } + + [Option(Description = "Resource tags as a JSON key-value object. Providing an empty object removes all tags.")] + public string? Tags { get; set; } + + [Option(Description = "The domain for NFSv4 user ID mapping. This setting applies to all Azure NetApp Files accounts in the subscription and region and affects non-LDAP NFSv4 volumes.")] + public string? NfsV4IdDomain { get; set; } + + [Option(Description = OptionDescriptions.ResourceGroup)] + public required string ResourceGroup { get; set; } + + [Option(Description = OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs new file mode 100644 index 0000000000..0d2d827318 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.NetAppFiles.Models; + +namespace Azure.Mcp.Tools.NetAppFiles.Services; + +public interface INetAppFilesAccountService +{ + Task GetAccountAsync( + string account, + string resourceGroup, + string subscription, + string? tenant = null, + CancellationToken cancellationToken = default); + + Task UpdateAccountAsync( + string account, + string resourceGroup, + string subscription, + IReadOnlyDictionary? tags = null, + string? nfsV4IdDomain = null, + string? tenant = null, + CancellationToken cancellationToken = default); + + Task CreateAccountAsync( + string account, + string location, + string resourceGroup, + string subscription, + string? tenant = null, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs new file mode 100644 index 0000000000..9f7ff65319 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure; +using Azure.Core; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.ResourceManager.NetApp; +using Azure.ResourceManager.NetApp.Models; + +namespace Azure.Mcp.Tools.NetAppFiles.Services; + +public class NetAppFilesAccountService(IAzureService azureService) : BaseAzureService(azureService), INetAppFilesAccountService +{ + public async Task GetAccountAsync( + string account, + string resourceGroup, + string subscription, + string? tenant = null, + CancellationToken cancellationToken = default) + { + var resourceGroupResource = await AzureService.GetResourceGroupResource( + subscription, + resourceGroup, + tenant, + cancellationToken: cancellationToken) + ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); + + var accountResource = resourceGroupResource.GetNetAppAccount(account, cancellationToken); + + return Map(accountResource.Value); + } + + public async Task CreateAccountAsync( + string account, + string location, + string resourceGroup, + string subscription, + string? tenant = null, + CancellationToken cancellationToken = default) + { + var resourceGroupResource = await AzureService.GetResourceGroupResource( + subscription, + resourceGroup, + tenant, + cancellationToken: cancellationToken) + ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); + + var accountData = new NetAppAccountData(new AzureLocation(location)); + var operation = await resourceGroupResource + .GetNetAppAccounts() + .CreateOrUpdateAsync(WaitUntil.Started, account, accountData, cancellationToken); + + await WaitForLroCompletionAsync(operation, cancellationToken); + + return Map(operation.Value); + } + + public async Task UpdateAccountAsync( + string account, + string resourceGroup, + string subscription, + IReadOnlyDictionary? tags = null, + string? nfsV4IdDomain = null, + string? tenant = null, + CancellationToken cancellationToken = default) + { + var resourceGroupResource = await AzureService.GetResourceGroupResource( + subscription, + resourceGroup, + tenant, + cancellationToken: cancellationToken) + ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); + + var accountResource = resourceGroupResource.GetNetAppAccount(account, cancellationToken).Value; + var patch = new NetAppAccountPatch(accountResource.Data.Location) + { + NfsV4IdDomain = nfsV4IdDomain + }; + + if (tags is not null) + { + patch.Tags.Clear(); + foreach (var tag in tags) + { + patch.Tags[tag.Key] = tag.Value; + } + } + + var operation = await accountResource.UpdateAsync(WaitUntil.Started, patch, cancellationToken); + await WaitForLroCompletionAsync(operation, cancellationToken); + + return Map(operation.Value); + } + + private static NetAppFilesAccount Map(NetAppAccountResource account) => new( + account.Data.Name, + account.Data.Id.ToString(), + account.Data.Location.ToString(), + account.Data.ProvisioningState?.ToString()); +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs new file mode 100644 index 0000000000..ecb64192a4 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.NetAppFiles.Validation; + +internal static class AccountNameValidator +{ + public const string ErrorMessage = "--account must be 1-128 characters, start with an alphanumeric character, and contain only alphanumerics, underscores, and hyphens."; + + public static bool IsValid(string account) + { + if (account.Length is < 1 or > 128 || !char.IsAsciiLetterOrDigit(account[0])) + { + return false; + } + + foreach (var character in account) + { + if (!char.IsAsciiLetterOrDigit(character) && character is not '_' and not '-') + { + return false; + } + } + + return true; + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs new file mode 100644 index 0000000000..45d046424d --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands.Account; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Services; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.NetAppFiles.Tests.Account; + +public class AccountCreateCommandTests : SubscriptionCommandUnitTestsBase +{ + private static readonly NetAppFilesAccount CreatedAccount = new( + "account1", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/account1", + "eastus", + "Succeeded"); + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + + Assert.Equal("create", command.Name); + Assert.False(string.IsNullOrWhiteSpace(command.Description)); + } + + [Fact] + public async Task ExecuteAsync_CreatesAccount() + { + Service.CreateAccountAsync( + "account1", + "eastus", + "rg", + "sub", + "tenant", + Arg.Any()) + .Returns(CreatedAccount); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--location", "eastus", + "--resource-group", "rg", + "--subscription", "sub", + "--tenant", "tenant"); + + var result = ValidateAndDeserializeResponse( + response, + NetAppFilesJsonContext.Default.AccountCreateResult); + Assert.Equal(CreatedAccount, result.Account); + } + + [Theory] + [InlineData("_account")] + [InlineData("-account")] + [InlineData("account/name")] + [InlineData("account.name")] + public async Task ExecuteAsync_InvalidAccountName_ReturnsBadRequest(string account) + { + var response = await ExecuteCommandAsync( + "--account", account, + "--location", "eastus", + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--account", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Fact] + public async Task ExecuteAsync_OversizedAccountName_ReturnsBadRequest() + { + var response = await ExecuteCommandAsync( + "--account", new string('a', 129), + "--location", "eastus", + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("1-128", response.Message); + } + + [Theory] + [InlineData("--location eastus --resource-group rg --subscription sub")] + [InlineData("--account account1 --resource-group rg --subscription sub")] + [InlineData("--account account1 --location eastus --subscription sub")] + [InlineData("--account account1 --location eastus --resource-group rg")] + public async Task ExecuteAsync_MissingRequiredOption_ReturnsBadRequest(string args) + { + var response = await ExecuteCommandAsync(args); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_ServiceConflict_ReturnsSafeMessage() + { + Service.CreateAccountAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException( + (int)HttpStatusCode.Conflict, + "backend payload containing internal account metadata")); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--location", "eastus", + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Contains("resource conflict", response.Message); + Assert.DoesNotContain("backend payload", response.Message); + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs new file mode 100644 index 0000000000..351a7d27a2 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands.Account; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Services; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.NetAppFiles.Tests.Account; + +public class AccountGetCommandTests : SubscriptionCommandUnitTestsBase +{ + private static readonly NetAppFilesAccount ExistingAccount = new( + "account1", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/account1", + "eastus", + "Succeeded"); + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + + Assert.Equal("get", command.Name); + Assert.False(string.IsNullOrWhiteSpace(command.Description)); + } + + [Fact] + public async Task ExecuteAsync_GetsAccount() + { + Service.GetAccountAsync( + "account1", + "rg", + "sub", + "tenant", + Arg.Any()) + .Returns(ExistingAccount); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--resource-group", "rg", + "--subscription", "sub", + "--tenant", "tenant"); + + var result = ValidateAndDeserializeResponse( + response, + NetAppFilesJsonContext.Default.AccountGetResult); + Assert.Equal(ExistingAccount, result.Account); + } + + [Theory] + [InlineData("_account")] + [InlineData("-account")] + [InlineData("account/name")] + [InlineData("account.name")] + public async Task ExecuteAsync_InvalidAccountName_ReturnsBadRequest(string account) + { + var response = await ExecuteCommandAsync( + "--account", account, + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--account", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Fact] + public async Task ExecuteAsync_OversizedAccountName_ReturnsBadRequest() + { + var response = await ExecuteCommandAsync( + "--account", new string('a', 129), + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("1-128", response.Message); + } + + [Theory] + [InlineData("--resource-group rg --subscription sub")] + [InlineData("--account account1 --subscription sub")] + [InlineData("--account account1 --resource-group rg")] + public async Task ExecuteAsync_MissingRequiredOption_ReturnsBadRequest(string args) + { + var response = await ExecuteCommandAsync(args); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_AccountNotFound_ReturnsSafeMessage() + { + Service.GetAccountAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException( + (int)HttpStatusCode.NotFound, + "backend payload containing internal account metadata")); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.NotFound, response.Status); + Assert.Contains("account not found", response.Message); + Assert.DoesNotContain("backend payload", response.Message); + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs new file mode 100644 index 0000000000..c9752f0a12 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands.Account; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Services; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.NetAppFiles.Tests.Account; + +public class AccountUpdateCommandTests : SubscriptionCommandUnitTestsBase +{ + private static readonly NetAppFilesAccount UpdatedAccount = new( + "account1", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/account1", + "eastus", + "Succeeded"); + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + + Assert.Equal("update", command.Name); + Assert.False(string.IsNullOrWhiteSpace(command.Description)); + } + + [Fact] + public async Task ExecuteAsync_UpdatesTagsAndNfsV4IdDomain() + { + Service.UpdateAccountAsync( + "account1", + "rg", + "sub", + Arg.Is>(tags => + tags.Count == 2 && tags["environment"] == "test" && tags["owner"] == "storage"), + "contoso.com", + "tenant", + Arg.Any()) + .Returns(UpdatedAccount); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--resource-group", "rg", + "--subscription", "sub", + "--tenant", "tenant", + "--tags", "{\"environment\":\"test\",\"owner\":\"storage\"}", + "--nfs-v4-id-domain", "contoso.com"); + + var result = ValidateAndDeserializeResponse( + response, + NetAppFilesJsonContext.Default.AccountUpdateResult); + Assert.Equal(UpdatedAccount, result.Account); + } + + [Fact] + public async Task ExecuteAsync_EmptyTags_ClearsTags() + { + Service.UpdateAccountAsync( + "account1", + "rg", + "sub", + Arg.Is>(tags => tags.Count == 0), + null, + null, + Arg.Any()) + .Returns(UpdatedAccount); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--resource-group", "rg", + "--subscription", "sub", + "--tags", "{}"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Theory] + [InlineData("{invalid-json}")] + [InlineData("[]")] + [InlineData("null")] + [InlineData("{\"environment\":1}")] + public async Task ExecuteAsync_InvalidTags_ReturnsBadRequest(string tags) + { + var response = await ExecuteCommandAsync( + "--account", "account1", + "--resource-group", "rg", + "--subscription", "sub", + "--tags", tags); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--tags", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Fact] + public async Task ExecuteAsync_NoUpdateProperty_ReturnsBadRequest() + { + var response = await ExecuteCommandAsync( + "--account", "account1", + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("At least one update property", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("_account")] + [InlineData("-account")] + [InlineData("account/name")] + [InlineData("account.name")] + public async Task ExecuteAsync_InvalidAccountName_ReturnsBadRequest(string account) + { + var response = await ExecuteCommandAsync( + "--account", account, + "--resource-group", "rg", + "--subscription", "sub", + "--tags", "{}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--account", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("--resource-group rg --subscription sub --tags {}")] + [InlineData("--account account1 --subscription sub --tags {}")] + [InlineData("--account account1 --resource-group rg --tags {}")] + public async Task ExecuteAsync_MissingRequiredOption_ReturnsBadRequest(string args) + { + var response = await ExecuteCommandAsync(args); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_ServiceConflict_ReturnsSafeMessage() + { + Service.UpdateAccountAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException( + (int)HttpStatusCode.Conflict, + "backend payload containing internal account metadata")); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--resource-group", "rg", + "--subscription", "sub", + "--nfs-v4-id-domain", "contoso.com"); + + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Contains("resource conflict", response.Message); + Assert.DoesNotContain("backend payload", response.Message); + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Azure.Mcp.Tools.NetAppFiles.Tests.csproj b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Azure.Mcp.Tools.NetAppFiles.Tests.csproj new file mode 100644 index 0000000000..07db629b0d --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Azure.Mcp.Tools.NetAppFiles.Tests.csproj @@ -0,0 +1,19 @@ + + + true + Exe + true + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs new file mode 100644 index 0000000000..91f6e56bc7 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Mcp.Tests; +using Microsoft.Mcp.Tests.Client; +using Microsoft.Mcp.Tests.Client.Helpers; +using Xunit; + +namespace Azure.Mcp.Tools.NetAppFiles.Tests; + +public class NetAppFilesCommandTests( + ITestOutputHelper output, + TestProxyFixture fixture, + LiveServerFixture liveServerFixture) + : RecordedCommandTestsBase(output, fixture, liveServerFixture) +{ + [Fact] + public async Task AccountGet_ReturnsAccount() + { + await CallToolAsync( + "netappfiles_account_create", + new() + { + { "account", Settings.ResourceBaseName }, + { "location", "eastus" }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + var result = await CallToolAsync( + "netappfiles_account_get", + new() + { + { "account", Settings.ResourceBaseName }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + var account = result.AssertProperty("account"); + Assert.Equal(JsonValueKind.Object, account.ValueKind); + account.AssertProperty("name"); + account.AssertProperty("id"); + Assert.Equal("eastus", account.AssertProperty("location").GetString()); + Assert.Equal("Succeeded", account.AssertProperty("provisioningState").GetString()); + } + + [Fact] + public async Task AccountCreate_ReturnsCreatedAccount() + { + var result = await CallToolAsync( + "netappfiles_account_create", + new() + { + { "account", Settings.ResourceBaseName }, + { "location", "eastus" }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + var account = result.AssertProperty("account"); + Assert.Equal(JsonValueKind.Object, account.ValueKind); + account.AssertProperty("name"); + account.AssertProperty("id"); + Assert.Equal("eastus", account.AssertProperty("location").GetString()); + Assert.Equal("Succeeded", account.AssertProperty("provisioningState").GetString()); + } + + [Fact] + public async Task AccountUpdate_ReturnsUpdatedAccount() + { + await CallToolAsync( + "netappfiles_account_create", + new() + { + { "account", Settings.ResourceBaseName }, + { "location", "eastus" }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + var result = await CallToolAsync( + "netappfiles_account_update", + new() + { + { "account", Settings.ResourceBaseName }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId }, + { "tags", "{\"recorded-test\":\"account-update\"}" } + }); + + var account = result.AssertProperty("account"); + Assert.Equal(JsonValueKind.Object, account.ValueKind); + account.AssertProperty("name"); + account.AssertProperty("id"); + Assert.Equal("eastus", account.AssertProperty("location").GetString()); + Assert.Equal("Succeeded", account.AssertProperty("provisioningState").GetString()); + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json new file mode 100644 index 0000000000..80da70da3d --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json @@ -0,0 +1,6 @@ +{ + "AssetsRepo": "Azure/azure-sdk-assets", + "AssetsRepoPrefixPath": "", + "TagPrefix": "Azure.Mcp.Tools.NetAppFiles.Tests", + "Tag": "" +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.UnitTests/SnapshotPolicy/SnapshotPolicyUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.UnitTests/SnapshotPolicy/SnapshotPolicyUpdateCommandTests.cs new file mode 100644 index 0000000000..2fa77533a6 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.UnitTests/SnapshotPolicy/SnapshotPolicyUpdateCommandTests.cs @@ -0,0 +1,342 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Tools.NetAppFiles.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands.SnapshotPolicy; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Services; +using Azure.Mcp.Tests.Commands; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Options; +using Microsoft.Mcp.Tests.Helpers; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.NetAppFiles.UnitTests.SnapshotPolicy; + +public class SnapshotPolicyUpdateCommandTests : SubscriptionCommandUnitTestsBase +{ + private const string SnapshotPolicyResourceId = "/subscriptions/sub123/resourceGroups/myrg/providers/Microsoft.NetApp/netAppAccounts/myanfaccount/snapshotPolicies/mypolicy"; + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + Assert.Equal("update", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + } + + [Theory] + [InlineData("--account myanfaccount --snapshot-policy mypolicy --resource-group myrg --subscription sub123", true)] + [InlineData("--snapshot-policy mypolicy --resource-group myrg --subscription sub123", false)] + [InlineData("--account myanfaccount --resource-group myrg --subscription sub123", false)] + [InlineData("--account myanfaccount --snapshot-policy mypolicy --subscription sub123", false)] + [InlineData("", false)] + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.UpdateSnapshotPolicy( + default!, + default!, + default!, + default, + default!, + default, + default, + default, + default, + default, + default, + default, + default, + default, + default, + default, + default, + default, + default, + default, + default, + TestContext.Current.CancellationToken) + .ReturnsForAnyArgs(BuildExpectedPolicy()); + } + + // Act + var response = await ExecuteCommandAsync(args); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + if (shouldSucceed) + { + Assert.Equal("Success", response.Message); + Assert.NotNull(response.Results); + } + else + { + Assert.True( + response.Message.Contains("provided", StringComparison.OrdinalIgnoreCase) || + response.Message.Contains("required", StringComparison.OrdinalIgnoreCase), + $"Expected a validation message, got: {response.Message}"); + } + } + + [Fact] + public async Task ExecuteAsync_UpdatesSnapshotPolicy_WithExpandedParameters() + { + var expectedPolicy = BuildExpectedPolicy(); + + Service.UpdateSnapshotPolicy( + Arg.Is("myanfaccount"), + Arg.Is("mypolicy"), + Arg.Is("myrg"), + Arg.Is("eastus"), + Arg.Is("sub123"), + Arg.Is(5), + Arg.Is(3), + Arg.Is(12), + Arg.Is(15), + Arg.Is(7), + Arg.Is("Monday"), + Arg.Is(4), + Arg.Is("1,15"), + Arg.Is(2), + Arg.Is(false), + Arg.Is(6), + Arg.Is(25), + Arg.Is(7), + Arg.Is(35), + Arg.Is?>(tags => tags != null && tags.Count == 1 && tags["env"] == "test"), + Arg.Any(), + Arg.Any()) + .Returns(expectedPolicy); + + // Act + var response = await ExecuteCommandAsync([ + "--account", "myanfaccount", + "--snapshot-policy", "mypolicy", + "--resource-group", "myrg", + "--location", "eastus", + "--subscription", "sub123", + "--hourly-schedule-minute", "5", + "--hourly-schedule-snapshots-to-keep", "3", + "--daily-schedule-hour", "12", + "--daily-schedule-minute", "15", + "--daily-schedule-snapshots-to-keep", "7", + "--weekly-schedule-day", "Monday", + "--weekly-schedule-hour", "6", + "--weekly-schedule-minute", "25", + "--weekly-schedule-snapshots-to-keep", "4", + "--monthly-schedule-days-of-month", "1,15", + "--monthly-schedule-hour", "7", + "--monthly-schedule-minute", "35", + "--monthly-schedule-snapshots-to-keep", "2", + "--enabled", "false", + "--tags", "{\"env\":\"test\"}" + ]); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await Service.Received(1).UpdateSnapshotPolicy( + "myanfaccount", + "mypolicy", + "myrg", + "eastus", + "sub123", + 5, + 3, + 12, + 15, + 7, + "Monday", + 4, + "1,15", + 2, + false, + 6, + 25, + 7, + 35, + Arg.Is?>(tags => tags != null && tags.Count == 1 && tags["env"] == "test"), + null, + Arg.Any()); + } + + [Theory] + [InlineData("--no-wait", "no-wait")] + [InlineData("--acquirePolicyToken", "acquirePolicyToken")] + [InlineData("--changeReference CR-123", "changeReference")] + [InlineData("--add properties.enabled=false", "add")] + [InlineData("--set properties.enabled=false", "set")] + [InlineData("--remove properties.weeklySchedule", "remove")] + [InlineData("--force-string", "force-string")] + public async Task ExecuteAsync_RejectsUnsupportedArguments(string extraArgs, string expectedArgument) + { + var response = await ExecuteCommandAsync($"--account myanfaccount --snapshot-policy mypolicy --resource-group myrg --subscription sub123 {extraArgs}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains(expectedArgument, response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_RejectsInvalidTagsJson() + { + // Act + var response = await ExecuteCommandAsync([ + "--account", "myanfaccount", + "--snapshot-policy", "mypolicy", + "--resource-group", "myrg", + "--subscription", "sub123", + "--tags", "{invalid-json}" + ]); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("Invalid tags JSON format", response.Message); + } + + [Fact] + public async Task ExecuteAsync_HandlesException() + { + Service.UpdateSnapshotPolicy( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new Exception("Test error")); + + // Act + var response = await ExecuteCommandAsync([ + "--account", "myanfaccount", + "--snapshot-policy", "mypolicy", + "--resource-group", "myrg", + "--subscription", "sub123" + ]); + + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + Assert.Contains("Test error", response.Message); + Assert.Contains("troubleshooting", response.Message); + } + + [Fact] + public async Task ExecuteAsync_DeserializationValidation() + { + var expectedPolicy = BuildExpectedPolicy(); + + Service.UpdateSnapshotPolicy( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .Returns(expectedPolicy); + + // Act + var response = await ExecuteCommandAsync([ + "--account", "myanfaccount", + "--snapshot-policy", "mypolicy", + "--resource-group", "myrg", + "--subscription", "sub123" + ]); + + Assert.NotNull(response.Results); + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, NetAppFilesJsonContext.Default.SnapshotPolicyUpdateCommandResult); + + Assert.NotNull(result); + Assert.NotNull(result.SnapshotPolicy); + Assert.Equal("myanfaccount/mypolicy", result.SnapshotPolicy.Name); + Assert.Equal("eastus", result.SnapshotPolicy.Location); + Assert.Equal("myrg", result.SnapshotPolicy.ResourceGroup); + Assert.True(result.SnapshotPolicy.Enabled); + } + + [Fact] + public async Task BindOptions_BindsOptionsCorrectly() + { + // Act + var args = CommandDefinition.Parse([ + "--account", "myanfaccount", + "--snapshot-policy", "mypolicy", + "--resource-group", "myrg", + "--subscription", "sub123", + "--hourly-schedule-minute", "15", + "--hourly-schedule-snapshots-to-keep", "3", + "--daily-schedule-hour", "6", + "--daily-schedule-minute", "30", + "--daily-schedule-snapshots-to-keep", "7", + "--weekly-schedule-day", "Wednesday", + "--weekly-schedule-hour", "5", + "--weekly-schedule-minute", "20", + "--weekly-schedule-snapshots-to-keep", "2", + "--monthly-schedule-days-of-month", "1,15", + "--monthly-schedule-hour", "8", + "--monthly-schedule-minute", "45", + "--monthly-schedule-snapshots-to-keep", "1", + "--enabled", "true", + "--tags", "{\"env\":\"test\"}" + ]); + + Assert.Empty(args.Errors); + } + + private static SnapshotPolicyCreateResult BuildExpectedPolicy() + { + return new SnapshotPolicyCreateResult( + Id: SnapshotPolicyResourceId, + Name: "myanfaccount/mypolicy", + Type: "Microsoft.NetApp/netAppAccounts/snapshotPolicies", + Location: "eastus", + ResourceGroup: "myrg", + ProvisioningState: "Succeeded", + Enabled: true, + HourlyScheduleMinute: 0, + HourlyScheduleSnapshotsToKeep: 5, + DailyScheduleHour: 12, + DailyScheduleMinute: 0, + DailyScheduleSnapshotsToKeep: 5, + WeeklyScheduleDay: "Monday", + WeeklyScheduleSnapshotsToKeep: 4, + MonthlyScheduleDaysOfMonth: "1,15", + MonthlyScheduleSnapshotsToKeep: 2); + } +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources-post.ps1 b/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources-post.ps1 new file mode 100644 index 0000000000..f2e074a540 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources-post.ps1 @@ -0,0 +1,17 @@ +param( + [string] $TenantId, + [string] $TestApplicationId, + [string] $ResourceGroupName, + [string] $BaseName, + [hashtable] $DeploymentOutputs, + [hashtable] $AdditionalParameters +) + +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot/../../../eng/common/scripts/common.ps1" +. "$PSScriptRoot/../../../eng/scripts/helpers/TestResourcesHelpers.ps1" + +New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot + +Write-Host "Azure NetApp Files post-deployment setup completed." \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep new file mode 100644 index 0000000000..263f9cd0bc --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep @@ -0,0 +1,26 @@ +targetScope = 'resourceGroup' + +@minLength(3) +@maxLength(24) +@description('The base resource name used by the live tests.') +param baseName string = resourceGroup().name + +@description('The client object ID to grant access to the test resource group.') +param testApplicationOid string + +resource contributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + scope: subscription() + name: 'b24988ac-6180-42a0-ab88-20f7382dd24c' +} + +resource netAppFilesContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, testApplicationOid, contributorRoleDefinition.id) + properties: { + principalId: testApplicationOid + principalType: 'ServicePrincipal' + roleDefinitionId: contributorRoleDefinition.id + description: 'Contributor for Azure NetApp Files live tests' + } +} + +output NETAPP_ACCOUNT_NAME string = baseName \ No newline at end of file From ebd9a5832dddcdec8d149a641ed64aa80c363ff6 Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Thu, 17 Sep 2026 14:07:58 -0400 Subject: [PATCH 02/16] chore: update username to use github username --- ...account-create.yaml => stumpc-netappfiles-account-create.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename servers/Azure.Mcp.Server/changelog-entries/{b-cstump-netappfiles-account-create.yaml => stumpc-netappfiles-account-create.yaml} (100%) diff --git a/servers/Azure.Mcp.Server/changelog-entries/b-cstump-netappfiles-account-create.yaml b/servers/Azure.Mcp.Server/changelog-entries/stumpc-netappfiles-account-create.yaml similarity index 100% rename from servers/Azure.Mcp.Server/changelog-entries/b-cstump-netappfiles-account-create.yaml rename to servers/Azure.Mcp.Server/changelog-entries/stumpc-netappfiles-account-create.yaml From 8c9203b2a5e6e2e948ab42e5b1acbeda838abf30 Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Thu, 17 Sep 2026 14:09:59 -0400 Subject: [PATCH 03/16] chore: update CODEOWNERS for NetAppFiles Service Owners --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index be3666dc36..9687ad6d46 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -362,7 +362,7 @@ /tools/Azure.Mcp.Tools.NetAppFiles/ @stumpc @microsoft/azure-mcp # ServiceLabel: %tools-NetAppFiles -# ServiceOwners: @microsoft/azure-mcp +# ServiceOwners: @stumpc @microsoft/azure-mcp # PRLabel: %tools-Optimization /tools/Azure.Mcp.Tools.Optimization/ @sanjaytholanim @liangchenmicrosoft From 33af56e76c963fed176bc3ef0d432143b09fc9e4 Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Thu, 17 Sep 2026 15:30:50 -0400 Subject: [PATCH 04/16] feat: add netappfiles pool create, get, update tools --- servers/Azure.Mcp.Server/README.md | 8 +- .../stumpc-netappfiles-pool.yaml | 3 + .../Azure.Mcp.Server/docs/azmcp-commands.md | 42 +++ .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 6 + .../src/Resources/consolidated-tools.json | 39 ++- .../src/AssemblyInfo.cs | 2 +- .../Commands/Account/AccountCreateCommand.cs | 2 +- .../src/Commands/Account/AccountGetCommand.cs | 2 +- .../Commands/Account/AccountUpdateCommand.cs | 2 +- .../src/Commands/NetAppFilesJsonContext.cs | 7 +- .../src/Commands/Pool/PoolCreateCommand.cs | 169 ++++++++++++ .../src/Commands/Pool/PoolGetCommand.cs | 99 +++++++ .../src/Commands/Pool/PoolUpdateCommand.cs | 160 +++++++++++ .../src/Models/NetAppFilesAccount.cs | 2 +- .../src/Models/NetAppFilesPool.cs | 17 ++ .../src/NetAppFilesSetup.cs | 13 +- .../Options/Account/AccountCreateOptions.cs | 2 +- .../src/Options/Account/AccountGetOptions.cs | 2 +- .../Options/Account/AccountUpdateOptions.cs | 2 +- .../src/Options/Pool/PoolCreateOptions.cs | 49 ++++ .../src/Options/Pool/PoolGetOptions.cs | 25 ++ .../src/Options/Pool/PoolUpdateOptions.cs | 40 +++ .../Services/INetAppFilesAccountService.cs | 2 +- .../src/Services/INetAppFilesPoolService.cs | 46 ++++ .../src/Services/NetAppFilesAccountService.cs | 2 +- .../src/Services/NetAppFilesPoolService.cs | 155 +++++++++++ .../src/Validation/AccountNameValidator.cs | 2 +- .../src/Validation/PoolNameValidator.cs | 27 ++ .../Account/AccountCreateCommandTests.cs | 2 +- .../Account/AccountGetCommandTests.cs | 2 +- .../Account/AccountUpdateCommandTests.cs | 2 +- .../NetAppFilesCommandTests.cs | 137 +++++++++- .../Pool/PoolCreateCommandTests.cs | 231 ++++++++++++++++ .../Pool/PoolGetCommandTests.cs | 128 +++++++++ .../Pool/PoolUpdateCommandTests.cs | 253 ++++++++++++++++++ 35 files changed, 1662 insertions(+), 20 deletions(-) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/stumpc-netappfiles-pool.yaml create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolCreateCommand.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolGetCommand.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolUpdateCommand.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesPool.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolCreateOptions.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolGetOptions.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolUpdateOptions.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesPoolService.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesPoolService.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/PoolNameValidator.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolCreateCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolGetCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolUpdateCommandTests.cs diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 221d243e5e..d49e533645 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -1250,6 +1250,12 @@ Example prompts that generate Azure CLI commands: * "Continue to the next orchestration step after I complete the previous action" * "Send brownfield analysis findings to continue migration planning" +### Azure NetApp Files + +* "Create a 4 TiB Premium capacity pool named 'my-pool' in Azure NetApp Files account 'my-account' in resource group 'my-resource-group'" +* "Show the configuration and provisioning state of capacity pool 'my-pool' in Azure NetApp Files account 'my-account'" +* "Resize capacity pool 'my-pool' in Azure NetApp Files account 'my-account' to 8 TiB" + ### 📊 Azure Quota * "Check resource usage and quota for 'Microsoft.App/containerApps' in region 'eastus'" @@ -1439,7 +1445,7 @@ The Azure MCP Server provides tools for interacting with **45+ Azure service are - 🧮 **Azure Data Explorer** - Analytics queries and KQL - 🐬 **Azure Database for MySQL** - MySQL database management - 🐘 **Azure Database for PostgreSQL** - PostgreSQL database management -- **Azure NetApp Files** - Enterprise file storage account management +- **Azure NetApp Files** - Enterprise file storage account and capacity pool management - 🏭 **Azure Device Registry** - Device Registry namespace management - 📊 **Azure Event Grid** - Event routing and management - ⚡ **Azure Event Hubs** - Namespace, event hub, and consumer group management diff --git a/servers/Azure.Mcp.Server/changelog-entries/stumpc-netappfiles-pool.yaml b/servers/Azure.Mcp.Server/changelog-entries/stumpc-netappfiles-pool.yaml new file mode 100644 index 0000000000..4205e9b2a5 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/stumpc-netappfiles-pool.yaml @@ -0,0 +1,3 @@ +changes: + - section: Features Added + description: Added `netappfiles pool create`, `netappfiles pool get`, and `netappfiles pool update` commands for managing Azure NetApp Files capacity pools. \ No newline at end of file diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index d7ce060069..c2a3e06352 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -2694,6 +2694,48 @@ azmcp netappfiles account update --account \ [--tags ] \ [--nfs-v4-id-domain ] \ [--tenant ] + +# Creates a capacity pool in an Azure NetApp Files account. +# Size is specified in TiB and must be a positive multiple of 4. +# Returns the pool configuration and provisioning state. +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp netappfiles pool create --account \ + --pool \ + --size \ + --service-level \ + --resource-group \ + --subscription \ + [--location ] \ + [--qos-type ] \ + [--cool-access ] \ + [--encryption-type ] \ + [--custom-throughput-mibps ] \ + [--tags ] \ + [--tenant ] + +# Gets a capacity pool by name from an Azure NetApp Files account. +# Returns the pool configuration, resource ID, location, and provisioning state. +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp netappfiles pool get --account \ + --pool \ + --resource-group \ + --subscription \ + [--tenant ] + +# Updates mutable properties of an Azure NetApp Files capacity pool. +# Providing an empty JSON object for --tags removes all tags. +# Returns the updated pool configuration and provisioning state. +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp netappfiles pool update --account \ + --pool \ + --resource-group \ + --subscription \ + [--size ] \ + [--qos-type ] \ + [--cool-access ] \ + [--custom-throughput-mibps ] \ + [--tags ] \ + [--tenant ] ``` ### Azure Database for MySQL Operations diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index ca99a7f0ea..adb63ec145 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -610,6 +610,12 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | netappfiles_account_get | Show me details for NetApp Files account \ in resource group \ | none | | netappfiles_account_update | Set the tags on Azure NetApp Files account \ in resource group \ to {"environment":"production"} | none | | netappfiles_account_update | Update the NFSv4 ID mapping domain for NetApp Files account \ in resource group \ to example.com | none | +| netappfiles_pool_create | Create a 4 TiB Premium capacity pool named \ in Azure NetApp Files account \ in resource group \ | none | +| netappfiles_pool_create | Create an Azure NetApp Files capacity pool called \ with Flexible service level, Manual QoS, and 128 MiB/s custom throughput in account \ | none | +| netappfiles_pool_get | Get capacity pool \ from Azure NetApp Files account \ in resource group \ | none | +| netappfiles_pool_get | Show me the configuration and provisioning state of NetApp Files capacity pool \ in account \ | none | +| netappfiles_pool_update | Resize Azure NetApp Files capacity pool \ in account \ to 8 TiB | none | +| netappfiles_pool_update | Set the tags on NetApp Files capacity pool \ in account \ to {"environment":"production"} | none | ## Azure Database for PostgreSQL diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index e4b8652049..e6f77847b2 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -2415,7 +2415,7 @@ }, { "name": "get_azure_storage_details", - "description": "Get details about Azure Storage resources including Storage accounts, Azure NetApp Files accounts, blob containers, blob data, and tables. Also manage Azure Managed Lustre (AMLFS) filesystems: list filesystems with provisioning state and capacity, retrieve available SKUs with bandwidth and scale targets, calculate required subnet sizes for deployment planning, and validate subnet configurations against SKU and size requirements.", + "description": "Get details about Azure Storage resources including Storage accounts, Azure NetApp Files accounts and capacity pools, blob containers, blob data, and tables. Also manage Azure Managed Lustre (AMLFS) filesystems: list filesystems with provisioning state and capacity, retrieve available SKUs with bandwidth and scale targets, calculate required subnet sizes for deployment planning, and validate subnet configurations against SKU and size requirements.", "toolMetadata": { "destructive": { "value": false, @@ -2450,6 +2450,7 @@ "managedlustre_fs_blob_import_get", "managedlustre_fs_expansion_get", "netappfiles_account_get", + "netappfiles_pool_get", "storage_account_get", "storage_blob_container_get", "storage_blob_get", @@ -2663,7 +2664,7 @@ }, { "name": "create_azure_storage", - "description": "Create Azure Storage accounts, blob containers, Azure Managed Lustre filesystems, and Azure NetApp Files accounts.", + "description": "Create Azure Storage accounts, blob containers, Azure Managed Lustre filesystems, and Azure NetApp Files accounts and capacity pools.", "toolMetadata": { "destructive": { "value": true, @@ -2694,6 +2695,7 @@ "storage_account_create", "storage_blob_container_create", "netappfiles_account_create", + "netappfiles_pool_create", "managedlustre_fs_create", "managedlustre_fs_blob_autoexport_create", "managedlustre_fs_blob_autoimport_create", @@ -2734,6 +2736,39 @@ "netappfiles_account_update" ] }, + { + "name": "update_azure_netapp_files_capacity_pools", + "description": "Update the size, QoS type, cool-access setting, custom throughput, or tags of Azure NetApp Files capacity pools.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by updating data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "netappfiles_pool_update" + ] + }, { "name": "update_azure_managed_lustre_filesystems", "description": "Update existing Azure Managed Lustre filesystem configurations.", diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs index 64899e5635..90715b9889 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs @@ -3,4 +3,4 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("Azure.Mcp.Tools.NetAppFiles.Tests")] \ No newline at end of file +[assembly: InternalsVisibleTo("Azure.Mcp.Tools.NetAppFiles.Tests")] diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs index ab9bd3fd72..6a351bd5b8 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs @@ -93,4 +93,4 @@ public override void ValidateOptions(AccountCreateOptions options, ValidationRes }; public record AccountCreateResult(NetAppFilesAccount Account); -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs index 7d79ddd361..8be61e7fd8 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs @@ -89,4 +89,4 @@ public override void ValidateOptions(AccountGetOptions options, ValidationResult }; public record AccountGetResult(NetAppFilesAccount Account); -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs index abba77c2b2..da3bdc08f6 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs @@ -123,4 +123,4 @@ public override void ValidateOptions(AccountUpdateOptions options, ValidationRes }; public record AccountUpdateResult(NetAppFilesAccount Account); -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs index 4ee43c2910..c339fc116b 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using Azure.Mcp.Tools.NetAppFiles.Commands.Account; +using Azure.Mcp.Tools.NetAppFiles.Commands.Pool; using Azure.Mcp.Tools.NetAppFiles.Models; namespace Azure.Mcp.Tools.NetAppFiles.Commands; @@ -12,7 +13,11 @@ namespace Azure.Mcp.Tools.NetAppFiles.Commands; [JsonSerializable(typeof(AccountUpdateCommand.AccountUpdateResult))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(NetAppFilesAccount))] +[JsonSerializable(typeof(NetAppFilesPool))] +[JsonSerializable(typeof(PoolCreateCommand.PoolCreateResult))] +[JsonSerializable(typeof(PoolGetCommand.PoolGetResult))] +[JsonSerializable(typeof(PoolUpdateCommand.PoolUpdateResult))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] -internal partial class NetAppFilesJsonContext : JsonSerializerContext; \ No newline at end of file +internal partial class NetAppFilesJsonContext : JsonSerializerContext; diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolCreateCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolCreateCommand.cs new file mode 100644 index 0000000000..ef77d42d1d --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolCreateCommand.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Options.Pool; +using Azure.Mcp.Tools.NetAppFiles.Services; +using Azure.Mcp.Tools.NetAppFiles.Validation; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Commands.Pool; + +[CommandMetadata( + Id = "ab3278eb-4048-4f65-b058-3a0c350a8236", + Name = "create", + Title = "Create Azure NetApp Files Capacity Pool", + Description = "Creates a capacity pool in an Azure NetApp Files account. Requires an account, pool name, size in TiB, service level, resource group, and subscription. Returns the created pool configuration and provisioning state.", + OperationPlane = ToolOperationPlane.Control, + Destructive = true, + Idempotent = true, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class PoolCreateCommand( + ILogger logger, + INetAppFilesPoolService service, + ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand(subscriptionResolver) +{ + private const long BytesPerTebibyte = 1_099_511_627_776; + private static readonly string[] s_serviceLevels = ["Flexible", "Premium", "Standard", "StandardZRS", "Ultra"]; + private static readonly string[] s_qosTypes = ["Auto", "Manual"]; + private static readonly string[] s_encryptionTypes = ["Single", "Double"]; + + private readonly ILogger _logger = logger; + private readonly INetAppFilesPoolService _service = service; + + public override async Task ExecuteAsync( + CommandContext context, + PoolCreateOptions options, + CancellationToken cancellationToken) + { + try + { + var tags = options.Tags is null + ? null + : JsonSerializer.Deserialize(options.Tags, NetAppFilesJsonContext.Default.DictionaryStringString); + + var pool = await _service.CreatePoolAsync( + options.Account, + options.Pool, + checked(options.Size * BytesPerTebibyte), + options.ServiceLevel, + options.ResourceGroup, + options.Subscription!, + options.Location, + options.QosType, + options.CoolAccess, + options.EncryptionType, + options.CustomThroughputMibps, + tags, + options.Tenant, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new PoolCreateResult(pool), + NetAppFilesJsonContext.Default.PoolCreateResult); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error creating Azure NetApp Files capacity pool. Account: {Account}, Pool: {Pool}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}", + options.Account, + options.Pool, + options.ResourceGroup, + options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + public override void ValidateOptions(PoolCreateOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + if (!AccountNameValidator.IsValid(options.Account)) + { + validationResult.Errors.Add(AccountNameValidator.ErrorMessage); + } + + if (!Validation.PoolNameValidator.IsValid(options.Pool)) + { + validationResult.Errors.Add(Validation.PoolNameValidator.ErrorMessage); + } + + if (options.Size <= 0 || options.Size % 4 != 0 || options.Size > long.MaxValue / BytesPerTebibyte) + { + validationResult.Errors.Add("--size must be a positive multiple of 4 TiB and small enough to represent in bytes."); + } + + if (!Contains(s_serviceLevels, options.ServiceLevel)) + { + validationResult.Errors.Add("--service-level must be one of: Flexible, Premium, Standard, StandardZRS, Ultra."); + } + + if (options.QosType is not null && !Contains(s_qosTypes, options.QosType)) + { + validationResult.Errors.Add("--qos-type must be one of: Auto, Manual."); + } + + if (options.EncryptionType is not null && !Contains(s_encryptionTypes, options.EncryptionType)) + { + validationResult.Errors.Add("--encryption-type must be one of: Single, Double."); + } + + if (options.CustomThroughputMibps is not null && + (!options.ServiceLevel.Equals("Flexible", StringComparison.OrdinalIgnoreCase) || + !string.Equals(options.QosType, "Manual", StringComparison.OrdinalIgnoreCase))) + { + validationResult.Errors.Add("--custom-throughput-mibps requires --service-level Flexible and --qos-type Manual."); + } + + if (options.CustomThroughputMibps <= 0) + { + validationResult.Errors.Add("--custom-throughput-mibps must be greater than zero."); + } + + if (options.Tags is not null) + { + try + { + if (JsonSerializer.Deserialize(options.Tags, NetAppFilesJsonContext.Default.DictionaryStringString) is null) + { + validationResult.Errors.Add("--tags must be a JSON key-value object."); + } + } + catch (JsonException) + { + validationResult.Errors.Add("--tags must be a JSON key-value object with string values."); + } + } + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + KeyNotFoundException => "Resource group not found. Verify the resource group exists and you have access.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.Conflict => + "The Azure NetApp Files capacity pool could not be created because of a resource conflict. Verify the account, pool name, and resource state.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed creating the Azure NetApp Files capacity pool. Verify that you have the required RBAC permissions.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.NotFound => + "Azure NetApp Files account not found. Verify the account and resource group names and that you have access.", + _ => base.GetErrorMessage(ex) + }; + + private static bool Contains(string[] values, string value) => + values.Contains(value, StringComparer.OrdinalIgnoreCase); + + public record PoolCreateResult(NetAppFilesPool Pool); +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolGetCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolGetCommand.cs new file mode 100644 index 0000000000..3b32368942 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolGetCommand.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Options.Pool; +using Azure.Mcp.Tools.NetAppFiles.Services; +using Azure.Mcp.Tools.NetAppFiles.Validation; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Commands.Pool; + +[CommandMetadata( + Id = "81710f89-6069-45df-9c5d-44a6063330d9", + Name = "get", + Title = "Get Azure NetApp Files Capacity Pool", + Description = "Gets a capacity pool by name from an Azure NetApp Files account. Returns the pool configuration, resource ID, location, and provisioning state.", + OperationPlane = ToolOperationPlane.Control, + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false)] +public sealed class PoolGetCommand( + ILogger logger, + INetAppFilesPoolService service, + ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand(subscriptionResolver) +{ + private readonly ILogger _logger = logger; + private readonly INetAppFilesPoolService _service = service; + + public override async Task ExecuteAsync( + CommandContext context, + PoolGetOptions options, + CancellationToken cancellationToken) + { + try + { + var pool = await _service.GetPoolAsync( + options.Account, + options.Pool, + options.ResourceGroup, + options.Subscription!, + options.Tenant, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new PoolGetResult(pool), + NetAppFilesJsonContext.Default.PoolGetResult); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error getting Azure NetApp Files capacity pool. Account: {Account}, Pool: {Pool}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}", + options.Account, + options.Pool, + options.ResourceGroup, + options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + public override void ValidateOptions(PoolGetOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + if (!AccountNameValidator.IsValid(options.Account)) + { + validationResult.Errors.Add(AccountNameValidator.ErrorMessage); + } + + if (!Validation.PoolNameValidator.IsValid(options.Pool)) + { + validationResult.Errors.Add(Validation.PoolNameValidator.ErrorMessage); + } + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + KeyNotFoundException => "Resource group not found. Verify the resource group exists and you have access.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed getting the Azure NetApp Files capacity pool. Verify that you have the required RBAC permissions.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.NotFound => + "Azure NetApp Files capacity pool not found. Verify the account, pool, and resource group names.", + _ => base.GetErrorMessage(ex) + }; + + public record PoolGetResult(NetAppFilesPool Pool); +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolUpdateCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolUpdateCommand.cs new file mode 100644 index 0000000000..1319f74028 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolUpdateCommand.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Options.Pool; +using Azure.Mcp.Tools.NetAppFiles.Services; +using Azure.Mcp.Tools.NetAppFiles.Validation; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Commands.Pool; + +[CommandMetadata( + Id = "158311f0-8304-43c0-a413-80c854643eaf", + Name = "update", + Title = "Update Azure NetApp Files Capacity Pool", + Description = "Updates the size, QoS type, cool-access setting, custom throughput, or tags of an Azure NetApp Files capacity pool. Returns the updated pool configuration and provisioning state.", + OperationPlane = ToolOperationPlane.Control, + Destructive = true, + Idempotent = true, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class PoolUpdateCommand( + ILogger logger, + INetAppFilesPoolService service, + ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand(subscriptionResolver) +{ + private const long BytesPerTebibyte = 1_099_511_627_776; + private static readonly string[] s_qosTypes = ["Auto", "Manual"]; + + private readonly ILogger _logger = logger; + private readonly INetAppFilesPoolService _service = service; + + public override async Task ExecuteAsync( + CommandContext context, + PoolUpdateOptions options, + CancellationToken cancellationToken) + { + try + { + var tags = options.Tags is null + ? null + : JsonSerializer.Deserialize(options.Tags, NetAppFilesJsonContext.Default.DictionaryStringString); + + var pool = await _service.UpdatePoolAsync( + options.Account, + options.Pool, + options.ResourceGroup, + options.Subscription!, + options.Size is null ? null : checked(options.Size.Value * BytesPerTebibyte), + options.QosType, + options.CoolAccess, + options.CustomThroughputMibps, + tags, + options.Tenant, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new PoolUpdateResult(pool), + NetAppFilesJsonContext.Default.PoolUpdateResult); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error updating Azure NetApp Files capacity pool. Account: {Account}, Pool: {Pool}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}", + options.Account, + options.Pool, + options.ResourceGroup, + options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + public override void ValidateOptions(PoolUpdateOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + if (!AccountNameValidator.IsValid(options.Account)) + { + validationResult.Errors.Add(AccountNameValidator.ErrorMessage); + } + + if (!Validation.PoolNameValidator.IsValid(options.Pool)) + { + validationResult.Errors.Add(Validation.PoolNameValidator.ErrorMessage); + } + + if (options.Size is not null && + (options.Size <= 0 || options.Size % 4 != 0 || options.Size > long.MaxValue / BytesPerTebibyte)) + { + validationResult.Errors.Add("--size must be a positive multiple of 4 TiB and small enough to represent in bytes."); + } + + if (options.QosType is not null && !s_qosTypes.Contains(options.QosType, StringComparer.OrdinalIgnoreCase)) + { + validationResult.Errors.Add("--qos-type must be one of: Auto, Manual."); + } + + if (options.CustomThroughputMibps <= 0) + { + validationResult.Errors.Add("--custom-throughput-mibps must be greater than zero."); + } + + if (options.CustomThroughputMibps is not null && + string.Equals(options.QosType, "Auto", StringComparison.OrdinalIgnoreCase)) + { + validationResult.Errors.Add("--custom-throughput-mibps cannot be used with --qos-type Auto."); + } + + if (options.Size is null && + options.QosType is null && + options.CoolAccess is null && + options.CustomThroughputMibps is null && + options.Tags is null) + { + validationResult.Errors.Add("At least one update property must be provided: --size, --qos-type, --cool-access, --custom-throughput-mibps, or --tags."); + } + + if (options.Tags is not null) + { + try + { + if (JsonSerializer.Deserialize(options.Tags, NetAppFilesJsonContext.Default.DictionaryStringString) is null) + { + validationResult.Errors.Add("--tags must be a JSON key-value object."); + } + } + catch (JsonException) + { + validationResult.Errors.Add("--tags must be a JSON key-value object with string values."); + } + } + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + KeyNotFoundException => "Resource group not found. Verify the resource group exists and you have access.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.Conflict => + "The Azure NetApp Files capacity pool could not be updated because of a resource conflict. Verify the pool state and update values.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed updating the Azure NetApp Files capacity pool. Verify that you have the required RBAC permissions.", + RequestFailedException requestFailedException when requestFailedException.Status == (int)HttpStatusCode.NotFound => + "Azure NetApp Files capacity pool not found. Verify the account, pool, and resource group names.", + _ => base.GetErrorMessage(ex) + }; + + public record PoolUpdateResult(NetAppFilesPool Pool); +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs index c43a29ddf6..18df250e00 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs @@ -7,4 +7,4 @@ public record NetAppFilesAccount( string Name, string Id, string Location, - string? ProvisioningState); \ No newline at end of file + string? ProvisioningState); diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesPool.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesPool.cs new file mode 100644 index 0000000000..efa2d734c5 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesPool.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.NetAppFiles.Models; + +public record NetAppFilesPool( + string Name, + string Id, + string Location, + long SizeInBytes, + string ServiceLevel, + string? QosType, + bool? CoolAccessEnabled, + string? EncryptionType, + int? CustomThroughputMibps, + IReadOnlyDictionary Tags, + string? ProvisioningState); diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs index aeee731e83..45a9e70591 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Azure.Mcp.Tools.NetAppFiles.Commands.Account; +using Azure.Mcp.Tools.NetAppFiles.Commands.Pool; using Azure.Mcp.Tools.NetAppFiles.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Mcp.Core.Areas; @@ -18,9 +19,13 @@ public class NetAppFilesSetup : IAreaSetup public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } public CommandGroup RegisterCommands(IServiceProvider serviceProvider) @@ -36,6 +41,12 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) account.AddCommand(serviceProvider); account.AddCommand(serviceProvider); + var pool = new CommandGroup("pool", "Azure NetApp Files capacity pool operations."); + root.AddSubGroup(pool); + pool.AddCommand(serviceProvider); + pool.AddCommand(serviceProvider); + pool.AddCommand(serviceProvider); + return root; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs index c32551463d..feb8a71b62 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs @@ -22,4 +22,4 @@ public class AccountCreateOptions : ISubscriptionOption [Option(Description = OptionDescriptions.Tenant)] public string? Tenant { get; set; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs index f96fb42f9f..7c33761aa8 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs @@ -19,4 +19,4 @@ public class AccountGetOptions : ISubscriptionOption [Option(Description = OptionDescriptions.Tenant)] public string? Tenant { get; set; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs index 2c63e1d8b7..b0b5931f7e 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs @@ -25,4 +25,4 @@ public sealed class AccountUpdateOptions : ISubscriptionOption [Option(Description = OptionDescriptions.Tenant)] public string? Tenant { get; set; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolCreateOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolCreateOptions.cs new file mode 100644 index 0000000000..169ea5cd1c --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolCreateOptions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Options.Pool; + +public sealed class PoolCreateOptions : ISubscriptionOption +{ + [Option(Description = "The name of the Azure NetApp Files account that will contain the capacity pool.")] + public required string Account { get; set; } + + [Option(Description = "The name of the capacity pool to create. Must be 1-64 characters, start with an alphanumeric character, and contain only alphanumerics, underscores, and hyphens.")] + public required string Pool { get; set; } + + [Option(Description = "The provisioned pool size in tebibytes. Must be a positive multiple of 4.")] + public required long Size { get; set; } + + [Option(Description = "The service level for the capacity pool. Accepted values: Flexible, Premium, Standard, StandardZRS, Ultra.")] + public required string ServiceLevel { get; set; } + + [Option(Description = "The Azure region for the capacity pool. Defaults to the parent account region.")] + public string? Location { get; set; } + + [Option(Description = "The quality-of-service type for the capacity pool. Accepted values: Auto, Manual.")] + public string? QosType { get; set; } + + [Option(Description = "Whether the capacity pool can contain cool-access-enabled volumes.")] + public bool? CoolAccess { get; set; } + + [Option(Description = "The data-at-rest encryption type for the capacity pool. Accepted values: Single, Double.")] + public string? EncryptionType { get; set; } + + [Option(Description = "The maximum throughput in MiB/s. Supported only when --service-level is Flexible and --qos-type is Manual.")] + public int? CustomThroughputMibps { get; set; } + + [Option(Description = "Resource tags as a JSON key-value object.")] + public string? Tags { get; set; } + + [Option(Description = OptionDescriptions.ResourceGroup)] + public required string ResourceGroup { get; set; } + + [Option(Description = OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolGetOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolGetOptions.cs new file mode 100644 index 0000000000..6a99217c3d --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolGetOptions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Options.Pool; + +public sealed class PoolGetOptions : ISubscriptionOption +{ + [Option(Description = "The name of the Azure NetApp Files account that contains the capacity pool.")] + public required string Account { get; set; } + + [Option(Description = "The name of the capacity pool to retrieve.")] + public required string Pool { get; set; } + + [Option(Description = OptionDescriptions.ResourceGroup)] + public required string ResourceGroup { get; set; } + + [Option(Description = OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolUpdateOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolUpdateOptions.cs new file mode 100644 index 0000000000..674f2a5be2 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Pool/PoolUpdateOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.NetAppFiles.Options.Pool; + +public sealed class PoolUpdateOptions : ISubscriptionOption +{ + [Option(Description = "The name of the Azure NetApp Files account that contains the capacity pool.")] + public required string Account { get; set; } + + [Option(Description = "The name of the capacity pool to update.")] + public required string Pool { get; set; } + + [Option(Description = "The new provisioned pool size in tebibytes. Must be a positive multiple of 4.")] + public long? Size { get; set; } + + [Option(Description = "The new quality-of-service type for the capacity pool. Accepted values: Auto, Manual.")] + public string? QosType { get; set; } + + [Option(Description = "Whether the capacity pool can contain cool-access-enabled volumes.")] + public bool? CoolAccess { get; set; } + + [Option(Description = "The new maximum throughput in MiB/s. Supported only for Flexible service-level pools using Manual QoS.")] + public int? CustomThroughputMibps { get; set; } + + [Option(Description = "Resource tags as a JSON key-value object. Providing an empty object removes all tags.")] + public string? Tags { get; set; } + + [Option(Description = OptionDescriptions.ResourceGroup)] + public required string ResourceGroup { get; set; } + + [Option(Description = OptionDescriptions.Subscription)] + public string? Subscription { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs index 0d2d827318..7c423a6fbd 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs @@ -30,4 +30,4 @@ Task CreateAccountAsync( string subscription, string? tenant = null, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesPoolService.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesPoolService.cs new file mode 100644 index 0000000000..2f28091be7 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesPoolService.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.NetAppFiles.Models; + +namespace Azure.Mcp.Tools.NetAppFiles.Services; + +public interface INetAppFilesPoolService +{ + Task GetPoolAsync( + string account, + string pool, + string resourceGroup, + string subscription, + string? tenant = null, + CancellationToken cancellationToken = default); + + Task CreatePoolAsync( + string account, + string pool, + long sizeInBytes, + string serviceLevel, + string resourceGroup, + string subscription, + string? location = null, + string? qosType = null, + bool? coolAccess = null, + string? encryptionType = null, + int? customThroughputMibps = null, + IReadOnlyDictionary? tags = null, + string? tenant = null, + CancellationToken cancellationToken = default); + + Task UpdatePoolAsync( + string account, + string pool, + string resourceGroup, + string subscription, + long? sizeInBytes = null, + string? qosType = null, + bool? coolAccess = null, + int? customThroughputMibps = null, + IReadOnlyDictionary? tags = null, + string? tenant = null, + CancellationToken cancellationToken = default); +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs index 9f7ff65319..f5fec9ef61 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs @@ -98,4 +98,4 @@ public async Task UpdateAccountAsync( account.Data.Id.ToString(), account.Data.Location.ToString(), account.Data.ProvisioningState?.ToString()); -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesPoolService.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesPoolService.cs new file mode 100644 index 0000000000..88c458defd --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesPoolService.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure; +using Azure.Core; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.ResourceManager.NetApp; +using Azure.ResourceManager.NetApp.Models; + +namespace Azure.Mcp.Tools.NetAppFiles.Services; + +public class NetAppFilesPoolService(IAzureService azureService) + : BaseAzureService(azureService), INetAppFilesPoolService +{ + public async Task GetPoolAsync( + string account, + string pool, + string resourceGroup, + string subscription, + string? tenant = null, + CancellationToken cancellationToken = default) + { + var resourceGroupResource = await AzureService.GetResourceGroupResource( + subscription, + resourceGroup, + tenant, + cancellationToken: cancellationToken) + ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); + + var accountResource = resourceGroupResource.GetNetAppAccount(account, cancellationToken).Value; + var poolResource = accountResource.GetCapacityPool(pool, cancellationToken); + + return Map(poolResource.Value); + } + + public async Task CreatePoolAsync( + string account, + string pool, + long sizeInBytes, + string serviceLevel, + string resourceGroup, + string subscription, + string? location = null, + string? qosType = null, + bool? coolAccess = null, + string? encryptionType = null, + int? customThroughputMibps = null, + IReadOnlyDictionary? tags = null, + string? tenant = null, + CancellationToken cancellationToken = default) + { + var resourceGroupResource = await AzureService.GetResourceGroupResource( + subscription, + resourceGroup, + tenant, + cancellationToken: cancellationToken) + ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); + + var accountResource = resourceGroupResource.GetNetAppAccount(account, cancellationToken).Value; + var poolLocation = location is null ? accountResource.Data.Location : new AzureLocation(location); + var poolData = new CapacityPoolData(poolLocation, sizeInBytes, new NetAppFileServiceLevel(serviceLevel)) + { + IsCoolAccessEnabled = coolAccess, + CustomThroughputMibpsInt = customThroughputMibps + }; + + if (qosType is not null) + { + poolData.QosType = new CapacityPoolQosType(qosType); + } + + if (encryptionType is not null) + { + poolData.EncryptionType = new CapacityPoolEncryptionType(encryptionType); + } + + if (tags is not null) + { + foreach (var tag in tags) + { + poolData.Tags[tag.Key] = tag.Value; + } + } + + var operation = await accountResource + .GetCapacityPools() + .CreateOrUpdateAsync(WaitUntil.Started, pool, poolData, cancellationToken); + + await WaitForLroCompletionAsync(operation, cancellationToken); + + return Map(operation.Value); + } + + public async Task UpdatePoolAsync( + string account, + string pool, + string resourceGroup, + string subscription, + long? sizeInBytes = null, + string? qosType = null, + bool? coolAccess = null, + int? customThroughputMibps = null, + IReadOnlyDictionary? tags = null, + string? tenant = null, + CancellationToken cancellationToken = default) + { + var resourceGroupResource = await AzureService.GetResourceGroupResource( + subscription, + resourceGroup, + tenant, + cancellationToken: cancellationToken) + ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); + + var accountResource = resourceGroupResource.GetNetAppAccount(account, cancellationToken).Value; + var poolResource = accountResource.GetCapacityPool(pool, cancellationToken).Value; + var patch = new CapacityPoolPatch(poolResource.Data.Location) + { + Size = sizeInBytes, + IsCoolAccessEnabled = coolAccess, + CustomThroughputMibpsInt = customThroughputMibps + }; + + if (qosType is not null) + { + patch.QosType = new CapacityPoolQosType(qosType); + } + + if (tags is not null) + { + foreach (var tag in tags) + { + patch.Tags[tag.Key] = tag.Value; + } + } + + var operation = await poolResource.UpdateAsync(WaitUntil.Started, patch, cancellationToken); + await WaitForLroCompletionAsync(operation, cancellationToken); + + return Map(operation.Value); + } + + private static NetAppFilesPool Map(CapacityPoolResource pool) => new( + pool.Data.Name, + pool.Data.Id.ToString(), + pool.Data.Location.ToString(), + pool.Data.Size, + pool.Data.ServiceLevel.ToString(), + pool.Data.QosType?.ToString(), + pool.Data.IsCoolAccessEnabled, + pool.Data.EncryptionType?.ToString(), + pool.Data.CustomThroughputMibpsInt, + new Dictionary(pool.Data.Tags), + pool.Data.ProvisioningState?.ToString()); +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs index ecb64192a4..004b5d47a6 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs @@ -24,4 +24,4 @@ public static bool IsValid(string account) return true; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/PoolNameValidator.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/PoolNameValidator.cs new file mode 100644 index 0000000000..224c7480cd --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/PoolNameValidator.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.NetAppFiles.Validation; + +internal static class PoolNameValidator +{ + public const string ErrorMessage = "--pool must be 1-64 characters, start with an alphanumeric character, and contain only alphanumerics, underscores, and hyphens."; + + public static bool IsValid(string pool) + { + if (pool.Length is < 1 or > 64 || !char.IsAsciiLetterOrDigit(pool[0])) + { + return false; + } + + foreach (var character in pool) + { + if (!char.IsAsciiLetterOrDigit(character) && character is not '_' and not '-') + { + return false; + } + } + + return true; + } +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs index 45d046424d..66cc98080e 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs @@ -123,4 +123,4 @@ public async Task ExecuteAsync_ServiceConflict_ReturnsSafeMessage() Assert.Contains("resource conflict", response.Message); Assert.DoesNotContain("backend payload", response.Message); } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs index 351a7d27a2..29c6fff16c 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs @@ -116,4 +116,4 @@ public async Task ExecuteAsync_AccountNotFound_ReturnsSafeMessage() Assert.Contains("account not found", response.Message); Assert.DoesNotContain("backend payload", response.Message); } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs index c9752f0a12..a0a0b222cd 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs @@ -166,4 +166,4 @@ public async Task ExecuteAsync_ServiceConflict_ReturnsSafeMessage() Assert.Contains("resource conflict", response.Message); Assert.DoesNotContain("backend payload", response.Message); } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs index 91f6e56bc7..46626d5262 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs @@ -101,4 +101,139 @@ await CallToolAsync( Assert.Equal("eastus", account.AssertProperty("location").GetString()); Assert.Equal("Succeeded", account.AssertProperty("provisioningState").GetString()); } -} \ No newline at end of file + + [Fact] + public async Task PoolCreate_ReturnsCreatedPool() + { + await CallToolAsync( + "netappfiles_account_create", + new() + { + { "account", Settings.ResourceBaseName }, + { "location", "eastus" }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + var result = await CallToolAsync( + "netappfiles_pool_create", + new() + { + { "account", Settings.ResourceBaseName }, + { "pool", $"{Settings.ResourceBaseName}-pool" }, + { "size", 4 }, + { "service-level", "Premium" }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + var pool = result.AssertProperty("pool"); + Assert.Equal(JsonValueKind.Object, pool.ValueKind); + pool.AssertProperty("name"); + pool.AssertProperty("id"); + Assert.Equal("eastus", pool.AssertProperty("location").GetString()); + Assert.Equal(4_398_046_511_104, pool.AssertProperty("sizeInBytes").GetInt64()); + Assert.Equal("Premium", pool.AssertProperty("serviceLevel").GetString()); + Assert.Equal("Succeeded", pool.AssertProperty("provisioningState").GetString()); + } + + [Fact] + public async Task PoolGet_ReturnsPool() + { + await CallToolAsync( + "netappfiles_account_create", + new() + { + { "account", Settings.ResourceBaseName }, + { "location", "eastus" }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + await CallToolAsync( + "netappfiles_pool_create", + new() + { + { "account", Settings.ResourceBaseName }, + { "pool", $"{Settings.ResourceBaseName}-pool" }, + { "size", 4 }, + { "service-level", "Premium" }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + var result = await CallToolAsync( + "netappfiles_pool_get", + new() + { + { "account", Settings.ResourceBaseName }, + { "pool", $"{Settings.ResourceBaseName}-pool" }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + var pool = result.AssertProperty("pool"); + Assert.Equal(JsonValueKind.Object, pool.ValueKind); + pool.AssertProperty("name"); + pool.AssertProperty("id"); + Assert.Equal("eastus", pool.AssertProperty("location").GetString()); + Assert.Equal(4_398_046_511_104, pool.AssertProperty("sizeInBytes").GetInt64()); + Assert.Equal("Premium", pool.AssertProperty("serviceLevel").GetString()); + Assert.Equal("Succeeded", pool.AssertProperty("provisioningState").GetString()); + } + + [Fact] + public async Task PoolUpdate_ReturnsUpdatedPool() + { + await CallToolAsync( + "netappfiles_account_create", + new() + { + { "account", Settings.ResourceBaseName }, + { "location", "eastus" }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + await CallToolAsync( + "netappfiles_pool_create", + new() + { + { "account", Settings.ResourceBaseName }, + { "pool", $"{Settings.ResourceBaseName}-pool" }, + { "size", 4 }, + { "service-level", "Premium" }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + var result = await CallToolAsync( + "netappfiles_pool_update", + new() + { + { "account", Settings.ResourceBaseName }, + { "pool", $"{Settings.ResourceBaseName}-pool" }, + { "tags", "{\"recorded-test\":\"pool-update\"}" }, + { "resource-group", Settings.ResourceGroupName }, + { "subscription", Settings.SubscriptionId }, + { "tenant", Settings.TenantId } + }); + + var pool = result.AssertProperty("pool"); + Assert.Equal(JsonValueKind.Object, pool.ValueKind); + pool.AssertProperty("name"); + pool.AssertProperty("id"); + Assert.Equal("eastus", pool.AssertProperty("location").GetString()); + Assert.Equal(4_398_046_511_104, pool.AssertProperty("sizeInBytes").GetInt64()); + Assert.Equal("Premium", pool.AssertProperty("serviceLevel").GetString()); + Assert.Equal("pool-update", pool.AssertProperty("tags").AssertProperty("recorded-test").GetString()); + Assert.Equal("Succeeded", pool.AssertProperty("provisioningState").GetString()); + } +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolCreateCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolCreateCommandTests.cs new file mode 100644 index 0000000000..6f38a66816 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolCreateCommandTests.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands.Pool; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Services; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.NetAppFiles.Tests.Pool; + +public class PoolCreateCommandTests : SubscriptionCommandUnitTestsBase +{ + private const long FourTebibytes = 4_398_046_511_104; + + private static readonly NetAppFilesPool CreatedPool = new( + "pool1", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/account1/capacityPools/pool1", + "eastus", + FourTebibytes, + "Flexible", + "Manual", + true, + "Double", + 128, + new Dictionary { ["environment"] = "test" }, + "Succeeded"); + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + + Assert.Equal("create", command.Name); + Assert.False(string.IsNullOrWhiteSpace(command.Description)); + } + + [Fact] + public async Task ExecuteAsync_CreatesPoolWithExpandedOptions() + { + Service.CreatePoolAsync( + "account1", + "pool1", + FourTebibytes, + "Flexible", + "rg", + "sub", + "eastus", + "Manual", + true, + "Double", + 128, + Arg.Is>(tags => + tags.Count == 1 && tags["environment"] == "test"), + "tenant", + Arg.Any()) + .Returns(CreatedPool); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--size", "4", + "--service-level", "Flexible", + "--resource-group", "rg", + "--subscription", "sub", + "--location", "eastus", + "--qos-type", "Manual", + "--cool-access", "true", + "--encryption-type", "Double", + "--custom-throughput-mibps", "128", + "--tags", "{\"environment\":\"test\"}", + "--tenant", "tenant"); + + var result = ValidateAndDeserializeResponse(response, NetAppFilesJsonContext.Default.PoolCreateResult); + Assert.Equal(CreatedPool.Name, result.Pool.Name); + Assert.Equal(CreatedPool.Id, result.Pool.Id); + Assert.Equal(CreatedPool.Location, result.Pool.Location); + Assert.Equal(CreatedPool.SizeInBytes, result.Pool.SizeInBytes); + Assert.Equal(CreatedPool.ServiceLevel, result.Pool.ServiceLevel); + Assert.Equal(CreatedPool.QosType, result.Pool.QosType); + Assert.Equal(CreatedPool.CoolAccessEnabled, result.Pool.CoolAccessEnabled); + Assert.Equal(CreatedPool.EncryptionType, result.Pool.EncryptionType); + Assert.Equal(CreatedPool.CustomThroughputMibps, result.Pool.CustomThroughputMibps); + Assert.Equal("test", result.Pool.Tags["environment"]); + Assert.Equal(CreatedPool.ProvisioningState, result.Pool.ProvisioningState); + } + + [Theory] + [InlineData("--pool pool1 --size 4 --service-level Premium --resource-group rg --subscription sub")] + [InlineData("--account account1 --size 4 --service-level Premium --resource-group rg --subscription sub")] + [InlineData("--account account1 --pool pool1 --service-level Premium --resource-group rg --subscription sub")] + [InlineData("--account account1 --pool pool1 --size 4 --resource-group rg --subscription sub")] + [InlineData("--account account1 --pool pool1 --size 4 --service-level Premium --subscription sub")] + [InlineData("--account account1 --pool pool1 --size 4 --service-level Premium --resource-group rg")] + public async Task ExecuteAsync_MissingRequiredOption_ReturnsBadRequest(string args) + { + var response = await ExecuteCommandAsync(args); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("_pool")] + [InlineData("-pool")] + [InlineData("pool/name")] + [InlineData("pool.name")] + public async Task ExecuteAsync_InvalidPoolName_ReturnsBadRequest(string pool) + { + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", pool, + "--size", "4", + "--service-level", "Premium", + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--pool", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("0")] + [InlineData("2")] + [InlineData("5")] + [InlineData("8388608")] + public async Task ExecuteAsync_InvalidSize_ReturnsBadRequest(string size) + { + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--size", size, + "--service-level", "Premium", + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--size", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("--service-level Archive", "--service-level")] + [InlineData("--service-level Premium --qos-type Dynamic", "--qos-type")] + [InlineData("--service-level Premium --encryption-type Triple", "--encryption-type")] + public async Task ExecuteAsync_InvalidEnumValue_ReturnsBadRequest(string options, string expectedOption) + { + var response = await ExecuteCommandAsync( + $"--account account1 --pool pool1 --size 4 --resource-group rg --subscription sub {options}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains(expectedOption, response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("--service-level Premium --qos-type Manual --custom-throughput-mibps 128")] + [InlineData("--service-level Flexible --qos-type Auto --custom-throughput-mibps 128")] + [InlineData("--service-level Flexible --qos-type Manual --custom-throughput-mibps 0")] + public async Task ExecuteAsync_InvalidCustomThroughput_ReturnsBadRequest(string options) + { + var response = await ExecuteCommandAsync( + $"--account account1 --pool pool1 --size 4 --resource-group rg --subscription sub {options}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--custom-throughput-mibps", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("{invalid-json}")] + [InlineData("[]")] + [InlineData("null")] + [InlineData("{\"environment\":1}")] + public async Task ExecuteAsync_InvalidTags_ReturnsBadRequest(string tags) + { + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--size", "4", + "--service-level", "Premium", + "--resource-group", "rg", + "--subscription", "sub", + "--tags", tags); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--tags", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Fact] + public async Task ExecuteAsync_ServiceConflict_ReturnsSafeMessage() + { + Service.CreatePoolAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException( + (int)HttpStatusCode.Conflict, + "backend payload containing internal pool metadata")); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--size", "4", + "--service-level", "Premium", + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Contains("resource conflict", response.Message); + Assert.DoesNotContain("backend payload", response.Message); + } +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolGetCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolGetCommandTests.cs new file mode 100644 index 0000000000..145d14e527 --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolGetCommandTests.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands.Pool; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Services; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.NetAppFiles.Tests.Pool; + +public class PoolGetCommandTests : SubscriptionCommandUnitTestsBase +{ + private static readonly NetAppFilesPool ExistingPool = new( + "pool1", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/account1/capacityPools/pool1", + "eastus", + 4_398_046_511_104, + "Premium", + "Auto", + false, + "Single", + null, + new Dictionary { ["environment"] = "test" }, + "Succeeded"); + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + + Assert.Equal("get", command.Name); + Assert.False(string.IsNullOrWhiteSpace(command.Description)); + } + + [Fact] + public async Task ExecuteAsync_GetsPool() + { + Service.GetPoolAsync( + "account1", + "pool1", + "rg", + "sub", + "tenant", + Arg.Any()) + .Returns(ExistingPool); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--resource-group", "rg", + "--subscription", "sub", + "--tenant", "tenant"); + + var result = ValidateAndDeserializeResponse(response, NetAppFilesJsonContext.Default.PoolGetResult); + Assert.Equal(ExistingPool.Name, result.Pool.Name); + Assert.Equal(ExistingPool.Id, result.Pool.Id); + Assert.Equal(ExistingPool.Location, result.Pool.Location); + Assert.Equal(ExistingPool.SizeInBytes, result.Pool.SizeInBytes); + Assert.Equal(ExistingPool.ServiceLevel, result.Pool.ServiceLevel); + Assert.Equal(ExistingPool.QosType, result.Pool.QosType); + Assert.Equal(ExistingPool.CoolAccessEnabled, result.Pool.CoolAccessEnabled); + Assert.Equal(ExistingPool.EncryptionType, result.Pool.EncryptionType); + Assert.Equal("test", result.Pool.Tags["environment"]); + Assert.Equal(ExistingPool.ProvisioningState, result.Pool.ProvisioningState); + } + + [Theory] + [InlineData("--pool pool1 --resource-group rg --subscription sub")] + [InlineData("--account account1 --resource-group rg --subscription sub")] + [InlineData("--account account1 --pool pool1 --subscription sub")] + [InlineData("--account account1 --pool pool1 --resource-group rg")] + public async Task ExecuteAsync_MissingRequiredOption_ReturnsBadRequest(string args) + { + var response = await ExecuteCommandAsync(args); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("_pool")] + [InlineData("-pool")] + [InlineData("pool/name")] + [InlineData("pool.name")] + public async Task ExecuteAsync_InvalidPoolName_ReturnsBadRequest(string pool) + { + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", pool, + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--pool", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Fact] + public async Task ExecuteAsync_PoolNotFound_ReturnsSafeMessage() + { + Service.GetPoolAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException( + (int)HttpStatusCode.NotFound, + "backend payload containing internal pool metadata")); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.NotFound, response.Status); + Assert.Contains("capacity pool not found", response.Message); + Assert.DoesNotContain("backend payload", response.Message); + } +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolUpdateCommandTests.cs new file mode 100644 index 0000000000..200d5dc38d --- /dev/null +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Pool/PoolUpdateCommandTests.cs @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands; +using Azure.Mcp.Tools.NetAppFiles.Commands.Pool; +using Azure.Mcp.Tools.NetAppFiles.Models; +using Azure.Mcp.Tools.NetAppFiles.Services; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.NetAppFiles.Tests.Pool; + +public class PoolUpdateCommandTests : SubscriptionCommandUnitTestsBase +{ + private const long EightTebibytes = 8_796_093_022_208; + + private static readonly NetAppFilesPool UpdatedPool = new( + "pool1", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/account1/capacityPools/pool1", + "eastus", + EightTebibytes, + "Flexible", + "Manual", + false, + "Single", + 128, + new Dictionary { ["environment"] = "test" }, + "Succeeded"); + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + + Assert.Equal("update", command.Name); + Assert.False(string.IsNullOrWhiteSpace(command.Description)); + } + + [Fact] + public async Task ExecuteAsync_UpdatesAllSupportedProperties() + { + Service.UpdatePoolAsync( + "account1", + "pool1", + "rg", + "sub", + EightTebibytes, + "Manual", + false, + 128, + Arg.Is>(tags => + tags.Count == 1 && tags["environment"] == "test"), + "tenant", + Arg.Any()) + .Returns(UpdatedPool); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--size", "8", + "--qos-type", "Manual", + "--cool-access", "false", + "--custom-throughput-mibps", "128", + "--tags", "{\"environment\":\"test\"}", + "--resource-group", "rg", + "--subscription", "sub", + "--tenant", "tenant"); + + var result = ValidateAndDeserializeResponse(response, NetAppFilesJsonContext.Default.PoolUpdateResult); + Assert.Equal(UpdatedPool.Name, result.Pool.Name); + Assert.Equal(UpdatedPool.SizeInBytes, result.Pool.SizeInBytes); + Assert.Equal(UpdatedPool.QosType, result.Pool.QosType); + Assert.False(result.Pool.CoolAccessEnabled); + Assert.Equal(UpdatedPool.CustomThroughputMibps, result.Pool.CustomThroughputMibps); + Assert.Equal("test", result.Pool.Tags["environment"]); + } + + [Fact] + public async Task ExecuteAsync_EmptyTags_ClearsTags() + { + Service.UpdatePoolAsync( + "account1", + "pool1", + "rg", + "sub", + null, + null, + null, + null, + Arg.Is>(tags => tags.Count == 0), + null, + Arg.Any()) + .Returns(UpdatedPool); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--resource-group", "rg", + "--subscription", "sub", + "--tags", "{}"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Fact] + public async Task ExecuteAsync_NoUpdateProperty_ReturnsBadRequest() + { + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("At least one update property", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("_account")] + [InlineData("-account")] + [InlineData("account/name")] + public async Task ExecuteAsync_InvalidAccountName_ReturnsBadRequest(string account) + { + var response = await ExecuteCommandAsync( + "--account", account, + "--pool", "pool1", + "--resource-group", "rg", + "--subscription", "sub", + "--tags", "{}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--account", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("_pool")] + [InlineData("-pool")] + [InlineData("pool/name")] + public async Task ExecuteAsync_InvalidPoolName_ReturnsBadRequest(string pool) + { + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", pool, + "--resource-group", "rg", + "--subscription", "sub", + "--tags", "{}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--pool", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("0")] + [InlineData("2")] + [InlineData("5")] + [InlineData("8388608")] + public async Task ExecuteAsync_InvalidSize_ReturnsBadRequest(string size) + { + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--size", size, + "--resource-group", "rg", + "--subscription", "sub"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--size", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("--qos-type Dynamic", "--qos-type")] + [InlineData("--custom-throughput-mibps 0", "--custom-throughput-mibps")] + [InlineData("--qos-type Auto --custom-throughput-mibps 128", "--custom-throughput-mibps")] + public async Task ExecuteAsync_InvalidUpdateCombination_ReturnsBadRequest(string updateOptions, string expectedOption) + { + var response = await ExecuteCommandAsync( + $"--account account1 --pool pool1 --resource-group rg --subscription sub {updateOptions}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains(expectedOption, response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("{invalid-json}")] + [InlineData("[]")] + [InlineData("null")] + [InlineData("{\"environment\":1}")] + public async Task ExecuteAsync_InvalidTags_ReturnsBadRequest(string tags) + { + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--resource-group", "rg", + "--subscription", "sub", + "--tags", tags); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--tags", response.Message); + Assert.Empty(Service.ReceivedCalls()); + } + + [Theory] + [InlineData("--pool pool1 --resource-group rg --subscription sub --tags {}")] + [InlineData("--account account1 --resource-group rg --subscription sub --tags {}")] + [InlineData("--account account1 --pool pool1 --subscription sub --tags {}")] + [InlineData("--account account1 --pool pool1 --resource-group rg --tags {}")] + public async Task ExecuteAsync_MissingRequiredOption_ReturnsBadRequest(string args) + { + var response = await ExecuteCommandAsync(args); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_ServiceConflict_ReturnsSafeMessage() + { + Service.UpdatePoolAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException( + (int)HttpStatusCode.Conflict, + "backend payload containing internal pool metadata")); + + var response = await ExecuteCommandAsync( + "--account", "account1", + "--pool", "pool1", + "--resource-group", "rg", + "--subscription", "sub", + "--size", "8"); + + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Contains("resource conflict", response.Message); + Assert.DoesNotContain("backend payload", response.Message); + } +} From 6840417e1bf5d9505064dbbf74c840978b714e92 Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Thu, 17 Sep 2026 16:07:41 -0400 Subject: [PATCH 05/16] fix: correctly associated netappfiles commands in consolidated-tools.json --- .../src/Resources/consolidated-tools.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index e4b8652049..2204128bad 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -2663,7 +2663,7 @@ }, { "name": "create_azure_storage", - "description": "Create Azure Storage accounts, blob containers, Azure Managed Lustre filesystems, and Azure NetApp Files accounts.", + "description": "Create Azure Storage accounts, blob containers, and Azure Managed Lustre filesystems.", "toolMetadata": { "destructive": { "value": true, @@ -2693,7 +2693,6 @@ "mappedToolList": [ "storage_account_create", "storage_blob_container_create", - "netappfiles_account_create", "managedlustre_fs_create", "managedlustre_fs_blob_autoexport_create", "managedlustre_fs_blob_autoimport_create", @@ -2702,8 +2701,8 @@ ] }, { - "name": "update_azure_netapp_files_accounts", - "description": "Update tags or the NFSv4 user ID mapping domain for Azure NetApp Files accounts.", + "name": "create_or_update_azure_netapp_files_accounts", + "description": "Create Azure NetApp Files accounts, or update their tags or NFSv4 user ID mapping domain.", "toolMetadata": { "destructive": { "value": true, @@ -2731,6 +2730,7 @@ } }, "mappedToolList": [ + "netappfiles_account_create", "netappfiles_account_update" ] }, From 293f93346e83386d5defc25e2a2614298d6f2abf Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Thu, 17 Sep 2026 16:08:00 -0400 Subject: [PATCH 06/16] chore: update changelog filename --- ...pfiles-account-create.yaml => stumpc-netappfiles-account.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename servers/Azure.Mcp.Server/changelog-entries/{stumpc-netappfiles-account-create.yaml => stumpc-netappfiles-account.yaml} (100%) diff --git a/servers/Azure.Mcp.Server/changelog-entries/stumpc-netappfiles-account-create.yaml b/servers/Azure.Mcp.Server/changelog-entries/stumpc-netappfiles-account.yaml similarity index 100% rename from servers/Azure.Mcp.Server/changelog-entries/stumpc-netappfiles-account-create.yaml rename to servers/Azure.Mcp.Server/changelog-entries/stumpc-netappfiles-account.yaml From fa4edc2361721393a4041b363ca00838b6003a4b Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Fri, 18 Sep 2026 08:45:36 -0400 Subject: [PATCH 07/16] fix: remove directory and files carried over due to IDE lag silliness --- .../SnapshotPolicyUpdateCommandTests.cs | 342 ------------------ 1 file changed, 342 deletions(-) delete mode 100644 tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.UnitTests/SnapshotPolicy/SnapshotPolicyUpdateCommandTests.cs diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.UnitTests/SnapshotPolicy/SnapshotPolicyUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.UnitTests/SnapshotPolicy/SnapshotPolicyUpdateCommandTests.cs deleted file mode 100644 index 2fa77533a6..0000000000 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.UnitTests/SnapshotPolicy/SnapshotPolicyUpdateCommandTests.cs +++ /dev/null @@ -1,342 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.CommandLine; -using System.Net; -using System.Text.Json; -using Azure.Mcp.Tools.NetAppFiles.Commands; -using Azure.Mcp.Tools.NetAppFiles.Commands.SnapshotPolicy; -using Azure.Mcp.Tools.NetAppFiles.Models; -using Azure.Mcp.Tools.NetAppFiles.Services; -using Azure.Mcp.Tests.Commands; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Mcp.Core.Models.Command; -using Microsoft.Mcp.Core.Options; -using Microsoft.Mcp.Tests.Helpers; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using Xunit; - -namespace Azure.Mcp.Tools.NetAppFiles.UnitTests.SnapshotPolicy; - -public class SnapshotPolicyUpdateCommandTests : SubscriptionCommandUnitTestsBase -{ - private const string SnapshotPolicyResourceId = "/subscriptions/sub123/resourceGroups/myrg/providers/Microsoft.NetApp/netAppAccounts/myanfaccount/snapshotPolicies/mypolicy"; - - [Fact] - public void Constructor_InitializesCommandCorrectly() - { - var command = Command.GetCommand(); - Assert.Equal("update", command.Name); - Assert.NotNull(command.Description); - Assert.NotEmpty(command.Description); - } - - [Theory] - [InlineData("--account myanfaccount --snapshot-policy mypolicy --resource-group myrg --subscription sub123", true)] - [InlineData("--snapshot-policy mypolicy --resource-group myrg --subscription sub123", false)] - [InlineData("--account myanfaccount --resource-group myrg --subscription sub123", false)] - [InlineData("--account myanfaccount --snapshot-policy mypolicy --subscription sub123", false)] - [InlineData("", false)] - public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) - { - if (shouldSucceed) - { - Service.UpdateSnapshotPolicy( - default!, - default!, - default!, - default, - default!, - default, - default, - default, - default, - default, - default, - default, - default, - default, - default, - default, - default, - default, - default, - default, - default, - TestContext.Current.CancellationToken) - .ReturnsForAnyArgs(BuildExpectedPolicy()); - } - - // Act - var response = await ExecuteCommandAsync(args); - - Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); - if (shouldSucceed) - { - Assert.Equal("Success", response.Message); - Assert.NotNull(response.Results); - } - else - { - Assert.True( - response.Message.Contains("provided", StringComparison.OrdinalIgnoreCase) || - response.Message.Contains("required", StringComparison.OrdinalIgnoreCase), - $"Expected a validation message, got: {response.Message}"); - } - } - - [Fact] - public async Task ExecuteAsync_UpdatesSnapshotPolicy_WithExpandedParameters() - { - var expectedPolicy = BuildExpectedPolicy(); - - Service.UpdateSnapshotPolicy( - Arg.Is("myanfaccount"), - Arg.Is("mypolicy"), - Arg.Is("myrg"), - Arg.Is("eastus"), - Arg.Is("sub123"), - Arg.Is(5), - Arg.Is(3), - Arg.Is(12), - Arg.Is(15), - Arg.Is(7), - Arg.Is("Monday"), - Arg.Is(4), - Arg.Is("1,15"), - Arg.Is(2), - Arg.Is(false), - Arg.Is(6), - Arg.Is(25), - Arg.Is(7), - Arg.Is(35), - Arg.Is?>(tags => tags != null && tags.Count == 1 && tags["env"] == "test"), - Arg.Any(), - Arg.Any()) - .Returns(expectedPolicy); - - // Act - var response = await ExecuteCommandAsync([ - "--account", "myanfaccount", - "--snapshot-policy", "mypolicy", - "--resource-group", "myrg", - "--location", "eastus", - "--subscription", "sub123", - "--hourly-schedule-minute", "5", - "--hourly-schedule-snapshots-to-keep", "3", - "--daily-schedule-hour", "12", - "--daily-schedule-minute", "15", - "--daily-schedule-snapshots-to-keep", "7", - "--weekly-schedule-day", "Monday", - "--weekly-schedule-hour", "6", - "--weekly-schedule-minute", "25", - "--weekly-schedule-snapshots-to-keep", "4", - "--monthly-schedule-days-of-month", "1,15", - "--monthly-schedule-hour", "7", - "--monthly-schedule-minute", "35", - "--monthly-schedule-snapshots-to-keep", "2", - "--enabled", "false", - "--tags", "{\"env\":\"test\"}" - ]); - - Assert.Equal(HttpStatusCode.OK, response.Status); - await Service.Received(1).UpdateSnapshotPolicy( - "myanfaccount", - "mypolicy", - "myrg", - "eastus", - "sub123", - 5, - 3, - 12, - 15, - 7, - "Monday", - 4, - "1,15", - 2, - false, - 6, - 25, - 7, - 35, - Arg.Is?>(tags => tags != null && tags.Count == 1 && tags["env"] == "test"), - null, - Arg.Any()); - } - - [Theory] - [InlineData("--no-wait", "no-wait")] - [InlineData("--acquirePolicyToken", "acquirePolicyToken")] - [InlineData("--changeReference CR-123", "changeReference")] - [InlineData("--add properties.enabled=false", "add")] - [InlineData("--set properties.enabled=false", "set")] - [InlineData("--remove properties.weeklySchedule", "remove")] - [InlineData("--force-string", "force-string")] - public async Task ExecuteAsync_RejectsUnsupportedArguments(string extraArgs, string expectedArgument) - { - var response = await ExecuteCommandAsync($"--account myanfaccount --snapshot-policy mypolicy --resource-group myrg --subscription sub123 {extraArgs}"); - - Assert.Equal(HttpStatusCode.BadRequest, response.Status); - Assert.Contains(expectedArgument, response.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ExecuteAsync_RejectsInvalidTagsJson() - { - // Act - var response = await ExecuteCommandAsync([ - "--account", "myanfaccount", - "--snapshot-policy", "mypolicy", - "--resource-group", "myrg", - "--subscription", "sub123", - "--tags", "{invalid-json}" - ]); - - Assert.Equal(HttpStatusCode.BadRequest, response.Status); - Assert.Contains("Invalid tags JSON format", response.Message); - } - - [Fact] - public async Task ExecuteAsync_HandlesException() - { - Service.UpdateSnapshotPolicy( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any?>(), - Arg.Any(), - Arg.Any()) - .ThrowsAsync(new Exception("Test error")); - - // Act - var response = await ExecuteCommandAsync([ - "--account", "myanfaccount", - "--snapshot-policy", "mypolicy", - "--resource-group", "myrg", - "--subscription", "sub123" - ]); - - Assert.Equal(HttpStatusCode.InternalServerError, response.Status); - Assert.Contains("Test error", response.Message); - Assert.Contains("troubleshooting", response.Message); - } - - [Fact] - public async Task ExecuteAsync_DeserializationValidation() - { - var expectedPolicy = BuildExpectedPolicy(); - - Service.UpdateSnapshotPolicy( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any?>(), - Arg.Any(), - Arg.Any()) - .Returns(expectedPolicy); - - // Act - var response = await ExecuteCommandAsync([ - "--account", "myanfaccount", - "--snapshot-policy", "mypolicy", - "--resource-group", "myrg", - "--subscription", "sub123" - ]); - - Assert.NotNull(response.Results); - var json = JsonSerializer.Serialize(response.Results); - var result = JsonSerializer.Deserialize(json, NetAppFilesJsonContext.Default.SnapshotPolicyUpdateCommandResult); - - Assert.NotNull(result); - Assert.NotNull(result.SnapshotPolicy); - Assert.Equal("myanfaccount/mypolicy", result.SnapshotPolicy.Name); - Assert.Equal("eastus", result.SnapshotPolicy.Location); - Assert.Equal("myrg", result.SnapshotPolicy.ResourceGroup); - Assert.True(result.SnapshotPolicy.Enabled); - } - - [Fact] - public async Task BindOptions_BindsOptionsCorrectly() - { - // Act - var args = CommandDefinition.Parse([ - "--account", "myanfaccount", - "--snapshot-policy", "mypolicy", - "--resource-group", "myrg", - "--subscription", "sub123", - "--hourly-schedule-minute", "15", - "--hourly-schedule-snapshots-to-keep", "3", - "--daily-schedule-hour", "6", - "--daily-schedule-minute", "30", - "--daily-schedule-snapshots-to-keep", "7", - "--weekly-schedule-day", "Wednesday", - "--weekly-schedule-hour", "5", - "--weekly-schedule-minute", "20", - "--weekly-schedule-snapshots-to-keep", "2", - "--monthly-schedule-days-of-month", "1,15", - "--monthly-schedule-hour", "8", - "--monthly-schedule-minute", "45", - "--monthly-schedule-snapshots-to-keep", "1", - "--enabled", "true", - "--tags", "{\"env\":\"test\"}" - ]); - - Assert.Empty(args.Errors); - } - - private static SnapshotPolicyCreateResult BuildExpectedPolicy() - { - return new SnapshotPolicyCreateResult( - Id: SnapshotPolicyResourceId, - Name: "myanfaccount/mypolicy", - Type: "Microsoft.NetApp/netAppAccounts/snapshotPolicies", - Location: "eastus", - ResourceGroup: "myrg", - ProvisioningState: "Succeeded", - Enabled: true, - HourlyScheduleMinute: 0, - HourlyScheduleSnapshotsToKeep: 5, - DailyScheduleHour: 12, - DailyScheduleMinute: 0, - DailyScheduleSnapshotsToKeep: 5, - WeeklyScheduleDay: "Monday", - WeeklyScheduleSnapshotsToKeep: 4, - MonthlyScheduleDaysOfMonth: "1,15", - MonthlyScheduleSnapshotsToKeep: 2); - } -} From ab51c0eff5ef94c6899f63333b7a9bf105f1a739 Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Fri, 18 Sep 2026 08:46:04 -0400 Subject: [PATCH 08/16] fix: do not block on fetching account info --- .../src/Services/NetAppFilesAccountService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs index 9f7ff65319..85135c0277 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs @@ -26,7 +26,7 @@ public async Task GetAccountAsync( cancellationToken: cancellationToken) ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); - var accountResource = resourceGroupResource.GetNetAppAccount(account, cancellationToken); + var accountResource = await resourceGroupResource.GetNetAppAccountAsync(account, cancellationToken); return Map(accountResource.Value); } @@ -72,7 +72,7 @@ public async Task UpdateAccountAsync( cancellationToken: cancellationToken) ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); - var accountResource = resourceGroupResource.GetNetAppAccount(account, cancellationToken).Value; + var accountResource = (await resourceGroupResource.GetNetAppAccountAsync(account, cancellationToken)).Value; var patch = new NetAppAccountPatch(accountResource.Data.Location) { NfsV4IdDomain = nfsV4IdDomain From 600f408908c6974066fb6e8f0924d0b3afb474e0 Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Wed, 23 Sep 2026 15:11:33 -0400 Subject: [PATCH 09/16] fix: netappfiles livetests working, update assets.json --- .../NetAppFilesCommandTests.cs | 29 ++++++++++++------- .../assets.json | 2 +- .../tests/test-resources.bicep | 3 +- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs index 91f6e56bc7..7b55f084e7 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs @@ -18,13 +18,16 @@ public class NetAppFilesCommandTests( [Fact] public async Task AccountGet_ReturnsAccount() { + var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"testacct{DateTime.UtcNow:MMddHHmmss}"); + var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); + await CallToolAsync( "netappfiles_account_create", new() { - { "account", Settings.ResourceBaseName }, + { "account", accountName }, { "location", "eastus" }, - { "resource-group", Settings.ResourceGroupName }, + { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId } }); @@ -33,8 +36,8 @@ await CallToolAsync( "netappfiles_account_get", new() { - { "account", Settings.ResourceBaseName }, - { "resource-group", Settings.ResourceGroupName }, + { "account", accountName }, + { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId } }); @@ -50,13 +53,16 @@ await CallToolAsync( [Fact] public async Task AccountCreate_ReturnsCreatedAccount() { + var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"testacct{DateTime.UtcNow:MMddHHmmss}"); + var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); + var result = await CallToolAsync( "netappfiles_account_create", new() { - { "account", Settings.ResourceBaseName }, + { "account", accountName }, { "location", "eastus" }, - { "resource-group", Settings.ResourceGroupName }, + { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId } }); @@ -72,13 +78,16 @@ public async Task AccountCreate_ReturnsCreatedAccount() [Fact] public async Task AccountUpdate_ReturnsUpdatedAccount() { + var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"testacct{DateTime.UtcNow:MMddHHmmss}"); + var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); + await CallToolAsync( "netappfiles_account_create", new() { - { "account", Settings.ResourceBaseName }, + { "account", accountName }, { "location", "eastus" }, - { "resource-group", Settings.ResourceGroupName }, + { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId } }); @@ -87,8 +96,8 @@ await CallToolAsync( "netappfiles_account_update", new() { - { "account", Settings.ResourceBaseName }, - { "resource-group", Settings.ResourceGroupName }, + { "account", accountName }, + { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId }, { "tags", "{\"recorded-test\":\"account-update\"}" } diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json index 80da70da3d..6be4dd7a69 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.NetAppFiles.Tests", - "Tag": "" + "Tag": "Azure.Mcp.Tools.NetAppFiles.Tests_0455f61671" } \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep index 263f9cd0bc..2b408e1bc3 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep @@ -17,10 +17,9 @@ resource netAppFilesContributorRoleAssignment 'Microsoft.Authorization/roleAssig name: guid(resourceGroup().id, testApplicationOid, contributorRoleDefinition.id) properties: { principalId: testApplicationOid - principalType: 'ServicePrincipal' roleDefinitionId: contributorRoleDefinition.id description: 'Contributor for Azure NetApp Files live tests' } } -output NETAPP_ACCOUNT_NAME string = baseName \ No newline at end of file +output NETAPP_ACCOUNT_NAME string = baseName From c8d0386fefb1b1acef61ad821e2735481fd019ad Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Mon, 28 Sep 2026 10:14:57 -0400 Subject: [PATCH 10/16] fix: use full words to pass spellcheck --- .../NetAppFilesCommandTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs index 7b55f084e7..208764d4ab 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs @@ -18,7 +18,7 @@ public class NetAppFilesCommandTests( [Fact] public async Task AccountGet_ReturnsAccount() { - var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"testacct{DateTime.UtcNow:MMddHHmmss}"); + var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"test-Account-{DateTime.UtcNow:MMddHHmmss}"); var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); await CallToolAsync( @@ -53,7 +53,7 @@ await CallToolAsync( [Fact] public async Task AccountCreate_ReturnsCreatedAccount() { - var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"testacct{DateTime.UtcNow:MMddHHmmss}"); + var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"test-Account-{DateTime.UtcNow:MMddHHmmss}"); var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); var result = await CallToolAsync( @@ -78,7 +78,7 @@ public async Task AccountCreate_ReturnsCreatedAccount() [Fact] public async Task AccountUpdate_ReturnsUpdatedAccount() { - var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"testacct{DateTime.UtcNow:MMddHHmmss}"); + var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"test-Account-{DateTime.UtcNow:MMddHHmmss}"); var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); await CallToolAsync( From b165fc72202a6a7e8823980f676674fa0e37814c Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Mon, 28 Sep 2026 12:54:03 -0400 Subject: [PATCH 11/16] fix: formatting issues --- tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs | 2 +- .../src/Commands/Account/AccountCreateCommand.cs | 2 +- .../src/Commands/Account/AccountGetCommand.cs | 2 +- .../src/Commands/Account/AccountUpdateCommand.cs | 2 +- .../src/Commands/NetAppFilesJsonContext.cs | 2 +- .../src/Models/NetAppFilesAccount.cs | 2 +- tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs | 2 +- .../src/Options/Account/AccountCreateOptions.cs | 2 +- .../src/Options/Account/AccountGetOptions.cs | 2 +- .../src/Options/Account/AccountUpdateOptions.cs | 2 +- .../src/Services/INetAppFilesAccountService.cs | 2 +- .../src/Services/NetAppFilesAccountService.cs | 2 +- .../src/Validation/AccountNameValidator.cs | 2 +- .../Account/AccountCreateCommandTests.cs | 2 +- .../Account/AccountGetCommandTests.cs | 2 +- .../Account/AccountUpdateCommandTests.cs | 2 +- .../NetAppFilesCommandTests.cs | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs index 64899e5635..90715b9889 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/AssemblyInfo.cs @@ -3,4 +3,4 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("Azure.Mcp.Tools.NetAppFiles.Tests")] \ No newline at end of file +[assembly: InternalsVisibleTo("Azure.Mcp.Tools.NetAppFiles.Tests")] diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs index ab9bd3fd72..6a351bd5b8 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountCreateCommand.cs @@ -93,4 +93,4 @@ public override void ValidateOptions(AccountCreateOptions options, ValidationRes }; public record AccountCreateResult(NetAppFilesAccount Account); -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs index 7d79ddd361..8be61e7fd8 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs @@ -89,4 +89,4 @@ public override void ValidateOptions(AccountGetOptions options, ValidationResult }; public record AccountGetResult(NetAppFilesAccount Account); -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs index abba77c2b2..da3bdc08f6 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs @@ -123,4 +123,4 @@ public override void ValidateOptions(AccountUpdateOptions options, ValidationRes }; public record AccountUpdateResult(NetAppFilesAccount Account); -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs index 4ee43c2910..c5f2650862 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/NetAppFilesJsonContext.cs @@ -15,4 +15,4 @@ namespace Azure.Mcp.Tools.NetAppFiles.Commands; [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] -internal partial class NetAppFilesJsonContext : JsonSerializerContext; \ No newline at end of file +internal partial class NetAppFilesJsonContext : JsonSerializerContext; diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs index c43a29ddf6..18df250e00 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs @@ -7,4 +7,4 @@ public record NetAppFilesAccount( string Name, string Id, string Location, - string? ProvisioningState); \ No newline at end of file + string? ProvisioningState); diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs index aeee731e83..ef64d2b4e3 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/NetAppFilesSetup.cs @@ -38,4 +38,4 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) return root; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs index c32551463d..feb8a71b62 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountCreateOptions.cs @@ -22,4 +22,4 @@ public class AccountCreateOptions : ISubscriptionOption [Option(Description = OptionDescriptions.Tenant)] public string? Tenant { get; set; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs index f96fb42f9f..7c33761aa8 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountGetOptions.cs @@ -19,4 +19,4 @@ public class AccountGetOptions : ISubscriptionOption [Option(Description = OptionDescriptions.Tenant)] public string? Tenant { get; set; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs index 2c63e1d8b7..b0b5931f7e 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Options/Account/AccountUpdateOptions.cs @@ -25,4 +25,4 @@ public sealed class AccountUpdateOptions : ISubscriptionOption [Option(Description = OptionDescriptions.Tenant)] public string? Tenant { get; set; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs index 0d2d827318..7c423a6fbd 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/INetAppFilesAccountService.cs @@ -30,4 +30,4 @@ Task CreateAccountAsync( string subscription, string? tenant = null, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs index 85135c0277..941301f0e7 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs @@ -98,4 +98,4 @@ public async Task UpdateAccountAsync( account.Data.Id.ToString(), account.Data.Location.ToString(), account.Data.ProvisioningState?.ToString()); -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs index ecb64192a4..004b5d47a6 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Validation/AccountNameValidator.cs @@ -24,4 +24,4 @@ public static bool IsValid(string account) return true; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs index 45d046424d..66cc98080e 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountCreateCommandTests.cs @@ -123,4 +123,4 @@ public async Task ExecuteAsync_ServiceConflict_ReturnsSafeMessage() Assert.Contains("resource conflict", response.Message); Assert.DoesNotContain("backend payload", response.Message); } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs index 351a7d27a2..29c6fff16c 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountGetCommandTests.cs @@ -116,4 +116,4 @@ public async Task ExecuteAsync_AccountNotFound_ReturnsSafeMessage() Assert.Contains("account not found", response.Message); Assert.DoesNotContain("backend payload", response.Message); } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs index c9752f0a12..a0a0b222cd 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/Account/AccountUpdateCommandTests.cs @@ -166,4 +166,4 @@ public async Task ExecuteAsync_ServiceConflict_ReturnsSafeMessage() Assert.Contains("resource conflict", response.Message); Assert.DoesNotContain("backend payload", response.Message); } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs index 208764d4ab..733b281812 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs @@ -110,4 +110,4 @@ await CallToolAsync( Assert.Equal("eastus", account.AssertProperty("location").GetString()); Assert.Equal("Succeeded", account.AssertProperty("provisioningState").GetString()); } -} \ No newline at end of file +} From 7048a60e2ca1167bdb161f36baacf5c788bbe7df Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Wed, 30 Sep 2026 13:29:08 -0400 Subject: [PATCH 12/16] tests: use new uploaded test asset for capacity pools --- .../tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json index 6be4dd7a69..f1f0f12b20 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.NetAppFiles.Tests", - "Tag": "Azure.Mcp.Tools.NetAppFiles.Tests_0455f61671" + "Tag": "Azure.Mcp.Tools.NetAppFiles.Tests_b035f505a2" } \ No newline at end of file From 3c2d2394f1d25a5efbff093025c07cc12e4079bf Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Tue, 6 Oct 2026 14:56:42 -0400 Subject: [PATCH 13/16] address PR comments --- .../src/Commands/Account/AccountGetCommand.cs | 6 +++ .../Commands/Account/AccountUpdateCommand.cs | 6 +++ .../src/Models/NetAppFilesAccount.cs | 2 +- .../src/Services/NetAppFilesAccountService.cs | 52 +++++++++++++----- .../NetAppFilesCommandTests.cs | 53 +++++++++++++++++++ .../assets.json | 2 +- 6 files changed, 106 insertions(+), 15 deletions(-) diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs index 8be61e7fd8..14e3f5a8a3 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountGetCommand.cs @@ -88,5 +88,11 @@ public override void ValidateOptions(AccountGetOptions options, ValidationResult _ => base.GetErrorMessage(ex) }; + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + KeyNotFoundException => HttpStatusCode.NotFound, + _ => base.GetStatusCode(ex), + }; + public record AccountGetResult(NetAppFilesAccount Account); } diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs index da3bdc08f6..86ed7dd854 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Account/AccountUpdateCommand.cs @@ -122,5 +122,11 @@ public override void ValidateOptions(AccountUpdateOptions options, ValidationRes _ => base.GetErrorMessage(ex) }; + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + KeyNotFoundException => HttpStatusCode.NotFound, + _ => base.GetStatusCode(ex), + }; + public record AccountUpdateResult(NetAppFilesAccount Account); } diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs index 18df250e00..9d63a24365 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesAccount.cs @@ -3,7 +3,7 @@ namespace Azure.Mcp.Tools.NetAppFiles.Models; -public record NetAppFilesAccount( +public sealed record NetAppFilesAccount( string Name, string Id, string Location, diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs index 941301f0e7..871cfe2bbd 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Services/NetAppFilesAccountService.cs @@ -1,12 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Azure; using Azure.Core; using Azure.Mcp.Core.Services.Azure; using Azure.Mcp.Tools.NetAppFiles.Models; using Azure.ResourceManager.NetApp; using Azure.ResourceManager.NetApp.Models; +using Azure.ResourceManager.Resources; namespace Azure.Mcp.Tools.NetAppFiles.Services; @@ -19,12 +19,11 @@ public async Task GetAccountAsync( string? tenant = null, CancellationToken cancellationToken = default) { - var resourceGroupResource = await AzureService.GetResourceGroupResource( - subscription, + var resourceGroupResource = await GetResourceGroupOrThrowAsync( resourceGroup, + subscription, tenant, - cancellationToken: cancellationToken) - ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); + cancellationToken: cancellationToken); var accountResource = await resourceGroupResource.GetNetAppAccountAsync(account, cancellationToken); @@ -39,12 +38,24 @@ public async Task CreateAccountAsync( string? tenant = null, CancellationToken cancellationToken = default) { - var resourceGroupResource = await AzureService.GetResourceGroupResource( - subscription, + var resourceGroupResource = await GetResourceGroupOrThrowAsync( resourceGroup, + subscription, tenant, - cancellationToken: cancellationToken) - ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); + cancellationToken: cancellationToken); + + try + { + var existingAccount = await resourceGroupResource.GetNetAppAccountAsync(account, cancellationToken); + if (existingAccount.HasValue) + { + throw new InvalidOperationException($"NetApp account '{account}' already exists in resource group '{resourceGroup}'."); + } + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // Account does not exist, continue with creation + } var accountData = new NetAppAccountData(new AzureLocation(location)); var operation = await resourceGroupResource @@ -65,12 +76,11 @@ public async Task UpdateAccountAsync( string? tenant = null, CancellationToken cancellationToken = default) { - var resourceGroupResource = await AzureService.GetResourceGroupResource( - subscription, + var resourceGroupResource = await GetResourceGroupOrThrowAsync( resourceGroup, + subscription, tenant, - cancellationToken: cancellationToken) - ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); + cancellationToken: cancellationToken); var accountResource = (await resourceGroupResource.GetNetAppAccountAsync(account, cancellationToken)).Value; var patch = new NetAppAccountPatch(accountResource.Data.Location) @@ -98,4 +108,20 @@ public async Task UpdateAccountAsync( account.Data.Id.ToString(), account.Data.Location.ToString(), account.Data.ProvisioningState?.ToString()); + + private async Task GetResourceGroupOrThrowAsync( + string resourceGroup, + string subscription, + string? tenant = null, + CancellationToken cancellationToken = default) + { + var resourceGroupResource = await AzureService.GetResourceGroupResource( + subscription, + resourceGroup, + tenant, + cancellationToken: cancellationToken) + ?? throw new KeyNotFoundException($"Resource group '{resourceGroup}' was not found."); + + return resourceGroupResource; + } } diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs index 733b281812..d33b13bc57 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs @@ -5,6 +5,7 @@ using Microsoft.Mcp.Tests; using Microsoft.Mcp.Tests.Client; using Microsoft.Mcp.Tests.Client.Helpers; +using Microsoft.Mcp.Tests.Generated.Models; using Xunit; namespace Azure.Mcp.Tools.NetAppFiles.Tests; @@ -15,6 +16,58 @@ public class NetAppFilesCommandTests( LiveServerFixture liveServerFixture) : RecordedCommandTestsBase(output, fixture, liveServerFixture) { + public override List HeaderRegexSanitizers => new() + { + new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("x-ms-operation-identifier") + { + Regex = "tenantId=(?[0-9a-fA-F-]{36})", + GroupForReplace = "tenant", + Value = "00000000-0000-0000-0000-000000000000" + }), + new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("x-ms-operation-identifier") + { + Regex = "objectId=(?[0-9a-fA-F-]{36})", + GroupForReplace = "object", + Value = "00000000-0000-0000-0000-000000000000" + }), + new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("x-ms-operation-identifier") + { + Regex = "objectId=(?[0-9a-fA-F-]{36})/.*/(?[0-9a-fA-F-]{36})", + GroupForReplace = "locationObject", + Value = "00000000-0000-0000-0000-000000000000" + }), + new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("x-ms-routing-request-id") + { + Regex = ".*:.*:(?[0-9a-fA-F-]{36})", + GroupForReplace = "routingId", + Value = "00000000-0000-0000-0000-000000000000" + }), + new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("x-ms-correlation-request-id") + { + Regex = "(?[0-9a-fA-F-]{36})", + GroupForReplace = "correlationId", + Value = "00000000-0000-0000-0000-000000000000" + }), + }; + + public override List BodyKeySanitizers => new() + { + new BodyKeySanitizer(new BodyKeySanitizerBody("$.tags.Owner") + { + Value = "Sanitized" + }), + new BodyKeySanitizer(new BodyKeySanitizerBody("$.tags.Owners") + { + Value = "Sanitized" + }), + new BodyKeySanitizer(new BodyKeySanitizerBody("$.id") + { + Regex = "/resourceGroups/(?[^/]+)", + GroupForReplace = "resourceGroup", + Value = "Sanitized" + }), + }; + [Fact] public async Task AccountGet_ReturnsAccount() { diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json index 6be4dd7a69..a850a766f4 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.NetAppFiles.Tests", - "Tag": "Azure.Mcp.Tools.NetAppFiles.Tests_0455f61671" + "Tag": "Azure.Mcp.Tools.NetAppFiles.Tests_013ab738a4" } \ No newline at end of file From 832faa130fe99d4346596d2b35e2f1634d513df2 Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Fri, 9 Oct 2026 12:10:10 -0400 Subject: [PATCH 14/16] tests: fix livetests for netappfiles pools --- .../src/Models/NetAppFilesPool.cs | 2 +- .../NetAppFilesCommandTests.cs | 128 ++++++++---------- .../tests/test-resources.bicep | 50 ++++++- 3 files changed, 104 insertions(+), 76 deletions(-) diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesPool.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesPool.cs index efa2d734c5..7dc0b0de7c 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesPool.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Models/NetAppFilesPool.cs @@ -3,7 +3,7 @@ namespace Azure.Mcp.Tools.NetAppFiles.Models; -public record NetAppFilesPool( +public sealed record NetAppFilesPool( string Name, string Id, string Location, diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs index 41d2627db1..e54386b87b 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs @@ -16,6 +16,10 @@ public class NetAppFilesCommandTests( LiveServerFixture liveServerFixture) : RecordedCommandTestsBase(output, fixture, liveServerFixture) { + string location = "westus"; + int sizeInTiB = 4; + string serviceLevel = "Standard"; + public override List HeaderRegexSanitizers => new() { new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("x-ms-operation-identifier") @@ -66,20 +70,30 @@ public class NetAppFilesCommandTests( GroupForReplace = "resourceGroup", Value = "Sanitized" }), + new BodyKeySanitizer(new BodyKeySanitizerBody("$.properties.poolId") + { + Value = "00000000-0000-0000-0000-000000000000" + }), + new BodyKeySanitizer(new BodyKeySanitizerBody("$.properties.resourceName") + { + Regex = "/resourceGroups/(?[^/]+)", + GroupForReplace = "resourceGroup", + Value = "Sanitized" + }), }; [Fact] public async Task AccountGet_ReturnsAccount() { var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"test-Account-{DateTime.UtcNow:MMddHHmmss}"); - var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); + var resourceGroupName = Settings.ResourceGroupName; await CallToolAsync( "netappfiles_account_create", new() { { "account", accountName }, - { "location", "eastus" }, + { "location", location }, { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId } @@ -99,7 +113,7 @@ await CallToolAsync( Assert.Equal(JsonValueKind.Object, account.ValueKind); account.AssertProperty("name"); account.AssertProperty("id"); - Assert.Equal("eastus", account.AssertProperty("location").GetString()); + Assert.Equal(location, account.AssertProperty("location").GetString()); Assert.Equal("Succeeded", account.AssertProperty("provisioningState").GetString()); } @@ -107,14 +121,14 @@ await CallToolAsync( public async Task AccountCreate_ReturnsCreatedAccount() { var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"test-Account-{DateTime.UtcNow:MMddHHmmss}"); - var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); + var resourceGroupName = Settings.ResourceGroupName; var result = await CallToolAsync( "netappfiles_account_create", new() { { "account", accountName }, - { "location", "eastus" }, + { "location", location }, { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId } @@ -124,7 +138,7 @@ public async Task AccountCreate_ReturnsCreatedAccount() Assert.Equal(JsonValueKind.Object, account.ValueKind); account.AssertProperty("name"); account.AssertProperty("id"); - Assert.Equal("eastus", account.AssertProperty("location").GetString()); + Assert.Equal(location, account.AssertProperty("location").GetString()); Assert.Equal("Succeeded", account.AssertProperty("provisioningState").GetString()); } @@ -132,14 +146,14 @@ public async Task AccountCreate_ReturnsCreatedAccount() public async Task AccountUpdate_ReturnsUpdatedAccount() { var accountName = RegisterOrRetrieveVariable("createdNetAppAccount", $"test-Account-{DateTime.UtcNow:MMddHHmmss}"); - var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); + var resourceGroupName = Settings.ResourceGroupName; await CallToolAsync( "netappfiles_account_create", new() { { "account", accountName }, - { "location", "eastus" }, + { "location", location }, { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId } @@ -160,33 +174,26 @@ await CallToolAsync( Assert.Equal(JsonValueKind.Object, account.ValueKind); account.AssertProperty("name"); account.AssertProperty("id"); - Assert.Equal("eastus", account.AssertProperty("location").GetString()); + Assert.Equal(location, account.AssertProperty("location").GetString()); Assert.Equal("Succeeded", account.AssertProperty("provisioningState").GetString()); } [Fact] public async Task PoolCreate_ReturnsCreatedPool() { - await CallToolAsync( - "netappfiles_account_create", - new() - { - { "account", Settings.ResourceBaseName }, - { "location", "eastus" }, - { "resource-group", Settings.ResourceGroupName }, - { "subscription", Settings.SubscriptionId }, - { "tenant", Settings.TenantId } - }); + var accountName = $"{Settings.ResourceBaseName}-account"; + var poolName = RegisterOrRetrieveVariable("createdNetAppPool", $"test-Pool-{DateTime.UtcNow:MMddHHmmss}"); + var resourceGroupName = Settings.ResourceGroupName; var result = await CallToolAsync( "netappfiles_pool_create", new() { - { "account", Settings.ResourceBaseName }, - { "pool", $"{Settings.ResourceBaseName}-pool" }, - { "size", 4 }, - { "service-level", "Premium" }, - { "resource-group", Settings.ResourceGroupName }, + { "account", accountName }, + { "pool", poolName }, + { "size", sizeInTiB }, + { "service-level", serviceLevel }, + { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId } }); @@ -195,46 +202,26 @@ await CallToolAsync( Assert.Equal(JsonValueKind.Object, pool.ValueKind); pool.AssertProperty("name"); pool.AssertProperty("id"); - Assert.Equal("eastus", pool.AssertProperty("location").GetString()); + Assert.Equal(location, pool.AssertProperty("location").GetString()); Assert.Equal(4_398_046_511_104, pool.AssertProperty("sizeInBytes").GetInt64()); - Assert.Equal("Premium", pool.AssertProperty("serviceLevel").GetString()); + Assert.Equal(serviceLevel, pool.AssertProperty("serviceLevel").GetString()); Assert.Equal("Succeeded", pool.AssertProperty("provisioningState").GetString()); } [Fact] public async Task PoolGet_ReturnsPool() { - await CallToolAsync( - "netappfiles_account_create", - new() - { - { "account", Settings.ResourceBaseName }, - { "location", "eastus" }, - { "resource-group", Settings.ResourceGroupName }, - { "subscription", Settings.SubscriptionId }, - { "tenant", Settings.TenantId } - }); - - await CallToolAsync( - "netappfiles_pool_create", - new() - { - { "account", Settings.ResourceBaseName }, - { "pool", $"{Settings.ResourceBaseName}-pool" }, - { "size", 4 }, - { "service-level", "Premium" }, - { "resource-group", Settings.ResourceGroupName }, - { "subscription", Settings.SubscriptionId }, - { "tenant", Settings.TenantId } - }); + var accountName = $"{Settings.ResourceBaseName}-account"; + var poolName = $"{Settings.ResourceBaseName}-pool"; + var resourceGroupName = Settings.ResourceGroupName; var result = await CallToolAsync( "netappfiles_pool_get", new() { - { "account", Settings.ResourceBaseName }, - { "pool", $"{Settings.ResourceBaseName}-pool" }, - { "resource-group", Settings.ResourceGroupName }, + { "account", accountName }, + { "pool", poolName }, + { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId } }); @@ -243,35 +230,28 @@ await CallToolAsync( Assert.Equal(JsonValueKind.Object, pool.ValueKind); pool.AssertProperty("name"); pool.AssertProperty("id"); - Assert.Equal("eastus", pool.AssertProperty("location").GetString()); + Assert.Equal(location, pool.AssertProperty("location").GetString()); Assert.Equal(4_398_046_511_104, pool.AssertProperty("sizeInBytes").GetInt64()); - Assert.Equal("Premium", pool.AssertProperty("serviceLevel").GetString()); + Assert.Equal(serviceLevel, pool.AssertProperty("serviceLevel").GetString()); Assert.Equal("Succeeded", pool.AssertProperty("provisioningState").GetString()); } [Fact] public async Task PoolUpdate_ReturnsUpdatedPool() { - await CallToolAsync( - "netappfiles_account_create", - new() - { - { "account", Settings.ResourceBaseName }, - { "location", "eastus" }, - { "resource-group", Settings.ResourceGroupName }, - { "subscription", Settings.SubscriptionId }, - { "tenant", Settings.TenantId } - }); + var accountName = $"{Settings.ResourceBaseName}-account"; + var poolName = RegisterOrRetrieveVariable("createdNetAppPool", $"test-Pool-{DateTime.UtcNow:MMddHHmmss}"); + var resourceGroupName = Settings.ResourceGroupName; await CallToolAsync( "netappfiles_pool_create", new() { - { "account", Settings.ResourceBaseName }, - { "pool", $"{Settings.ResourceBaseName}-pool" }, - { "size", 4 }, - { "service-level", "Premium" }, - { "resource-group", Settings.ResourceGroupName }, + { "account", accountName }, + { "pool", poolName }, + { "size", sizeInTiB }, + { "service-level", serviceLevel }, + { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId } }); @@ -280,10 +260,10 @@ await CallToolAsync( "netappfiles_pool_update", new() { - { "account", Settings.ResourceBaseName }, - { "pool", $"{Settings.ResourceBaseName}-pool" }, + { "account", accountName }, + { "pool", poolName }, { "tags", "{\"recorded-test\":\"pool-update\"}" }, - { "resource-group", Settings.ResourceGroupName }, + { "resource-group", resourceGroupName }, { "subscription", Settings.SubscriptionId }, { "tenant", Settings.TenantId } }); @@ -292,9 +272,9 @@ await CallToolAsync( Assert.Equal(JsonValueKind.Object, pool.ValueKind); pool.AssertProperty("name"); pool.AssertProperty("id"); - Assert.Equal("eastus", pool.AssertProperty("location").GetString()); + Assert.Equal(location, pool.AssertProperty("location").GetString()); Assert.Equal(4_398_046_511_104, pool.AssertProperty("sizeInBytes").GetInt64()); - Assert.Equal("Premium", pool.AssertProperty("serviceLevel").GetString()); + Assert.Equal(serviceLevel, pool.AssertProperty("serviceLevel").GetString()); Assert.Equal("pool-update", pool.AssertProperty("tags").AssertProperty("recorded-test").GetString()); Assert.Equal("Succeeded", pool.AssertProperty("provisioningState").GetString()); } diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep index 2b408e1bc3..225e8e6d8d 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep @@ -8,6 +8,51 @@ param baseName string = resourceGroup().name @description('The client object ID to grant access to the test resource group.') param testApplicationOid string +param location string = resourceGroup().location + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = { + name: '${baseName}-vnet' + location: location + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + subnets: [ + { + name: 'anf' + properties: { + addressPrefix: '10.0.0.0/24' + delegations: [ + { + name: 'Microsoft.NetApp-volumes' + properties: { + serviceName: 'Microsoft.NetApp/volumes' + } + } + ] + } + } + ] + } +} + +resource netAppAccount 'Microsoft.NetApp/netAppAccounts@2026-01-01' = { + name: '${baseName}-account' + location: location +} + +resource capacityPool 'Microsoft.NetApp/netAppAccounts/capacityPools@2026-01-01' = { + parent: netAppAccount + name: '${baseName}-pool' + location: location + properties: { + serviceLevel: 'Standard' + size: 4398046511104 + } +} + resource contributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { scope: subscription() name: 'b24988ac-6180-42a0-ab88-20f7382dd24c' @@ -22,4 +67,7 @@ resource netAppFilesContributorRoleAssignment 'Microsoft.Authorization/roleAssig } } -output NETAPP_ACCOUNT_NAME string = baseName +output NETAPP_ACCOUNT_NAME string = netAppAccount.name +output NETAPP_POOL_NAME string = capacityPool.name +output NETAPP_SUBNET_ID string = virtualNetwork.properties.subnets[0].id +output LOCATION string = location From d5a0ecaf79b2abb140e7b5693a3836ee6679936a Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Fri, 9 Oct 2026 12:49:00 -0400 Subject: [PATCH 15/16] fix: update tib size range for capacity pools according to documentation --- .../src/Commands/Pool/PoolCreateCommand.cs | 4 ++-- .../src/Commands/Pool/PoolUpdateCommand.cs | 4 ++-- .../NetAppFilesCommandTests.cs | 8 ++++---- .../tests/test-resources.bicep | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolCreateCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolCreateCommand.cs index ef77d42d1d..62cc1b7c10 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolCreateCommand.cs @@ -102,9 +102,9 @@ public override void ValidateOptions(PoolCreateOptions options, ValidationResult validationResult.Errors.Add(Validation.PoolNameValidator.ErrorMessage); } - if (options.Size <= 0 || options.Size % 4 != 0 || options.Size > long.MaxValue / BytesPerTebibyte) + if (options.Size <= 0 || options.Size > 2048) { - validationResult.Errors.Add("--size must be a positive multiple of 4 TiB and small enough to represent in bytes."); + validationResult.Errors.Add("--size must be greater than 0 and less than or equal to 2048."); } if (!Contains(s_serviceLevels, options.ServiceLevel)) diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolUpdateCommand.cs b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolUpdateCommand.cs index 1319f74028..051edfc373 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolUpdateCommand.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/src/Commands/Pool/PoolUpdateCommand.cs @@ -98,9 +98,9 @@ public override void ValidateOptions(PoolUpdateOptions options, ValidationResult } if (options.Size is not null && - (options.Size <= 0 || options.Size % 4 != 0 || options.Size > long.MaxValue / BytesPerTebibyte)) + (options.Size <= 0 || options.Size > 2048)) { - validationResult.Errors.Add("--size must be a positive multiple of 4 TiB and small enough to represent in bytes."); + validationResult.Errors.Add("--size must be greater than 0 and less than or equal to 2048."); } if (options.QosType is not null && !s_qosTypes.Contains(options.QosType, StringComparer.OrdinalIgnoreCase)) diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs index e54386b87b..17d3333356 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/NetAppFilesCommandTests.cs @@ -17,7 +17,7 @@ public class NetAppFilesCommandTests( : RecordedCommandTestsBase(output, fixture, liveServerFixture) { string location = "westus"; - int sizeInTiB = 4; + int sizeInTiB = 1; string serviceLevel = "Standard"; public override List HeaderRegexSanitizers => new() @@ -203,7 +203,7 @@ public async Task PoolCreate_ReturnsCreatedPool() pool.AssertProperty("name"); pool.AssertProperty("id"); Assert.Equal(location, pool.AssertProperty("location").GetString()); - Assert.Equal(4_398_046_511_104, pool.AssertProperty("sizeInBytes").GetInt64()); + Assert.Equal(1_099_511_627_776, pool.AssertProperty("sizeInBytes").GetInt64()); Assert.Equal(serviceLevel, pool.AssertProperty("serviceLevel").GetString()); Assert.Equal("Succeeded", pool.AssertProperty("provisioningState").GetString()); } @@ -231,7 +231,7 @@ public async Task PoolGet_ReturnsPool() pool.AssertProperty("name"); pool.AssertProperty("id"); Assert.Equal(location, pool.AssertProperty("location").GetString()); - Assert.Equal(4_398_046_511_104, pool.AssertProperty("sizeInBytes").GetInt64()); + Assert.Equal(1_099_511_627_776, pool.AssertProperty("sizeInBytes").GetInt64()); Assert.Equal(serviceLevel, pool.AssertProperty("serviceLevel").GetString()); Assert.Equal("Succeeded", pool.AssertProperty("provisioningState").GetString()); } @@ -273,7 +273,7 @@ await CallToolAsync( pool.AssertProperty("name"); pool.AssertProperty("id"); Assert.Equal(location, pool.AssertProperty("location").GetString()); - Assert.Equal(4_398_046_511_104, pool.AssertProperty("sizeInBytes").GetInt64()); + Assert.Equal(1_099_511_627_776, pool.AssertProperty("sizeInBytes").GetInt64()); Assert.Equal(serviceLevel, pool.AssertProperty("serviceLevel").GetString()); Assert.Equal("pool-update", pool.AssertProperty("tags").AssertProperty("recorded-test").GetString()); Assert.Equal("Succeeded", pool.AssertProperty("provisioningState").GetString()); diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep index 225e8e6d8d..a38fc90297 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/test-resources.bicep @@ -49,7 +49,7 @@ resource capacityPool 'Microsoft.NetApp/netAppAccounts/capacityPools@2026-01-01' location: location properties: { serviceLevel: 'Standard' - size: 4398046511104 + size: 1099511627776 } } From f72db65d70559931414a1064c087ab86f9cc5a42 Mon Sep 17 00:00:00 2001 From: Christian Stump Date: Fri, 9 Oct 2026 12:49:59 -0400 Subject: [PATCH 16/16] chore: update assets for livetests --- .../tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json index f1f0f12b20..b5289ce847 100644 --- a/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.NetAppFiles/tests/Azure.Mcp.Tools.NetAppFiles.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.NetAppFiles.Tests", - "Tag": "Azure.Mcp.Tools.NetAppFiles.Tests_b035f505a2" + "Tag": "Azure.Mcp.Tools.NetAppFiles.Tests_41ffb0add1" } \ No newline at end of file