diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index a3261ab69..6e3b48395 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -1080,8 +1080,36 @@ azmcp monitor metrics query --subscription \ azmcp azuremanagedlustre filesystem list --subscription \ --resource-group -azmcp azuremanagedlustre filesystem sku get --subscription \ - --location +# Create an Azure Managed Lustre filesystem +azmcp azuremanagedlustre filesystem create --subscription \ + --sku \ + --size \ + --subnet-id \ + --zone \ + --maintenance-day \ + --maintenance-time \ + [--hsm-container ] \ + [--hsm-log-container ] \ + [--import-prefix ] \ + [--root-squash-mode ] \ + [--no-squash-nid-list ] \ + [--squash-uid ] \ + [--squash-gid ] \ + [--custom-encryption] \ + [--key-url ] \ + [--source-vault ] \ + [--user-assigned-identity-id ] + +# Update an existing Azure Managed Lustre filesystem +azmcp azuremanagedlustre filesystem update --subscription \ + --resource-group \ + --name \ + [--maintenance-day ] \ + [--maintenance-time ] \ + [--root-squash-mode ] \ + [--no-squash-nid-list ] \ + [--squash-uid ] \ + [--squash-gid ] # Returns the required number of IP addresses for a specific Azure Managed Lustre SKU and filesystem size azmcp azuremanagedlustre filesystem subnetsize ask --subscription \ @@ -1094,6 +1122,10 @@ azmcp azuremanagedlustre filesystem subnetsize validate --subscription \ --size \ --location + +# Lists the available Azure Managed Lustre SKUs in a specific location +azmcp azuremanagedlustre filesystem sku get --subscription \ + --location ``` ### Azure Native ISV Operations diff --git a/eng/pipelines/templates/jobs/live-test.yml b/eng/pipelines/templates/jobs/live-test.yml index ad8005913..7ed6fbc26 100644 --- a/eng/pipelines/templates/jobs/live-test.yml +++ b/eng/pipelines/templates/jobs/live-test.yml @@ -1,7 +1,7 @@ parameters: - name: TimeoutInMinutes type: number - default: 60 + default: 75 jobs: - job: LiveTest diff --git a/servers/Azure.Mcp.Server/CHANGELOG.md b/servers/Azure.Mcp.Server/CHANGELOG.md index c7f497508..add6f7a12 100644 --- a/servers/Azure.Mcp.Server/CHANGELOG.md +++ b/servers/Azure.Mcp.Server/CHANGELOG.md @@ -11,6 +11,9 @@ The Azure MCP Server updates automatically by default whenever a new release com [[#705](https://github.com/microsoft/mcp/pull/705)] - Added the following Azure Managed Lustre commands: - `azmcp_azuremanagedlustre_filesystem_subnetsize_validate`: Check if the subnet can host the target Azure Managed Lustre SKU and size [[#110](https://github.com/microsoft/mcp/issues/110)]. +- Added the following Azure Managed Lustre commands: [[#50](https://github.com/microsoft/mcp/issues/50)] + - `azmcp_azuremanagedlustre_filesystem_create`: Create an Azure Managed Lustre filesystems. + - `azmcp_azuremanagedlustre_filesystem_update`: Update an Azure Managed Lustre filesystems. ### Breaking Changes diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 1946a6402..02fe4131f 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -331,6 +331,7 @@ To use Azure Entra ID, review the [troubleshooting guide](https://github.com/mic * "List the Azure Managed Lustre clusters in resource group 'my-resource-group'" * "How many IP Addresses I need to create a 128 TiB cluster of AMLFS 500?" * "Check if 'my-subnet-id' can host an Azure Managed Lustre with 'my-size' TiB and 'my-sku' in 'my-region' +* Create a 4 TIB Azure Managed Lustre filesystem in 'my-region' attaching to 'my-subnet' in virtual network 'my-virtual-network' ### 📊 Azure Monitor diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/AzureManagedLustreSetup.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/AzureManagedLustreSetup.cs index 151180a02..3c52ce270 100644 --- a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/AzureManagedLustreSetup.cs +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/AzureManagedLustreSetup.cs @@ -18,6 +18,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -26,7 +28,7 @@ public void ConfigureServices(IServiceCollection services) public CommandGroup RegisterCommands(IServiceProvider serviceProvider) { var azureManagedLustre = new CommandGroup(Name, - "Azure Managed Lustre operations - Commands for listing and inspecting Azure Managed Lustre file systems (AMLFS) used for high-performance computing workloads."); + "Azure Managed Lustre operations - Commands for creating, updating, listing and inspecting Azure Managed Lustre file systems (AMLFS) used for high-performance computing workloads. The tool focuses on managing all the aspects related to Azure Managed Lustre filesystem instances."); var fileSystem = new CommandGroup("filesystem", "Azure Managed Lustre file system operations - Commands for listing managed Lustre file systems."); azureManagedLustre.AddSubGroup(fileSystem); @@ -34,6 +36,12 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) var list = serviceProvider.GetRequiredService(); fileSystem.AddCommand(list.Name, list); + var create = serviceProvider.GetRequiredService(); + fileSystem.AddCommand(create.Name, create); + + var update = serviceProvider.GetRequiredService(); + fileSystem.AddCommand(update.Name, update); + var subnetSize = new CommandGroup("subnetsize", "Subnet size planning and validation operations for Azure Managed Lustre."); fileSystem.AddSubGroup(subnetSize); diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/AzureManagedLustreJsonContext.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/AzureManagedLustreJsonContext.cs index b8069c00d..079267ed3 100644 --- a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/AzureManagedLustreJsonContext.cs +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/AzureManagedLustreJsonContext.cs @@ -11,6 +11,8 @@ namespace Azure.Mcp.Tools.AzureManagedLustre.Commands; [JsonSerializable(typeof(SubnetSizeValidateCommand.FileSystemCheckSubnetResult))] [JsonSerializable(typeof(FileSystemListCommand.FileSystemListResult))] [JsonSerializable(typeof(SkuGetCommand.SkuGetResult))] +[JsonSerializable(typeof(FileSystemCreateCommand.FileSystemCreateResult))] +[JsonSerializable(typeof(FileSystemUpdateCommand.FileSystemUpdateResult))] [JsonSerializable(typeof(LustreFileSystem))] [JsonSerializable(typeof(AzureManagedLustreSkuInfo))] [JsonSerializable(typeof(AzureManagedLustreSkuCapability))] diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/BaseAzureManagedLustreCommand.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/BaseAzureManagedLustreCommand.cs index f3c30604e..f33833e0d 100644 --- a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/BaseAzureManagedLustreCommand.cs +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/BaseAzureManagedLustreCommand.cs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.CommandLine.Parsing; using System.Diagnostics.CodeAnalysis; +using System.Net; using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; using Azure.Mcp.Tools.AzureManagedLustre.Options; using Microsoft.Extensions.Logging; @@ -15,4 +18,93 @@ public abstract class BaseAzureManagedLustreCommand< { // Currently no additional options beyond subscription + resource group protected readonly ILogger> _logger = logger; + + public void ValidateRootSquashOptions(CommandResult commandResult) + { + var rootSquashMode = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.RootSquashModeOption); + var noSquashNidLists = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.NoSquashNidListsOption); + var squashUid = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.SquashUidOption); + var squashGid = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.SquashGidOption); + + + // If root squash mode is provided and not 'none', require UID, GID and no squash NID list + if (!string.IsNullOrWhiteSpace(rootSquashMode) && !rootSquashMode.Equals("None", StringComparison.OrdinalIgnoreCase)) + { + if (!(squashUid.HasValue && squashGid.HasValue && !string.IsNullOrWhiteSpace(noSquashNidLists))) + { + commandResult.AddError("When --root-squash-mode is not 'None', --squash-uid, --squash-gid and --no-squash-nid-list must be provided."); + } + } + } + + public void ValidateMaintanenceOptionsCreate(CommandResult commandResult) + { + ValidateMaintenanceOptions(commandResult, false); + } + + public void ValidateMaintanenceOptionsUpdate(CommandResult commandResult) + { + ValidateMaintenanceOptions(commandResult, true); + } + + public void ValidateMaintenanceOptions(CommandResult commandResult, bool update = false) + { + // Read values from the same option instances used during registration + var maintenanceDay = update ? commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.OptionalMaintenanceDayOption) : commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.MaintenanceDayOption); + var maintenanceTime = update ? commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.OptionalMaintenanceTimeOption) : commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.MaintenanceTimeOption); + var updateWithoutMaintenance = string.IsNullOrWhiteSpace(maintenanceDay) && string.IsNullOrWhiteSpace(maintenanceTime) && update; + + if ((string.IsNullOrWhiteSpace(maintenanceDay) || string.IsNullOrWhiteSpace(maintenanceTime)) && !updateWithoutMaintenance) + { + commandResult.AddError("When updating maintenance window, both --maintenance-day and --maintenance-time must be specified."); + } + } + + public void ValidateHSMOptions(CommandResult commandResult) + { + // Read values from the same option instances used during registration + var hsmContainer = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.HsmContainerOption); + var hsmLogContainer = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.HsmLogContainerOption); + var hsmEnabled = !string.IsNullOrWhiteSpace(hsmContainer) || !string.IsNullOrWhiteSpace(hsmLogContainer); + + + // Always require both values if one is specified. + if (hsmEnabled && (string.IsNullOrWhiteSpace(hsmContainer) || string.IsNullOrWhiteSpace(hsmLogContainer))) + { + commandResult.AddError("When enabling Azure Blob Integration both data container and log container must be specified."); + } + } + + public void ValidateEncryptionOptions(CommandResult commandResult) + { + // Read values from the same option instances used during registration + var encryptionEnabled = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.CustomEncryptionOption); + var keyUrl = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.KeyUrlOption); + var sourceVault = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.SourceVaultOption); + var userAssignedIdentityId = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.UserAssignedIdentityIdOption); + + if (encryptionEnabled == true) + { + if (string.IsNullOrWhiteSpace(keyUrl) || string.IsNullOrWhiteSpace(sourceVault) || string.IsNullOrWhiteSpace(userAssignedIdentityId)) + { + commandResult.AddError("Missing Required options: key-url, source-vault, user-assigned-identity when custom-encryption is set"); + ; + } + } + } + + public void ValidateHasUpdateOptions(CommandResult commandResult) + { + var maintenanceDay = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.OptionalMaintenanceDayOption); + var maintenanceTime = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.OptionalMaintenanceTimeOption); + var rootSquashMode = commandResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.RootSquashModeOption); + + if (string.IsNullOrWhiteSpace(maintenanceDay) && + string.IsNullOrWhiteSpace(maintenanceTime) && + string.IsNullOrWhiteSpace(rootSquashMode) + ) + { + commandResult.AddError("At least one of maintenance-day/time or root-squash fields must be provided."); + } + } } diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/FileSystem/FileSystemCreateCommand.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/FileSystem/FileSystemCreateCommand.cs new file mode 100644 index 000000000..42d79b0f5 --- /dev/null +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/FileSystem/FileSystemCreateCommand.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Tools.AzureManagedLustre.Options; +using Azure.Mcp.Tools.AzureManagedLustre.Options.FileSystem; +using Azure.Mcp.Tools.AzureManagedLustre.Services; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tools.AzureManagedLustre.Commands.FileSystem; + +public sealed class FileSystemCreateCommand(ILogger logger) + : BaseAzureManagedLustreCommand(logger) +{ + private const string CommandTitle = "Create Azure Managed Lustre FileSystem"; + + private new readonly ILogger _logger = logger; + + public override string Name => "create"; + + public override string Description => + """ + Create an Azure Managed Lustre (AMLFS) file system using the specified network, capacity, maintenance window and availability zone. + Optionally provides possibility to define Blob Integration, customer managed key encryption and root squash configuration. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + Idempotent = false, + OpenWorld = false, + ReadOnly = false, + LocalRequired = false, + Secret = false + }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + + command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsRequired()); + command.Options.Add(AzureManagedLustreOptionDefinitions.NameOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.LocationOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.SkuOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.SizeOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.SubnetIdOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.ZoneOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.MaintenanceDayOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.MaintenanceTimeOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.HsmContainerOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.HsmLogContainerOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.ImportPrefixOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.RootSquashModeOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.NoSquashNidListsOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.SquashUidOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.SquashGidOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.CustomEncryptionOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.KeyUrlOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.SourceVaultOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.UserAssignedIdentityIdOption); + command.Validators.Add(ValidateRootSquashOptions); + command.Validators.Add(ValidateMaintanenceOptionsCreate); + command.Validators.Add(ValidateEncryptionOptions); + command.Validators.Add(ValidateHSMOptions); + } + + protected override FileSystemCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); + options.Name = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.NameOption.Name); + options.Location = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.LocationOption.Name); + options.Sku = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.SkuOption.Name); + options.SizeTiB = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.SizeOption.Name); + options.SubnetId = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.SubnetIdOption.Name); + options.Zone = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.ZoneOption.Name); + options.HsmContainer = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.HsmContainerOption.Name); + options.HsmLogContainer = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.HsmLogContainerOption.Name); + options.ImportPrefix = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.ImportPrefixOption.Name); + options.MaintenanceDay = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.MaintenanceDayOption.Name); + options.MaintenanceTime = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.MaintenanceTimeOption.Name); + options.RootSquashMode = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.RootSquashModeOption.Name); + options.NoSquashNidLists = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.NoSquashNidListsOption.Name); + options.SquashUid = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.SquashUidOption.Name); + options.SquashGid = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.SquashGidOption.Name); + options.EnableCustomEncryption = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.CustomEncryptionOption.Name); + options.KeyUrl = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.KeyUrlOption.Name); + options.SourceVaultId = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.SourceVaultOption.Name); + options.UserAssignedIdentityId = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.UserAssignedIdentityIdOption.Name); + return options; + } + + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + + try + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + var svc = context.GetService(); + var fs = await svc.CreateFileSystemAsync( + options.Subscription!, + options.ResourceGroup!, + options.Name!, + options.Location!, + options.Sku!, + options.SizeTiB!.Value, + options.SubnetId!, + options.Zone!, + options.MaintenanceDay!, + options.MaintenanceTime!, + options.HsmContainer, + options.HsmLogContainer, + options.ImportPrefix, + options.RootSquashMode, + options.NoSquashNidLists, + options.SquashUid, + options.SquashGid, + options.EnableCustomEncryption ?? false, + options.KeyUrl, + options.SourceVaultId, + options.UserAssignedIdentityId, + options.Tenant, + options.RetryPolicy); + + context.Response.Results = ResponseResult.Create(new(fs), AzureManagedLustreJsonContext.Default.FileSystemCreateResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating AMLFS."); + HandleException(context, ex); + } + + return context.Response; + } + + internal record FileSystemCreateResult(Models.LustreFileSystem FileSystem); +} diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/FileSystem/FileSystemUpdateCommand.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/FileSystem/FileSystemUpdateCommand.cs new file mode 100644 index 000000000..e5a2c18a8 --- /dev/null +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Commands/FileSystem/FileSystemUpdateCommand.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Tools.AzureManagedLustre.Options; +using Azure.Mcp.Tools.AzureManagedLustre.Options.FileSystem; +using Azure.Mcp.Tools.AzureManagedLustre.Services; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tools.AzureManagedLustre.Commands.FileSystem; + +public sealed class FileSystemUpdateCommand(ILogger logger) + : BaseAzureManagedLustreCommand(logger) +{ + private const string CommandTitle = "Update Azure Managed Lustre FileSystem"; + + private new readonly ILogger _logger = logger; + + public override string Name => "update"; + + public override string Description => + """ + Update maintenance window and/or root squash settings of an existing Azure Managed Lustre (AMLFS) file system. Provide either maintenance day and time or root squash fields (no-squash-nid-list, squash-uid, squash-gid). Root squash fields must be provided if root squash is not None. In case of maintenance window update, both maintenance day and maintenance time should be provided. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = true, + Idempotent = true, + OpenWorld = false, + ReadOnly = false, + LocalRequired = false, + Secret = false + }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + + command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsRequired()); + command.Options.Add(AzureManagedLustreOptionDefinitions.NameOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.OptionalMaintenanceDayOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.OptionalMaintenanceTimeOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.NoSquashNidListsOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.SquashUidOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.SquashGidOption); + command.Options.Add(AzureManagedLustreOptionDefinitions.RootSquashModeOption); + command.Validators.Add(ValidateRootSquashOptions); + command.Validators.Add(ValidateMaintanenceOptionsUpdate); + command.Validators.Add(ValidateEncryptionOptions); + command.Validators.Add(ValidateHSMOptions); + command.Validators.Add(ValidateHasUpdateOptions); + } + + protected override FileSystemUpdateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); + options.Name = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.NameOption.Name); + options.MaintenanceDay = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.OptionalMaintenanceDayOption.Name); + options.MaintenanceTime = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.OptionalMaintenanceTimeOption.Name); + options.RootSquashMode = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.RootSquashModeOption.Name); + options.NoSquashNidLists = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.NoSquashNidListsOption.Name); + options.SquashUid = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.SquashUidOption.Name); + options.SquashGid = parseResult.GetValueOrDefault(AzureManagedLustreOptionDefinitions.SquashGidOption.Name); + return options; + } + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + try + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + var options = BindOptions(parseResult); + + var svc = context.GetService(); + var fs = await svc.UpdateFileSystemAsync( + options.Subscription!, + options.ResourceGroup!, + options.Name!, + options.MaintenanceDay, + options.MaintenanceTime, + options.RootSquashMode, + options.NoSquashNidLists, + options.SquashUid, + options.SquashGid, + options.Tenant, + options.RetryPolicy); + + context.Response.Results = ResponseResult.Create(new FileSystemUpdateResult(fs), AzureManagedLustreJsonContext.Default.FileSystemUpdateResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating AMLFS."); + HandleException(context, ex); + } + + return context.Response; + } + + internal record FileSystemUpdateResult(Models.LustreFileSystem FileSystem); +} diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Options/AzureManagedLustreOptionDefinitions.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Options/AzureManagedLustreOptionDefinitions.cs index 3955bd8c7..1a49f7f36 100644 --- a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Options/AzureManagedLustreOptionDefinitions.cs +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Options/AzureManagedLustreOptionDefinitions.cs @@ -1,43 +1,202 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Core.Models.Option; + namespace Azure.Mcp.Tools.AzureManagedLustre.Options; public static class AzureManagedLustreOptionDefinitions { public const string sku = "sku"; public const string size = "size"; - public const string subnetId = "subnet-id"; + public const string name = "name"; public const string location = "location"; + public const string subnetId = "subnet-id"; + public const string zone = "zone"; + public const string hsmContainer = "hsm-container"; + public const string hsmLogContainer = "hsm-log-container"; + public const string importPrefix = "import-prefix"; + public const string maintenanceDay = "maintenance-day"; + public const string maintenanceTime = "maintenance-time"; + public const string rootSquashMode = "root-squash-mode"; + public const string noSquashNidLists = "no-squash-nid-list"; + public const string squashUid = "squash-uid"; + public const string squashGid = "squash-gid"; + public const string customEncryption = "custom-encryption"; + public const string keyUrl = "key-url"; + public const string sourceVault = "source-vault"; + public const string userAssignedIdentityId = "user-assigned-identity-id"; + public static readonly Option SkuOption = new( $"--{sku}" ) { - Description = "The AMLFS SKU. Allowed values: AMLFS-Durable-Premium-40, AMLFS-Durable-Premium-125, AMLFS-Durable-Premium-250, AMLFS-Durable-Premium-500.", - Required = true + Required = true, + Description = "The AMLFS SKU. Exact allowed values: AMLFS-Durable-Premium-40, AMLFS-Durable-Premium-125, AMLFS-Durable-Premium-250, AMLFS-Durable-Premium-500." }; public static readonly Option SizeOption = new( $"--{size}" ) { - Description = "The AMLFS size (TiB).", + Required = true, + Description = "The AMLFS size in TiB as an integer (no unit). Examples: 4, 12, 128." + }; + + public static readonly Option LocationOption = new( + $"--{location}" + ) + { + Description = "Azure region/region short name (use Azure location token, lowercase). Examples: uaenorth, swedencentral, eastus.", Required = true }; + public static readonly Option OptionalLocationOption = new( + $"--{location}" + ) + { + Description = LocationOption.Description, + Required = false + }; + + public static readonly Option NameOption = new( + $"--{name}" + ) + { + Required = true, + Description = "The AMLFS resource name. Must be DNS-friendly (letters, numbers, hyphens). Example: --name amlfs-001" + }; + public static readonly Option SubnetIdOption = new( $"--{subnetId}" ) { - Description = "The subnet resource ID to validate for AMLFS.", - Required = true + Required = true, + Description = "Full subnet resource ID. Required format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnet}/subnets/{subnet}.\n" + + "Example: --subnet-id /subscriptions/0000/resourceGroups/my-rg/providers/Microsoft.Network/virtualNetworks/vnet-001/subnets/subnet-001" }; - public static readonly Option LocationOption = new( - $"--{location}" + public static readonly Option ZoneOption = new( + $"--{zone}" ) { - Description = "Azure region/region short name (use Azure location token, lowercase). Examples: uaenorth, swedencentral, eastus.", - Required = true + Required = true, + Description = "Availability zone identifier. Use a single digit string matching the region's AZ labels (e.g. '1').\n" + + "Example: --zone 1" + }; + + public static readonly Option HsmContainerOption = new( + $"--{hsmContainer}" + ) + { + Required = false, + Description = "Full blob container resource ID for HSM integration. HPC Cache Resource Provider must have before deployment Storage Blob Data Contributor and Storage Account Contributor roles on parent Storage Account." + + "Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{account}/blobServices/default/containers/{container}.\n" + + "Example: --hsm-container /subscriptions/0000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/stacc/blobServices/default/containers/hsm-container\n" + }; + + public static readonly Option HsmLogContainerOption = new( + $"--{hsmLogContainer}" + ) + { + Required = false, + Description = "Full blob container resource ID for HSM logging. HPC Cache Resource Provider must have before deployment Storage Blob Data Contributor and Storage Account Contributor roles on parent Storage Account. Same format as --hsm-container.\n" + + "Example: --hsm-log-container /subscriptions/0000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/stacc/blobServices/default/containers/hsm-logs\n" + }; + + public static readonly Option ImportPrefixOption = new( + $"--{importPrefix}" + ) + { + Required = false, + Description = "Optional HSM import prefix (path prefix inside the container starting with /). Examples: '/ingest/', '/archive/2019/'.\n" + }; + + public static readonly Option MaintenanceDayOption = new( + $"--{maintenanceDay}" + ) + { + Required = true, + Description = "Preferred maintenance day. Allowed values: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday.\n" + }; + + public static readonly Option MaintenanceTimeOption = new( + $"--{maintenanceTime}" + ) + { + Required = true, + Description = "Preferred maintenance time in UTC. Format: HH:MM (24-hour). Examples: 00:00, 23:00.\n" + }; + + public static readonly Option RootSquashModeOption = new( + $"--{rootSquashMode}" + ) + { + Required = false, + Description = "Root squash mode. Allowed values: All, RootOnly, None.\n" + }; + + public static readonly Option NoSquashNidListsOption = new( + $"--{noSquashNidLists}" + ) + { + Required = false, + Description = "Comma-separated list of NIDs (network identifiers) not to squash. Example: '10.0.2.4@tcp;10.0.2.[6-8]@tcp'.\n" + }; + + public static readonly Option SquashUidOption = new( + $"--{squashUid}" + ) + { + Required = false, + Description = "Numeric UID to squash root to. Required in case root squash mode is not None. Example: --squash-uid 1000.\n" + }; + + public static readonly Option SquashGidOption = new( + $"--{squashGid}" + ) + { + Required = false, + Description = "Numeric GID to squash root to. Required in case root squash mode is not None. Example: --squash-gid 1000.\n" + }; + + public static readonly Option CustomEncryptionOption = new( + $"--{customEncryption}" + ) + { + Required = false, + Description = "Enable customer-managed encryption using a Key Vault key. When true, --key-url and --source-vault required, with a user-assigned identity already configured for Key Vault key access." + }; + + public static readonly Option KeyUrlOption = new( + $"--{keyUrl}" + ) + { + Required = false, + Description = "Full Key Vault key URL. Format: https://{vaultName}.vault.azure.net/keys/{keyName}/{keyVersion}.\n" + + "Example: --key-url https://kv-amlfs-001.vault.azure.net/keys/key-amlfs-001/0000\n" }; + + public static readonly Option SourceVaultOption = new( + $"--{sourceVault}" + ) + { + Required = false, + Description = "Full Key Vault resource ID. Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.KeyVault/vaults/{vaultName}.\n" + + "Example: --source-vault /subscriptions/0000/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv-amlfs-001\n" + }; + + public static readonly Option UserAssignedIdentityIdOption = new( + $"--{userAssignedIdentityId}" + ) + { + Required = false, + Description = "User-assigned managed identity resource ID (full resource ID) to use for Key Vault access when custom encryption is enabled. The identity must have RBAC role to access the encryption key\n" + + "Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}.\n" + + "Example: --user-assigned-identity-id /subscriptions/0000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/identity1\n" + }; + + public static readonly Option OptionalMaintenanceDayOption = MaintenanceDayOption.AsOptional(); + public static readonly Option OptionalMaintenanceTimeOption = MaintenanceTimeOption.AsOptional(); } + diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Options/FileSystem/FileSystemCreateOptions.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Options/FileSystem/FileSystemCreateOptions.cs new file mode 100644 index 000000000..81624b1ef --- /dev/null +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Options/FileSystem/FileSystemCreateOptions.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.AzureManagedLustre.Options.FileSystem; + +public sealed class FileSystemCreateOptions : BaseAzureManagedLustreOptions +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("location")] + public string? Location { get; set; } + + [JsonPropertyName(AzureManagedLustreOptionDefinitions.sku)] + public string? Sku { get; set; } + + [JsonPropertyName(AzureManagedLustreOptionDefinitions.size)] + public int? SizeTiB { get; set; } + + [JsonPropertyName("subnet-id")] + public string? SubnetId { get; set; } + + [JsonPropertyName("zone")] + public string? Zone { get; set; } + + [JsonPropertyName("hsm-container")] + public string? HsmContainer { get; set; } + + [JsonPropertyName("hsm-log-container")] + public string? HsmLogContainer { get; set; } + + [JsonPropertyName("import-prefix")] + public string? ImportPrefix { get; set; } + + [JsonPropertyName("maintenance-day")] + public string? MaintenanceDay { get; set; } + + [JsonPropertyName("maintenance-time")] + public string? MaintenanceTime { get; set; } + + [JsonPropertyName("root-squash-mode")] + public string? RootSquashMode { get; set; } + + [JsonPropertyName("no-squash-nid-list")] + public string? NoSquashNidLists { get; set; } + + [JsonPropertyName("squash-uid")] + public long? SquashUid { get; set; } + + [JsonPropertyName("squash-gid")] + public long? SquashGid { get; set; } + + [JsonPropertyName("custom-encryption")] + public bool? EnableCustomEncryption { get; set; } + + [JsonPropertyName("key-url")] + public string? KeyUrl { get; set; } + + [JsonPropertyName("source-vault")] + public string? SourceVaultId { get; set; } + + [JsonPropertyName("user-assigned-identity-id")] + public string? UserAssignedIdentityId { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Options/FileSystem/FileSystemUpdateOptions.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Options/FileSystem/FileSystemUpdateOptions.cs new file mode 100644 index 000000000..6a95708cf --- /dev/null +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Options/FileSystem/FileSystemUpdateOptions.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.AzureManagedLustre.Options.FileSystem; + +public sealed class FileSystemUpdateOptions : BaseAzureManagedLustreOptions +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("maintenance-day")] + public string? MaintenanceDay { get; set; } + + [JsonPropertyName("maintenance-time")] + public string? MaintenanceTime { get; set; } + + [JsonPropertyName("root-squash-mode")] + public string? RootSquashMode { get; set; } + + [JsonPropertyName("no-squash-nid-list")] + public string? NoSquashNidLists { get; set; } + + [JsonPropertyName("squash-uid")] + public long? SquashUid { get; set; } + + [JsonPropertyName("squash-gid")] + public long? SquashGid { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Services/AzureManagedLustreService.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Services/AzureManagedLustreService.cs index 411ac4760..8c4967cc4 100644 --- a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Services/AzureManagedLustreService.cs +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Services/AzureManagedLustreService.cs @@ -2,15 +2,19 @@ // Licensed under the MIT License. using System.Net; +using Azure.Core; using Azure.Mcp.Core.Options; using Azure.Mcp.Core.Services.Azure; using Azure.Mcp.Core.Services.Azure.ResourceGroup; using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.Mcp.Tools.AzureManagedLustre.Models; +using Azure.ResourceManager.Models; +using Azure.ResourceManager.Resources.Models; using Azure.ResourceManager.StorageCache; using Azure.ResourceManager.StorageCache.Models; + namespace Azure.Mcp.Tools.AzureManagedLustre.Services; public sealed class AzureManagedLustreService(ISubscriptionService subscriptionService, IResourceGroupService resourceGroupService, ITenantService tenantService) : BaseAzureService(tenantService), IAzureManagedLustreService @@ -20,7 +24,7 @@ public sealed class AzureManagedLustreService(ISubscriptionService subscriptionS public async Task> ListFileSystemsAsync(string subscription, string? resourceGroup = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null) { - ArgumentException.ThrowIfNullOrWhiteSpace(subscription); + ValidateRequiredParameters(subscription); var results = new List(); @@ -87,6 +91,100 @@ private static List MapCapabilities(IEnumerable return list; } + private static AmlFileSystemPropertiesMaintenanceWindow GenerateMaintenanceWindow(string maintenanceDay, string maintenanceTime) + { + MaintenanceDayOfWeekType dayEnum; + + if (!Enum.TryParse(maintenanceDay, true, out dayEnum)) + { + throw new ArgumentException($"Invalid maintenance day '{maintenanceDay}'. Allowed values: Monday..Sunday"); + } + + return new AmlFileSystemPropertiesMaintenanceWindow + { + DayOfWeek = dayEnum, + TimeOfDayUTC = maintenanceTime + }; + } + + private static AmlFileSystemRootSquashSettings GenerateRootSquashSettings(string rootSquashMode, string? noSquashNidLists, long? squashUid, long? squashGid) + { + // Root squash: default to None if not provided; when not None, ensure required squash parameters are provided + var rootSquashSettings = new AmlFileSystemRootSquashSettings + { + Mode = AmlFileSystemSquashMode.None + }; + + if (!string.IsNullOrWhiteSpace(rootSquashMode)) + { + AmlFileSystemSquashMode modeParsed = rootSquashMode; + + // When a squash mode other than None is specified, UID and GID must be provided + if (modeParsed != AmlFileSystemSquashMode.None) + { + if (!squashUid.HasValue) + { + throw new ArgumentException("squash-uid must be provided when root-squash-mode is not None."); + } + if (!squashGid.HasValue) + { + throw new ArgumentException("squash-gid must be provided when root-squash-mode is not None."); + } + if (string.IsNullOrWhiteSpace(noSquashNidLists)) + { + throw new ArgumentException("no-squash-nid-list must be provided when root-squash-mode is not None."); + } + if (squashUid.Value < 0) + { + throw new ArgumentException("squash-uid must be a non-negative integer."); + } + if (squashGid.Value < 0) + { + throw new ArgumentException("squash-gid must be a non-negative integer."); + } + + rootSquashSettings = new AmlFileSystemRootSquashSettings + { + Mode = modeParsed, + NoSquashNidLists = noSquashNidLists, + SquashUID = squashUid, + SquashGID = squashGid + }; + } + } + + return rootSquashSettings; + } + + private static AmlFileSystemPropertiesHsm GenerateHsmSettings(string? hsmContainer, string? hsmLogContainer, string? importPrefix) + { + // HSM settings if provided + if (!string.IsNullOrWhiteSpace(hsmContainer) || !string.IsNullOrWhiteSpace(hsmLogContainer) || !string.IsNullOrWhiteSpace(importPrefix)) + { + if (string.IsNullOrWhiteSpace(hsmContainer) || string.IsNullOrWhiteSpace(hsmLogContainer)) + { + throw new ArgumentException("Both hsm-container and hsm-log-container must be provided when specifying HSM settings."); + } + + var hsmSettings = new AmlFileSystemHsmSettings(hsmContainer, hsmLogContainer); + if (!string.IsNullOrWhiteSpace(importPrefix)) + { + hsmSettings.ImportPrefix = importPrefix; + } + + return new AmlFileSystemPropertiesHsm + { + Settings = hsmSettings + }; + } + else + { + return new AmlFileSystemPropertiesHsm + { + Settings = null + }; + } + } public async Task GetRequiredAmlFSSubnetsSize(string subscription, string sku, int size, string? tenant = null, @@ -158,6 +256,179 @@ sku.LocationInfo is null || } } + + public async Task CreateFileSystemAsync( + string subscription, + string resourceGroup, + string name, + string location, + string sku, + int sizeTiB, + string subnetId, + string zone, + string maintenanceDay, + string maintenanceTime, + string? hsmContainer = null, + string? hsmLogContainer = null, + string? importPrefix = null, + string? rootSquashMode = null, + string? noSquashNidLists = null, + long? squashUid = null, + long? squashGid = null, + bool enableCustomEncryption = false, + string? keyUrl = null, + string? sourceVaultId = null, + string? userAssignedIdentityId = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null + ) + { + ValidateRequiredParameters(subscription, resourceGroup, name, location, sku, subnetId); + + var rg = await _resourceGroupService.GetResourceGroupResource(subscription, resourceGroup, tenant, retryPolicy) + ?? throw new Exception($"Resource group '{resourceGroup}' not found"); + var sub = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy) + ?? throw new Exception($"Subscription '{subscription}' not found"); + + var data = new AmlFileSystemData(new AzureLocation(location)) + { + SkuName = sku, + StorageCapacityTiB = sizeTiB, + FilesystemSubnet = subnetId + }; + + // Validate zone support for the specified location before adding + try + { + bool? supportsZones = null; + + await foreach (var loc in sub.GetLocationsAsync()) + { + if (loc.Name.Equals(location, StringComparison.OrdinalIgnoreCase) || + loc.DisplayName.Equals(location, StringComparison.OrdinalIgnoreCase)) + { + supportsZones = (loc.AvailabilityZoneMappings?.Count ?? 0) > 0; + break; + } + } + + if (supportsZones == false && !string.Equals(zone, "1", StringComparison.OrdinalIgnoreCase)) + { + throw new Exception($"Location '{location}' does not support availability zones; only zone '1' is allowed."); + } + if (supportsZones == true) + { + // Zone is required by command; add to zones + data.Zones.Add(zone); + } + + } + catch (Exception ex) + { + throw new Exception($"Failed to validate availability zones for location '{location}': {ex.Message}", ex); + } + + data.RootSquashSettings = GenerateRootSquashSettings(rootSquashMode ?? "None", noSquashNidLists, squashUid, squashGid); + data.MaintenanceWindow = GenerateMaintenanceWindow(maintenanceDay, maintenanceTime); + data.Hsm = GenerateHsmSettings(hsmContainer, hsmLogContainer, importPrefix); + + // Encryption + if (enableCustomEncryption) + { + if (string.IsNullOrWhiteSpace(keyUrl) || string.IsNullOrWhiteSpace(sourceVaultId)) + { + throw new Exception("Both key-url and source-vault must be provided when custom-encryption is enabled."); + } + data.KeyEncryptionKey = new StorageCacheEncryptionKeyVaultKeyReference( + new Uri(keyUrl!), + new WritableSubResource { Id = new ResourceIdentifier(sourceVaultId!) }); + + // Assign user-assigned managed identity for Key Vault access + if (!string.IsNullOrWhiteSpace(userAssignedIdentityId)) + { + data.Identity = new ManagedServiceIdentity(ManagedServiceIdentityType.UserAssigned) + { + UserAssignedIdentities = + { + [new ResourceIdentifier(userAssignedIdentityId)] = new UserAssignedIdentity() + } + }; + + } + } + + try + { + var collection = rg.GetAmlFileSystems(); + var createOperationResult = await collection.CreateOrUpdateAsync(WaitUntil.Completed, name, data); + var fileSystemResource = createOperationResult.Value; + return Map(fileSystemResource); + } + catch (RequestFailedException rfe) + { + throw new Exception($"Failed to create AML file system '{name}': {rfe.Message}", rfe); + } + catch (Exception ex) + { + throw new Exception($"Failed to create AML file system '{name}': {ex.Message}", ex); + } + } + + public async Task UpdateFileSystemAsync( + string subscription, + string resourceGroup, + string name, + string? maintenanceDay = null, + string? maintenanceTime = null, + string? rootSquashMode = null, + string? noSquashNidLists = null, + long? squashUid = null, + long? squashGid = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null) + { + ValidateRequiredParameters(subscription, resourceGroup, name); + + var rg = await _resourceGroupService.GetResourceGroupResource(subscription, resourceGroup, tenant, retryPolicy) + ?? throw new Exception($"Resource group '{resourceGroup}' not found"); + + try + { + var fs = await rg.GetAmlFileSystemAsync(name); + + var patch = new AmlFileSystemPatch(); + + // Maintenance window update if any value provided + if (!string.IsNullOrWhiteSpace(maintenanceDay) && !string.IsNullOrWhiteSpace(maintenanceTime)) + { + var maintenanceWindowConfiguration = GenerateMaintenanceWindow(maintenanceDay, maintenanceTime); + + patch.MaintenanceWindow = new AmlFileSystemUpdatePropertiesMaintenanceWindow + { + DayOfWeek = maintenanceWindowConfiguration.DayOfWeek, + TimeOfDayUTC = maintenanceWindowConfiguration.TimeOfDayUTC + }; + } + + // Root squash updates: if any related field provided, set RootSquashSettings accordingly + if (!string.IsNullOrWhiteSpace(rootSquashMode)) + { + patch.RootSquashSettings = GenerateRootSquashSettings(rootSquashMode ?? "None", noSquashNidLists, squashUid, squashGid); + } + + var updateOperation = await fs.Value.UpdateAsync(WaitUntil.Completed, patch); + return Map(updateOperation.Value); + } + catch (RequestFailedException rfe) + { + throw new Exception($"Failed to update AML file system '{name}': {rfe.Message}", rfe); + } + catch (Exception ex) + { + throw new Exception($"Failed to update AML file system '{name}': {ex.Message}", ex); + } + } + public async Task CheckAmlFSSubnetAsync( string subscription, string sku, diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Services/IAzureManagedLustreService.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Services/IAzureManagedLustreService.cs index 933e82c1c..94674cab5 100644 --- a/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Services/IAzureManagedLustreService.cs +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/src/Services/IAzureManagedLustreService.cs @@ -33,4 +33,49 @@ Task> SkuGetInfoAsync( string? tenant = null, string? location = null, RetryPolicyOptions? retryPolicy = null); + + Task CreateFileSystemAsync( + string subscription, + string resourceGroup, + string name, + string location, + string sku, + int sizeTiB, + string subnetId, + string zone, + // Maintenance window + string maintenanceDay, + string maintenanceTime, + // HSM + string? hsmContainer = null, + string? hsmLogContainer = null, + string? importPrefix = null, + // Root squash + string? rootSquashMode = null, + string? noSquashNidLists = null, + long? squashUid = null, + long? squashGid = null, + // Encryption + bool enableCustomEncryption = false, + string? keyUrl = null, + string? sourceVaultId = null, + string? userAssignedIdentityId = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null); + + Task UpdateFileSystemAsync( + string subscription, + string resourceGroup, + string name, + // Maintenance window (optional) + string? maintenanceDay = null, + string? maintenanceTime = null, + // Root squash updates (all optional; if UID/GID provided, both required) + string? rootSquashMode = null, + string? noSquashNidLists = null, + long? squashUid = null, + long? squashGid = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null); } + diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/Azure.Mcp.Tools.AzureManagedLustre.LiveTests/AzureManagedLustreCommandTests.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/Azure.Mcp.Tools.AzureManagedLustre.LiveTests/AzureManagedLustreCommandTests.cs index ca79b3065..212c68c49 100644 --- a/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/Azure.Mcp.Tools.AzureManagedLustre.LiveTests/AzureManagedLustreCommandTests.cs +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/Azure.Mcp.Tools.AzureManagedLustre.LiveTests/AzureManagedLustreCommandTests.cs @@ -22,6 +22,23 @@ public async Task Should_list_filesystems_by_subscription() var fileSystems = result.AssertProperty("fileSystems"); Assert.Equal(JsonValueKind.Array, fileSystems.ValueKind); + var found = false; + + foreach (var fs in fileSystems.EnumerateArray()) + { + if (fs.ValueKind != JsonValueKind.Object) + continue; + + if (fs.TryGetProperty("name", out var nameProp) && + nameProp.ValueKind == JsonValueKind.String && + string.Equals(nameProp.GetString(), Settings.ResourceBaseName, StringComparison.OrdinalIgnoreCase)) + { + found = true; + break; + } + } + + Assert.True(found, $"Expected at least one filesystem in resource group with name '{Settings.ResourceBaseName}'."); } [Fact] @@ -38,6 +55,7 @@ public async Task Should_calculate_required_subnet_size() var ips = result.AssertProperty("numberOfRequiredIPs"); Assert.Equal(JsonValueKind.Number, ips.ValueKind); + Assert.Equal(21, ips.GetInt32()); } [Fact] @@ -94,6 +112,119 @@ public async Task Should_get_sku_info_no_zonal_support() } + [Fact] + public async Task Should_create_azure_managed_lustre_no_blob_no_cmk() + { + var fsName = $"amlfs-{Guid.NewGuid().ToString("N")[..8]}"; + var subnetId = Environment.GetEnvironmentVariable("AMLFS_SUBNET_ID"); + var location = Environment.GetEnvironmentVariable("LOCATION"); + + // Calculate CMK required variables + + var keyUri = Environment.GetEnvironmentVariable("KEY_URI_WITH_VERSION"); + var keyVaultResourceId = Environment.GetEnvironmentVariable("KEY_VAULT_RESOURCE_ID"); + var userAssignedIdentityId = Environment.GetEnvironmentVariable("USER_ASSIGNED_IDENTITY_RESOURCE_ID"); + + // Calculate HSM required variables + var hsmDataContainerId = Environment.GetEnvironmentVariable("HSM_CONTAINER_ID"); + var hsmLogContainerId = Environment.GetEnvironmentVariable("HSM_LOGS_CONTAINER_ID"); + + var result = await CallToolAsync( + "azmcp_azuremanagedlustre_filesystem_create", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "location", location }, + { "name", fsName }, + { "sku", "AMLFS-Durable-Premium-500" }, + { "size", 4 }, + { "zone", 1 }, + { "subnet-id", subnetId }, + { "hsm-container", hsmDataContainerId }, + { "hsm-log-container", hsmLogContainerId }, + { "custom-encryption", true }, + { "key-url", keyUri }, + { "source-vault", keyVaultResourceId }, + { "user-assigned-identity-id", userAssignedIdentityId }, + { "maintenance-day", "Monday" }, + { "maintenance-time", "01:00" }, + { "root-squash-mode", "All" }, + { "no-squash-nid-list", "10.0.0.4"}, + { "squash-uid", 1000 }, + { "squash-gid", 1000 } + }); + + var fileSystem = result.AssertProperty("fileSystem"); + Assert.Equal(JsonValueKind.Object, fileSystem.ValueKind); + + var name = fileSystem.GetProperty("name").GetString(); + Assert.Equal(fsName, name); + + var fsLocation = fileSystem.GetProperty("location").GetString(); + Assert.Equal(location, fsLocation); + + var capacity = fileSystem.AssertProperty("storageCapacityTiB"); + Assert.Equal(JsonValueKind.Number, capacity.ValueKind); + Assert.Equal(4, capacity.GetInt32()); + } + + [Fact] + public async Task Should_update_maintenance_and_verify_with_list() + { + // Update maintenance window for existing filesystem + var updateResult = await CallToolAsync( + "azmcp_azuremanagedlustre_filesystem_update", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "name", Settings.ResourceBaseName }, + { "maintenance-day", "Wednesday" }, + { "maintenance-time", "11:00" } + }); + + var updatedFs = updateResult.AssertProperty("fileSystem"); + Assert.Equal(JsonValueKind.Object, updatedFs.ValueKind); + + // Verify via list + var listResult = await CallToolAsync( + "azmcp_azuremanagedlustre_filesystem_list", + new() + { + { "subscription", Settings.SubscriptionId } + }); + + var fileSystems = listResult.AssertProperty("fileSystems"); + Assert.Equal(JsonValueKind.Array, fileSystems.ValueKind); + + var found = false; + foreach (var fs in fileSystems.EnumerateArray()) + { + if (fs.ValueKind != JsonValueKind.Object) + continue; + + if (fs.TryGetProperty("name", out var nameProp) && + nameProp.ValueKind == JsonValueKind.String && + string.Equals(nameProp.GetString(), Settings.ResourceBaseName, StringComparison.OrdinalIgnoreCase)) + { + // Check maintenance fields + if (fs.TryGetProperty("maintenanceDay", out var dayProp) && dayProp.ValueKind == JsonValueKind.String && + fs.TryGetProperty("maintenanceTime", out var timeProp) && timeProp.ValueKind == JsonValueKind.String) + { + Assert.Equal("Wednesday", dayProp.GetString()); + Assert.Equal("11:00", timeProp.GetString()); + found = true; + break; + } + } + } + + Assert.True(found, $"Expected filesystem '{Settings.ResourceBaseName}' to have maintenance Wednesday at 11:00."); + } + + + [Fact] public async Task Should_check_subnet_size_and_succeed() { diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/Azure.Mcp.Tools.AzureManagedLustre.UnitTests/FileSystem/FileSystemCreateCommandTests.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/Azure.Mcp.Tools.AzureManagedLustre.UnitTests/FileSystem/FileSystemCreateCommandTests.cs new file mode 100644 index 000000000..1db17ae6a --- /dev/null +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/Azure.Mcp.Tools.AzureManagedLustre.UnitTests/FileSystem/FileSystemCreateCommandTests.cs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.AzureManagedLustre.Commands; +using Azure.Mcp.Tools.AzureManagedLustre.Commands.FileSystem; +using Azure.Mcp.Tools.AzureManagedLustre.Models; +using Azure.Mcp.Tools.AzureManagedLustre.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.AzureManagedLustre.UnitTests.FileSystem; + +public class FileSystemCreateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IAzureManagedLustreService _svc; + private readonly ILogger _logger; + private readonly FileSystemCreateCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + private const string Sub = "sub123"; + private const string Rg = "rg1"; + private const string Name = "amlfs-01"; + private const string Location = "eastus"; + private const string Sku = "AMLFS-Durable-Premium-125"; + private const int Size = 4; + private const string SubnetId = "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/sub1"; + private const string Zone = "1"; + + public FileSystemCreateCommandTests() + { + _svc = Substitute.For(); + _logger = Substitute.For>(); + var services = new ServiceCollection().AddSingleton(_svc); + _serviceProvider = services.BuildServiceProvider(); + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = _command.GetCommand(); + Assert.Equal("create", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + } + + [Theory] + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --location eastus --sku AMLFS-Durable-Premium-125 --size 4 --subnet-id /subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/sub1 --zone 1 --maintenance-day Monday --maintenance-time 00:00", true)] + [InlineData("--resource-group rg1 --name amlfs-01 --location eastus --sku AMLFS-Durable-Premium-125 --size 4 --subnet-id /subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/sub1 --zone 1 --maintenance-day Monday --maintenance-time 00:00", false)] // Missing subscription + [InlineData("--subscription sub123 --name amlfs-01 --location eastus --sku AMLFS-Durable-Premium-125 --size 4 --subnet-id /subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/sub1 --zone 1 --maintenance-day Monday --maintenance-time 00:00", false)] // Missing resource-group + [InlineData("--subscription sub123 --resource-group rg1 --location eastus --sku AMLFS-Durable-Premium-125 --size 4 --subnet-id /subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/sub1 --zone 1 --maintenance-day Monday --maintenance-time 00:00", false)] // Missing name + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --sku AMLFS-Durable-Premium-125 --size 4 --subnet-id /subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/sub1 --zone 1 --maintenance-day Monday --maintenance-time 00:00", false)] // Missing location + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --location eastus --size 4 --subnet-id /subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/sub1 --zone 1 --maintenance-day Monday --maintenance-time 00:00", false)] // Missing sku + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --location eastus --sku AMLFS-Durable-Premium-125 --subnet-id /subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/sub1 --zone 1 --maintenance-day Monday --maintenance-time 00:00", false)] // Missing size + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --location eastus --sku AMLFS-Durable-Premium-125 --size 4 --zone 1 --maintenance-day Monday --maintenance-time 00:00", false)] // Missing subnet-id + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --location eastus --sku AMLFS-Durable-Premium-125 --size 4 --subnet-id /subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/sub1 --maintenance-day Monday --maintenance-time 00:00", false)] // Missing zone + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --location eastus --sku AMLFS-Durable-Premium-125 --size 4 --subnet-id /subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/sub1 --zone 1 --maintenance-time 00:00", false)] // Missing maintenance-day + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --location eastus --sku AMLFS-Durable-Premium-125 --size 4 --subnet-id /subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/sub1 --zone 1 --maintenance-day Monday", false)] // Missing maintenance-time + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + // Arrange + if (shouldSucceed) + { + var expected = CreateLustre(); + _svc.CreateFileSystemAsync( + Arg.Is(Sub), Arg.Is(Rg), Arg.Is(Name), Arg.Is(Location), Arg.Is(Sku), Arg.Is(Size), Arg.Is(SubnetId), Arg.Is(Zone), + Arg.Is("Monday"), Arg.Is("00:00"), + 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(expected); + } + + var parseResult = _commandDefinition.Parse(args.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + if (shouldSucceed) + { + Assert.NotNull(response.Results); + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, AzureManagedLustreJsonContext.Default.FileSystemCreateResult); + Assert.NotNull(result); + Assert.Equal(Name, result!.FileSystem.Name); + } + else + { + Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task ExecuteAsync_RootSquashNotNone_MissingOtherParams_Returns400() + { + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--location", Location, + "--sku", Sku, + "--size", Size.ToString(), + "--subnet-id", SubnetId, + "--zone", Zone, + "--maintenance-day", "Monday", + "--maintenance-time", "00:00", + "--root-squash-mode", "All" + ]); + + var response = await _command.ExecuteAsync(_context, args); + + Assert.True(response.Status >= HttpStatusCode.BadRequest); + Assert.Contains("root-squash-mode", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_RootSquashNotNone_WithParams_CallsService() + { + var expected = CreateLustre(); + _svc.CreateFileSystemAsync(Sub, Rg, Name, Location, Sku, Size, SubnetId, Zone, + "Monday", "00:00", + null, null, null, + "All", "nid1,nid2", 1000, 1000, + false, null, null, null, + null, Arg.Any()).Returns(expected); + + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--location", Location, + "--sku", Sku, + "--size", Size.ToString(), + "--subnet-id", SubnetId, + "--zone", Zone, + "--maintenance-day", "Monday", + "--maintenance-time", "00:00", + "--root-squash-mode", "All", + "--no-squash-nid-list", "nid1,nid2", + "--squash-uid", "1000", + "--squash-gid", "1000" + ]); + + var response = await _command.ExecuteAsync(_context, args); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await _svc.Received(1).CreateFileSystemAsync(Sub, Rg, Name, Location, Sku, Size, SubnetId, Zone, + "Monday", "00:00", + null, null, null, + "All", "nid1,nid2", 1000, 1000, + false, null, null, null, + null, Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_RootSquashNotNone_MissingNoSquashNidList_Returns400() + { + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--location", Location, + "--sku", Sku, + "--size", Size.ToString(), + "--subnet-id", SubnetId, + "--zone", Zone, + "--maintenance-day", "Monday", + "--maintenance-time", "00:00", + "--root-squash-mode", "All", + "--squash-uid", "1000", + "--squash-gid", "1000" + ]); + + var response = await _command.ExecuteAsync(_context, args); + + Assert.True(response.Status >= HttpStatusCode.BadRequest); + Assert.Contains("no-squash", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_EncryptionEnabledWithoutKey_Returns400() + { + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--location", Location, + "--sku", Sku, + "--size", Size.ToString(), + "--subnet-id", SubnetId, + "--zone", Zone, + "--maintenance-day", "Monday", + "--maintenance-time", "00:00", + "--custom-encryption", "true", + "--source-vault", "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv" + ]); + + var response = await _command.ExecuteAsync(_context, args); + + Assert.True(response.Status >= HttpStatusCode.BadRequest); + Assert.Contains("key-url", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_EncryptionEnabledWithKeyAndVault_CallsService() + { + var expected = CreateLustre(); + _svc.CreateFileSystemAsync(Sub, Rg, Name, Location, Sku, Size, SubnetId, Zone, + "Monday", "00:00", + null, null, null, + null, null, null, null, + true, "https://kv.vault.azure.net/keys/k/123", "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/identity1", + null, Arg.Any()).Returns(expected); + + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--location", Location, + "--sku", Sku, + "--size", Size.ToString(), + "--subnet-id", SubnetId, + "--zone", Zone, + "--maintenance-day", "Monday", + "--maintenance-time", "00:00", + "--custom-encryption", "true", + "--key-url", "https://kv.vault.azure.net/keys/k/123", + "--source-vault", "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv", + "--user-assigned-identity-id", "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/identity1" + ]); + + var response = await _command.ExecuteAsync(_context, args); + Assert.Equal(HttpStatusCode.OK, response.Status); + await _svc.Received(1).CreateFileSystemAsync(Sub, Rg, Name, Location, Sku, Size, SubnetId, Zone, + "Monday", "00:00", + null, null, null, + null, null, null, null, + true, "https://kv.vault.azure.net/keys/k/123", "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/identity1", + null, Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_ServiceThrowsGeneralException_Returns500() + { + _svc.CreateFileSystemAsync(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(), Arg.Any()).ThrowsAsync(new Exception("boom")); + + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--location", Location, + "--sku", Sku, + "--size", Size.ToString(), + "--subnet-id", SubnetId, + "--zone", Zone, + "--maintenance-day", "Monday", + "--maintenance-time", "00:00" + ]); + + var response = await _command.ExecuteAsync(_context, args); + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + Assert.Contains("boom", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_HandlesRequestFailedException_Conflict() + { + _svc.CreateFileSystemAsync(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(), Arg.Any()).ThrowsAsync(new Azure.RequestFailedException(409, "conflict")); + + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--location", Location, + "--sku", Sku, + "--size", Size.ToString(), + "--subnet-id", SubnetId, + "--zone", Zone, + "--maintenance-day", "Monday", + "--maintenance-time", "00:00" + ]); + + var response = await _command.ExecuteAsync(_context, args); + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Contains("conflict", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_HsmOneContainerMissing_ReturnsErrorFromService() + { + _svc.CreateFileSystemAsync(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(), Arg.Any()).ThrowsAsync(new Exception("Both hsm-container and hsm-log-container must be provided")); + + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--location", Location, + "--sku", Sku, + "--size", Size.ToString(), + "--subnet-id", SubnetId, + "--zone", Zone, + "--maintenance-day", "Monday", + "--maintenance-time", "00:00", + "--hsm-container", "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acc/blobServices/default/containers/hsm" + ]); + + var response = await _command.ExecuteAsync(_context, args); + Assert.True(response.Status >= HttpStatusCode.BadRequest); + Assert.Contains("Azure Blob Integration", response.Message, StringComparison.OrdinalIgnoreCase); + } + + private static LustreFileSystem CreateLustre() => new( + Name, + $"/subs/{Sub}/rg/{Rg}/providers/Microsoft.StorageCache/amlfs/{Name}", + Rg, + Sub, + Location, + "Succeeded", + "Healthy", + "10.0.0.4", + Sku, + Size, + null, + "Monday", + "00:00"); +} diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/Azure.Mcp.Tools.AzureManagedLustre.UnitTests/FileSystem/FileSystemUpdateCommandTests.cs b/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/Azure.Mcp.Tools.AzureManagedLustre.UnitTests/FileSystem/FileSystemUpdateCommandTests.cs new file mode 100644 index 000000000..fc4ac2cfe --- /dev/null +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/Azure.Mcp.Tools.AzureManagedLustre.UnitTests/FileSystem/FileSystemUpdateCommandTests.cs @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.AzureManagedLustre.Commands; +using Azure.Mcp.Tools.AzureManagedLustre.Commands.FileSystem; +using Azure.Mcp.Tools.AzureManagedLustre.Models; +using Azure.Mcp.Tools.AzureManagedLustre.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.AzureManagedLustre.UnitTests.FileSystem; + +public class FileSystemUpdateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IAzureManagedLustreService _svc; + private readonly ILogger _logger; + private readonly FileSystemUpdateCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + private const string Sub = "sub123"; + private const string Rg = "rg1"; + private const string Name = "amlfs-01"; + + public FileSystemUpdateCommandTests() + { + _svc = Substitute.For(); + _logger = Substitute.For>(); + var services = new ServiceCollection().AddSingleton(_svc); + _serviceProvider = services.BuildServiceProvider(); + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = _command.GetCommand(); + Assert.Equal("update", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + } + + [Theory] + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01", false)] // Missing update params + [InlineData("--resource-group rg1 --name amlfs-01 --maintenance-day Monday", false)] // Missing subscription + [InlineData("--subscription sub123 --name amlfs-01 --maintenance-day Monday", false)] // Missing resource group + [InlineData("--subscription sub123 --resource-group rg1 --maintenance-day Monday", false)] // Missing name + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --maintenance-day Monday", false)] + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --maintenance-time 00:00", false)] + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --no-squash-nid-list nid1,nid2 --squash-uid 1000", false)] // missing gid + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + // Arrange + if (shouldSucceed) + { + _svc.UpdateFileSystemAsync( + Arg.Is(Sub), Arg.Is(Rg), Arg.Is(Name), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(CreateLustre()); + } + + var parseResult = _commandDefinition.Parse(args.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + if (!shouldSucceed) + { + Assert.False(string.IsNullOrWhiteSpace(response.Message)); + Assert.True( + response.Message.Contains("required", StringComparison.OrdinalIgnoreCase) + || response.Message.Contains("provide", StringComparison.OrdinalIgnoreCase) + || response.Message.Contains("must be", StringComparison.OrdinalIgnoreCase) + ); + } + else + { + Assert.NotNull(response.Results); + } + } + + [Fact] + public async Task ExecuteAsync_MaintenanceUpdate_CallsServiceAndReturnsResult() + { + var expected = CreateLustre(); + _svc.UpdateFileSystemAsync(Sub, Rg, Name, "Monday", "01:00", null, null, null, null, null, Arg.Any()) + .Returns(expected); + + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--maintenance-day", "Monday", + "--maintenance-time", "01:00" + ]); + + var response = await _command.ExecuteAsync(_context, args); + Assert.Equal(HttpStatusCode.OK, response.Status); + await _svc.Received(1).UpdateFileSystemAsync(Sub, Rg, Name, "Monday", "01:00", null, null, null, null, null, Arg.Any()); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, AzureManagedLustreJsonContext.Default.FileSystemUpdateResult); + Assert.NotNull(result); + Assert.Equal(Name, result!.FileSystem.Name); + } + + [Fact] + public async Task ExecuteAsync_RootSquashUpdate_CallsService() + { + var expected = CreateLustre(); + _svc.UpdateFileSystemAsync(Sub, Rg, Name, null, null, "All", "nid1,nid2", 1000, 1000, null, Arg.Any()) + .Returns(expected); + + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--root-squash-mode", "All", + "--no-squash-nid-list", "nid1,nid2", + "--squash-uid", "1000", + "--squash-gid", "1000" + ]); + + var response = await _command.ExecuteAsync(_context, args); + Assert.Equal(HttpStatusCode.OK, response.Status); + await _svc.Received(1).UpdateFileSystemAsync(Sub, Rg, Name, null, null, "All", "nid1,nid2", 1000, 1000, null, Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_ServiceThrowsRequestFailed_ReturnsStatus() + { + _svc.UpdateFileSystemAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new Azure.RequestFailedException(404, "not found")); + + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--maintenance-day", "Monday", + "--maintenance-time", "00:00" + ]); + + var response = await _command.ExecuteAsync(_context, args); + Assert.Equal(HttpStatusCode.NotFound, response.Status); + Assert.Contains("not found", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --root-squash-mode All --squash-uid 1000", false)] // missing gid + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --root-squash-mode All --squash-gid 1000", false)] // missing uid + [InlineData("--subscription sub123 --resource-group rg1 --name amlfs-01 --root-squash-mode None", true)] // None doesn't require uid/gid + public async Task ExecuteAsync_RootSquashMode_Validation_Works(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + _svc.UpdateFileSystemAsync( + Arg.Is(Sub), Arg.Is(Rg), Arg.Is(Name), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(CreateLustre()); + } + + var parseResult = _commandDefinition.Parse(args.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + var response = await _command.ExecuteAsync(_context, parseResult); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + if (!shouldSucceed) + { + Assert.Contains("squash", response.Message, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task ExecuteAsync_RootSquashMode_WithUidGid_SucceedsAndCallsService() + { + var expected = CreateLustre(); + _svc.UpdateFileSystemAsync(Sub, Rg, Name, null, null, "All", "nid1,nid2", 2000, 3000, null, Arg.Any()) + .Returns(expected); + + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--root-squash-mode", "All", + "--no-squash-nid-list", "nid1,nid2", + "--squash-uid", "2000", + "--squash-gid", "3000" + ]); + + var response = await _command.ExecuteAsync(_context, args); + Assert.Equal(HttpStatusCode.OK, response.Status); + await _svc.Received(1).UpdateFileSystemAsync(Sub, Rg, Name, null, null, "All", "nid1,nid2", 2000, 3000, null, Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_RootSquashNotNone_MissingNoSquashNidList_Returns400() + { + var args = _commandDefinition.Parse([ + "--subscription", Sub, + "--resource-group", Rg, + "--name", Name, + "--root-squash-mode", "All", + "--squash-uid", "1000", + "--squash-gid", "1000" + ]); + + var response = await _command.ExecuteAsync(_context, args); + + Assert.True(response.Status >= HttpStatusCode.BadRequest); + Assert.Contains("no-squash", response.Message, StringComparison.OrdinalIgnoreCase); + } + + private static LustreFileSystem CreateLustre() => new( + Name, + $"/subs/{Sub}/rg/{Rg}/providers/Microsoft.StorageCache/amlfs/{Name}", + Rg, + Sub, + "eastus", + "Succeeded", + "Healthy", + "10.0.0.4", + "AMLFS-Durable-Premium-125", + 4, + null, + "Monday", + "00:00"); + +} diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/test-resources-post.ps1 b/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/test-resources-post.ps1 index 148ba56a9..7f4f3651b 100644 --- a/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/test-resources-post.ps1 +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/test-resources-post.ps1 @@ -32,3 +32,60 @@ if ($amlfsCluster) { } else { Write-Error "AMLFS Cluster '$amlfsName' not found" } + +# Retrieve principal ID for "HPC Cache Resource Provider" and assign roles on the storage account +# This is not easy to do in Bicep and at the resource group scope +Write-Host "Resolving 'HPC Cache Resource Provider' service principal using the network cards of AMLFS instance..." -ForegroundColor Yellow + +# Ensure required modules are available +if (-not (Get-Command -Name Get-AzNetworkInterface -ErrorAction SilentlyContinue)) { + Install-Module -Name Az.Network -Repository PSGallery -Scope CurrentUser -Force +} +if (-not (Get-Command -Name Get-AzActivityLog -ErrorAction SilentlyContinue)) { + Install-Module -Name Az.Monitor -Repository PSGallery -Scope CurrentUser -Force +} + +# Find the first NIC starting with 'amlfs' +$nic = Get-AzNetworkInterface -ResourceGroupName $ResourceGroupName -ErrorAction Stop | + Where-Object { $_.Name -like 'amlfs*' } | + Sort-Object Name | + Select-Object -First 1 + +if (-not $nic) { + Write-Error "No network interfaces starting with 'amlfs' found in resource group '$ResourceGroupName'." +} + +Write-Host "Selected NIC: $($nic.Name)" -ForegroundColor Yellow + +# Get the first (earliest) activity log entry for the NIC and extract the caller +$startTime = (Get-Date).AddDays(-7) +$events = Get-AzActivityLog -ResourceId $nic.Id -StartTime $startTime -ErrorAction Stop + +if (-not $events) { + Write-Error "No activity log events found for '$($nic.Name)' since $startTime." -ForegroundColor Yellow +} else { + $firstEvent = $events | Sort-Object EventTimestamp | Select-Object -First 1 + $opName = $firstEvent.OperationName.LocalizedValue + if (-not $opName) { $opName = $firstEvent.OperationName.Value } + + Write-Host "First operation on resource: $opName" -ForegroundColor Gray + Write-Host "Caller: $($firstEvent.Caller)" -ForegroundColor Green +} + + +$storageAccountName = $testSettings.ResourceBaseName + +$sa = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -Name $storageAccountName -ErrorAction Stop +$scope = $sa.Id + +$rolesToAssign = @( + "Storage Account Contributor", + "Storage Blob Data Contributor" +) + +$HPCCacheResourceProviderPrincipalId = $firstEvent.Caller + +foreach ($role in $rolesToAssign) { + Write-Host "Assigning role '$role' to principal 'HPC Cache Resource Provider'on scope '$scope'..." -ForegroundColor Yellow + New-AzRoleAssignment -Scope $scope -RoleDefinitionName $role -PrincipalId $HPCCacheResourceProviderPrincipalId | Out-Null +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/test-resources.bicep index 7b70ed507..bcf3c3155 100644 --- a/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/test-resources.bicep +++ b/tools/Azure.Mcp.Tools.AzureManagedLustre/tests/test-resources.bicep @@ -34,6 +34,11 @@ param amlfsSku string = 'AMLFS-Durable-Premium-500' @minValue(4) param amlfsCapacityTiB int = 4 +var kvCryptoUserRoleDefinitionId = '14b46e9e-c2b7-41b4-b07b-48a6ebf60603' + +var userAssignedName = '${baseName}-uai' + + resource vnet 'Microsoft.Network/virtualNetworks@2023-05-01' = { name: '${baseName}-vnet' location: location @@ -114,6 +119,93 @@ resource amlfs 'Microsoft.StorageCache/amlFilesystems@2024-07-01' = { } } +resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = { + name: baseName + location: location + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + allowBlobPublicAccess: false + minimumTlsVersion: 'TLS1_2' + supportsHttpsTrafficOnly: true + } +} + +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2025-01-01' existing = { + parent: storageAccount + name: 'default' +} + +resource hsmContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-01-01' = { + parent : blobService + name: 'hsm-data' + properties: { + publicAccess: 'None' + } + dependsOn: [ blobService ] +} + +resource hsmLogsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-01-01' = { + parent : blobService + name: 'hsm-logs' + properties: { + publicAccess: 'None' + } + dependsOn: [ blobService ] +} + + +resource userAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: userAssignedName + location: location +} + + +resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' = { + name: baseName + location: location + properties: { + tenantId: tenant().tenantId + sku: { + family: 'A' + name: 'standard' + } + enableRbacAuthorization: true + enablePurgeProtection: true + softDeleteRetentionInDays: 90 + publicNetworkAccess: 'Enabled' + } +} + +resource keyVaultCryptoUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, 'kv-crypto-user', userAssignedIdentity.id) + scope: keyVault + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', kvCryptoUserRoleDefinitionId) + principalId: userAssignedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource keyVaultKey 'Microsoft.KeyVault/vaults/keys@2024-11-01' = { + parent: keyVault + name: 'encryption-key' + properties: { + kty: 'RSA' + keySize: 2048 + } +} + +// Outputs for tests +output STORAGE_ACCOUNT_ID string = storageAccount.id +output HSM_CONTAINER_ID string = hsmContainer.id +output HSM_LOGS_CONTAINER_ID string = hsmLogsContainer.id +output KEY_VAULT_RESOURCE_ID string = keyVault.id +output KEY_VAULT_NAME string = keyVault.name +output USER_ASSIGNED_IDENTITY_RESOURCE_ID string = userAssignedIdentity.id +output KEY_URI_WITH_VERSION string = keyVaultKey.properties.keyUriWithVersion output AMLFS_ID string = amlfs.id output AMLFS_SUBNET_ID string = filesystemSubnetId output AMLFS_SUBNET_SMALL_ID string = filesystemSmallSubnetId