diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 9a2120e9fc..3b0fcf767d 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -1078,6 +1078,9 @@ Example prompts that generate Azure CLI commands: * "Stop VM 'my-vm' in resource group 'my-rg'" * "Deallocate VM 'my-vm' in resource group 'my-rg' to stop billing" * "Restart VM 'my-vm' in resource group 'my-rg'" +* "Create an Azure Compute Gallery named 'myGallery' in resource group 'my-resource-group'" +* "Create a compute gallery 'myGallery' in resource group 'my-rg' in eastus with a description" +* "I want to publish a VM application, create a gallery 'myGallery' in resource group 'my-rg' first" * "Delete virtual machine scale set 'my-vmss' in resource group 'my-resource-group'" * "Force delete VMSS 'my-vmss' in resource group 'my-rg' using force-deletion" @@ -1430,7 +1433,7 @@ The Azure MCP Server provides tools for interacting with **45+ Azure service are - 🛡️ **Azure Best Practices** - Secure, production-grade guidance - 🖥️ **Azure CLI Generate** - Generate Azure CLI commands from natural language - 📞 **Azure Communication Services** - SMS messaging and communication -- � **Azure Compute** - Virtual Machine, Virtual Machine Scale Set, and Disk management +- � **Azure Compute** - Virtual Machine, Virtual Machine Scale Set, Disk, and Compute Gallery management - �🔐 **Azure Confidential Ledger** - Tamper-proof ledger operations - 📦 **Azure Container Apps** - Container hosting - 📦 **Azure Container Registry (ACR)** - Container registry management diff --git a/servers/Azure.Mcp.Server/changelog-entries/compute-gallery-create.yaml b/servers/Azure.Mcp.Server/changelog-entries/compute-gallery-create.yaml new file mode 100644 index 0000000000..9c8c20fe0f --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/compute-gallery-create.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Added `azmcp compute gallery create` to create an Azure Compute Gallery for storing VM images and VM applications." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 7ab362f360..be0ffab0d9 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -2434,6 +2434,53 @@ azmcp compute disk update --subscription \ | `--disk-access` | No | Resource ID of the disk access resource for private endpoint connections | | `--tier` | No | Performance tier for the disk (e.g., P30, P40, P50) | +#### Azure Compute Gallery + +```bash +# Create an Azure Compute Gallery +# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp compute gallery create --subscription \ + --resource-group \ + --gallery + +# Create an Azure Compute Gallery in a specific region with a description +# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp compute gallery create --subscription \ + --resource-group \ + --gallery \ + --location \ + --description + +# Create an Azure Compute Gallery with tags +# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp compute gallery create --subscription \ + --resource-group \ + --gallery \ + --tags +``` + +**Command Behavior:** +- Creates an Azure Compute Gallery (formerly Shared Image Gallery), the top-level container used to store and share VM image definitions and VM application definitions. +- Uses create-or-update (PUT) semantics. If a gallery with the same name already exists in the resource group, parameters that are not supplied are left unchanged. +- Supplying `--tags` replaces the gallery's entire tag set rather than merging, so any existing tag that is omitted is removed. Include every tag that should be kept. +- If `--location` is not specified, a new gallery is created in the resource group's location, and an existing gallery keeps the location it already has. A gallery's location cannot be changed after creation, so supplying a `--location` that differs from an existing gallery's region fails. +- Gallery names must be unique within the subscription, not just within the resource group. +- Gallery names must be 1-80 characters, contain only letters, digits, periods, and underscores, and start and end with a letter or digit. Unlike most Compute resources, hyphens are not allowed in gallery names. +- Create the gallery before publishing VM images or VM applications into it. + +**Returns:** +- Created gallery information including name, resource ID, resource group, location, description, globally unique name, sharing permissions, tags, and provisioning state. + +**Parameters:** +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--subscription` | Yes | Azure subscription ID or name | +| `--resource-group`, `-g` | Yes | Resource group name | +| `--gallery`, `--name` | Yes | Name of the Azure Compute Gallery to create | +| `--location` | No | Azure region for the gallery (defaults to the resource group's location for a new gallery, or the existing gallery's location when updating) | +| `--description` | No | Description of the gallery | +| `--tags` | No | Comma-separated tags in key=value format (e.g., env=prod,team=compute) | + ### Azure Confidential Ledger Operations ```bash diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 09f651077a..bb069b5c2e 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -518,6 +518,11 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | compute_disk_update | Change the encryption type of disk in resource group to EncryptionAtRestWithPlatformAndCustomerKeys | investigation-required | | compute_disk_update | Set disk access on disk in resource group to with network access policy AllowPrivate | none | | compute_disk_update | Update disk to Standard_LRS SKU with 512 GB size and tags env=dev | none | +| compute_gallery_create | Create an Azure Compute Gallery named in resource group | none | +| compute_gallery_create | Create a compute gallery in resource group in eastus | none | +| compute_gallery_create | Create a gallery named in resource group with description "Shared images for the platform team" | none | +| compute_gallery_create | I want to publish a VM application, create a gallery in resource group first | none | +| compute_gallery_create | Create a compute gallery in resource group with tags env=prod,team=compute | none | ## Azure Confidential Ledger diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index f1e2e2374f..6509dc1201 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -2559,6 +2559,39 @@ "compute_disk_create" ] }, + { + "name": "create_azure_compute_gallery", + "description": "Create an Azure Compute Gallery (formerly Shared Image Gallery), the top-level container used to store and share VM image definitions and VM application definitions across subscriptions, regions, and tenants. Supports setting the region, description, and tags. Create a gallery before publishing VM images or VM applications.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool creates new resources which may incur costs." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times may create duplicate resources." + }, + "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 creates new Azure resources." + }, + "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": [ + "compute_gallery_create" + ] + }, { "name": "update_azure_compute_vm_and_vmss", "description": "Update Azure Virtual Machines (VMs) and Virtual Machine Scale Sets (VMSS). Modify VM properties like size, tags, license type, boot diagnostics, and user data. Update VMSS capacity, upgrade policy, overprovision settings, auto OS upgrade, and scale-in policies. Change VM power state (start, stop, deallocate, restart). For VM and VMSS update operations, PATCH semantics apply - only specified properties are updated; unspecified properties remain unchanged. Power-state actions are operation-based changes, not PATCH updates.", diff --git a/tools/Azure.Mcp.Tools.Compute/cspell.yaml b/tools/Azure.Mcp.Tools.Compute/cspell.yaml index c7edb84e3c..25457351b4 100644 --- a/tools/Azure.Mcp.Tools.Compute/cspell.yaml +++ b/tools/Azure.Mcp.Tools.Compute/cspell.yaml @@ -16,8 +16,11 @@ words: - delvm - delvms - eaaaa + - envprod - exactdisk - galleryimageversionid + - leadingperiod + - leadingunderscore - lnxvmss - maaacbaj - mysnapshot @@ -31,10 +34,15 @@ words: - otherdisk - securedisk - swingbench + - teamcompute - testdatadisk - testdisk + - testgallery - testkey - testvm + - trailinghyphen + - trailingperiod + - trailingunderscore - uploaddisk - uploadnosize - uploadsec diff --git a/tools/Azure.Mcp.Tools.Compute/src/Commands/ComputeJsonContext.cs b/tools/Azure.Mcp.Tools.Compute/src/Commands/ComputeJsonContext.cs index baf7aea283..af91251ebd 100644 --- a/tools/Azure.Mcp.Tools.Compute/src/Commands/ComputeJsonContext.cs +++ b/tools/Azure.Mcp.Tools.Compute/src/Commands/ComputeJsonContext.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using Azure.Mcp.Tools.Compute.Commands.Disk; +using Azure.Mcp.Tools.Compute.Commands.Gallery; using Azure.Mcp.Tools.Compute.Commands.Vm; using Azure.Mcp.Tools.Compute.Commands.Vmss; using Azure.Mcp.Tools.Compute.Models; @@ -18,6 +19,8 @@ namespace Azure.Mcp.Tools.Compute.Commands; [JsonSerializable(typeof(DiskUpdateCommand.DiskUpdateCommandResult))] [JsonSerializable(typeof(DiskInfo))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(GalleryCreateCommand.GalleryCreateCommandResult))] +[JsonSerializable(typeof(GalleryInfo))] [JsonSerializable(typeof(VmCreateCommand.VmCreateCommandResult))] [JsonSerializable(typeof(VmCreateResult))] [JsonSerializable(typeof(VmUpdateCommand.VmUpdateCommandResult))] diff --git a/tools/Azure.Mcp.Tools.Compute/src/Commands/Gallery/GalleryCreateCommand.cs b/tools/Azure.Mcp.Tools.Compute/src/Commands/Gallery/GalleryCreateCommand.cs new file mode 100644 index 0000000000..327f585fe2 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Commands/Gallery/GalleryCreateCommand.cs @@ -0,0 +1,185 @@ +// 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.Compute.Models; +using Azure.Mcp.Tools.Compute.Options.Gallery; +using Azure.Mcp.Tools.Compute.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.Compute.Commands.Gallery; + +/// +/// Command to create an Azure Compute Gallery. +/// +[CommandMetadata( + Id = "7c4e1f2a-9b3d-4e5a-8c61-2d0f3a5b7e91", + Name = "create", + Title = "Create Azure Compute Gallery", + Description = """ + Creates an Azure Compute Gallery (formerly Shared Image Gallery) in the specified resource group. + A gallery is the top-level container used to store and share VM image definitions and VM application + definitions across subscriptions, regions, and tenants. Create a gallery before publishing VM images + or VM applications. If --location is not specified, a new gallery is created in the resource group's + location and an existing gallery keeps its current location. This uses create-or-update semantics: if a + gallery with the same name already exists, values that are not supplied are left unchanged, but + supplying --tags replaces the gallery's entire tag set rather than merging, so include every tag that + should be kept. Gallery names must be unique within the subscription. Returns the + created gallery including its name, resource ID, location, description, globally unique name, + sharing permissions, tags, and provisioning state. + """, + OperationPlane = ToolOperationPlane.Control, + Destructive = true, + Idempotent = false, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class GalleryCreateCommand(ILogger logger, IComputeService computeService, ISubscriptionResolver subscriptionResolver) + : SubscriptionCommand(subscriptionResolver) +{ + private const int MaxGalleryNameLength = 80; + + private readonly ILogger _logger = logger; + private readonly IComputeService _computeService = computeService; + + public override void ValidateOptions(GalleryCreateOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + if (!IsValidGalleryName(options.Gallery)) + { + validationResult.Errors.Add( + $"Invalid gallery name '{options.Gallery}'. Gallery names must be 1-{MaxGalleryNameLength} characters, " + + "contain only letters, digits, periods, and underscores, and start and end with a letter or digit. " + + "Hyphens are not allowed in gallery names."); + } + } + + // Unlike most Microsoft.Compute resources, galleries disallow hyphens and must end with an alphanumeric. + internal static bool IsValidGalleryName(string? gallery) + { + if (string.IsNullOrEmpty(gallery) || gallery.Length > MaxGalleryNameLength) + { + return false; + } + + if (!char.IsAsciiLetterOrDigit(gallery[0]) || !char.IsAsciiLetterOrDigit(gallery[^1])) + { + return false; + } + + foreach (var c in gallery) + { + if (!char.IsAsciiLetterOrDigit(c) && c is not '.' and not '_') + { + return false; + } + } + + return true; + } + + internal static Dictionary? ParseTags(string? tags) + { + if (string.IsNullOrWhiteSpace(tags)) + { + return null; + } + + var parsed = new Dictionary(); + // Empty segments are kept so that leading, trailing, or consecutive commas fail validation + // below rather than being silently discarded. + foreach (var pair in tags.Split(',', StringSplitOptions.TrimEntries)) + { + var parts = pair.Split('=', 2); + if (parts.Length != 2) + { + throw new ArgumentException($"Invalid tag '{pair}'. Tags must use the format 'key=value'."); + } + + var key = parts[0].Trim(); + var value = parts[1].Trim(); + + if (key.Length == 0) + { + throw new ArgumentException($"Invalid tag '{pair}'. Tag keys cannot be empty."); + } + + if (value.Length == 0) + { + throw new ArgumentException($"Invalid tag '{pair}'. Tag values cannot be empty."); + } + + parsed[key] = value; + } + + return parsed.Count > 0 ? parsed : null; + } + + public override async Task ExecuteAsync(CommandContext context, GalleryCreateOptions options, CancellationToken cancellationToken) + { + try + { + context.Activity?.AddTag("subscription", options.Subscription); + + // Parse tags before any Azure call so malformed input fails fast without creating a resource. + var tags = ParseTags(options.Tags); + + var gallery = await _computeService.CreateGalleryAsync( + options.Gallery, + options.ResourceGroup, + options.Subscription!, + options.Location, + options.Description, + tags, + options.Tenant, + cancellationToken); + + context.Response.Results = ResponseResult.Create(new(gallery), ComputeJsonContext.Default.GalleryCreateCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error creating gallery. Gallery: {Gallery}, ResourceGroup: {ResourceGroup}, Location: {Location}.", + options.Gallery, options.ResourceGroup, options.Location ?? "(default)"); + HandleException(context, ex); + } + + return context.Response; + } + + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + RequestFailedException reqEx => (HttpStatusCode)reqEx.Status, + Identity.AuthenticationFailedException => HttpStatusCode.Unauthorized, + ArgumentException => HttpStatusCode.BadRequest, + _ => base.GetStatusCode(ex) + }; + + protected override string GetErrorMessage(Exception ex) => ex switch + { + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "Resource group not found. Verify the resource group exists and you have access.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + $"Authorization failed creating the gallery. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.ErrorCode == "InvalidResourceLocation" => + $"A gallery with this name already exists in a different region. A gallery's location cannot be changed, so omit --location to update the existing gallery in place, or choose a different gallery name to create one in the requested region. Details: {reqEx.Message}", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => + $"Conflict creating the gallery. Another operation may be in progress on this gallery. Details: {reqEx.Message}", + RequestFailedException reqEx => reqEx.Message, + Identity.AuthenticationFailedException => + "Authentication failed. Please run 'az login' to sign in.", + ArgumentException argEx => $"Invalid parameter: {argEx.Message}", + _ => base.GetErrorMessage(ex) + }; + + /// + /// Result record for the gallery create command. + /// + public record GalleryCreateCommandResult(GalleryInfo Gallery); +} diff --git a/tools/Azure.Mcp.Tools.Compute/src/ComputeSetup.cs b/tools/Azure.Mcp.Tools.Compute/src/ComputeSetup.cs index 0d26c5fcc7..fd03b099fc 100644 --- a/tools/Azure.Mcp.Tools.Compute/src/ComputeSetup.cs +++ b/tools/Azure.Mcp.Tools.Compute/src/ComputeSetup.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Azure.Mcp.Tools.Compute.Commands.Disk; +using Azure.Mcp.Tools.Compute.Commands.Gallery; using Azure.Mcp.Tools.Compute.Commands.Vm; using Azure.Mcp.Tools.Compute.Commands.Vmss; using Azure.Mcp.Tools.Compute.Services; @@ -42,6 +43,9 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + + // Gallery commands + services.AddSingleton(); } public CommandGroup RegisterCommands(IServiceProvider serviceProvider) @@ -92,6 +96,15 @@ Note that this tool requires appropriate Azure RBAC permissions and will only ac disk.AddCommand(serviceProvider); disk.AddCommand(serviceProvider); + // Create Gallery subgroup + var gallery = new CommandGroup( + "gallery", + "Azure Compute Gallery operations - Commands for managing Azure Compute Galleries, the containers used to store and share VM images and VM applications."); + compute.AddSubGroup(gallery); + + // Register Gallery commands + gallery.AddCommand(serviceProvider); + return compute; } } diff --git a/tools/Azure.Mcp.Tools.Compute/src/Models/GalleryInfo.cs b/tools/Azure.Mcp.Tools.Compute/src/Models/GalleryInfo.cs new file mode 100644 index 0000000000..aa4616e5b0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Models/GalleryInfo.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Compute.Models; + +/// +/// Represents an Azure Compute Gallery. +/// +public class GalleryInfo +{ + /// + /// Gets or sets the name of the gallery. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the resource ID of the gallery. + /// + public string? Id { get; set; } + + /// + /// Gets or sets the resource group containing the gallery. + /// + public string? ResourceGroup { get; set; } + + /// + /// Gets or sets the location of the gallery. + /// + public string? Location { get; set; } + + /// + /// Gets or sets the description of the gallery. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the globally unique name assigned to the gallery by Azure. + /// + public string? UniqueName { get; set; } + + /// + /// Gets or sets the provisioning state of the gallery. + /// + public string? ProvisioningState { get; set; } + + /// + /// Gets or sets the sharing permissions of the gallery (e.g., Private, Groups, Community). + /// + public string? SharingPermissions { get; set; } + + /// + /// Gets or sets the tags applied to the gallery. + /// + public Dictionary? Tags { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Compute/src/Options/ComputeOptionDescriptions.cs b/tools/Azure.Mcp.Tools.Compute/src/Options/ComputeOptionDescriptions.cs index e8c1d4dea4..18d123eebc 100644 --- a/tools/Azure.Mcp.Tools.Compute/src/Options/ComputeOptionDescriptions.cs +++ b/tools/Azure.Mcp.Tools.Compute/src/Options/ComputeOptionDescriptions.cs @@ -23,6 +23,8 @@ internal static class ComputeOptionDescriptions internal const string Tier = "Performance tier of the disk (e.g., P10, P15, P20, P30, P40, P50, P60, P70, P80). Applicable to Premium SSD disks only."; internal const string VmName = "The name of the virtual machine."; internal const string VmssName = "The name of the virtual machine scale set."; + internal const string GalleryName = "The name of the Azure Compute Gallery. Valid characters are letters, digits, periods, and underscores, up to 80 characters. Must start and end with a letter or digit. Hyphens are not allowed."; + internal const string GalleryDescription = "A description of the gallery to help users understand what it contains."; internal const string Location = "The Azure region/location. Defaults to the resource group's location if not specified."; internal const string VmSize = "The VM size (e.g., Standard_D2s_v3, Standard_B2s). Defaults to Standard_D2s_v5 if not specified."; internal const string Image = "The OS image to use. Can be a URN (publisher:offer:sku:version), a shared gallery image ID (starting with '/sharedGalleries/'), or an alias such as 'Ubuntu2404' or 'Win2022Datacenter'."; diff --git a/tools/Azure.Mcp.Tools.Compute/src/Options/Gallery/GalleryCreateOptions.cs b/tools/Azure.Mcp.Tools.Compute/src/Options/Gallery/GalleryCreateOptions.cs new file mode 100644 index 0000000000..9f2ce92820 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/src/Options/Gallery/GalleryCreateOptions.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.Compute.Options.Gallery; + +/// +/// Options for the GalleryCreate command. +/// +public sealed class GalleryCreateOptions : ISubscriptionOption +{ + [Option(Description = ComputeOptionDescriptions.GalleryName, Aliases = ["name"])] + public required string Gallery { get; set; } + + [Option(Description = ComputeOptionDescriptions.Location, Aliases = ["l"])] + public string? Location { get; set; } + + [Option(Description = ComputeOptionDescriptions.GalleryDescription)] + public string? Description { get; set; } + + [Option(Description = ComputeOptionDescriptions.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.Compute/src/Services/ComputeService.cs b/tools/Azure.Mcp.Tools.Compute/src/Services/ComputeService.cs index d4cdccc2e6..a9f723ebc2 100644 --- a/tools/Azure.Mcp.Tools.Compute/src/Services/ComputeService.cs +++ b/tools/Azure.Mcp.Tools.Compute/src/Services/ComputeService.cs @@ -1849,6 +1849,73 @@ public async Task DeleteDiskAsync( } } + public async Task CreateGalleryAsync( + string gallery, + string resourceGroup, + string subscription, + string? location = null, + string? description = null, + IReadOnlyDictionary? tags = null, + string? tenant = null, + CancellationToken cancellationToken = default) + { + var armClient = await CreateArmClientAsync(tenant, cancellationToken: cancellationToken); + var subscriptionResource = armClient.GetSubscriptionResource( + SubscriptionResource.CreateResourceIdentifier(subscription)); + var rgResource = await subscriptionResource.GetResourceGroups().GetAsync(resourceGroup, cancellationToken); + var galleries = rgResource.Value.GetGalleries(); + + var resolvedLocation = location; + if (resolvedLocation is null) + { + // A gallery's location is immutable, so an update has to resend the existing location. + // Defaulting to the resource group's location would fail with InvalidResourceLocation + // whenever the gallery lives in a different region from its resource group. + var existing = await galleries.GetIfExistsAsync(gallery, cancellationToken: cancellationToken); + resolvedLocation = existing.HasValue && existing.Value is not null + ? existing.Value.Data.Location.Name + : rgResource.Value.Data.Location.Name; + } + + var galleryData = new GalleryData(new AzureLocation(resolvedLocation)) + { + Description = description + }; + + if (tags is not null) + { + foreach (var tag in tags) + { + galleryData.Tags[tag.Key] = tag.Value; + } + } + + _logger.LogInformation("Creating gallery {Gallery} in resource group {ResourceGroup}", gallery, resourceGroup); + + var createOperation = await galleries + .CreateOrUpdateAsync(WaitUntil.Started, gallery, galleryData, cancellationToken); + await WaitForLroCompletionAsync(createOperation, cancellationToken); + + return ConvertToGalleryModel(createOperation.Value, resourceGroup); + } + + private static GalleryInfo ConvertToGalleryModel(GalleryResource galleryResource, string resourceGroup) + { + var data = galleryResource.Data; + return new() + { + Name = data.Name, + Id = data.Id?.ToString(), + ResourceGroup = resourceGroup, + Location = data.Location.ToString(), + Description = data.Description, + UniqueName = data.IdentifierUniqueName, + ProvisioningState = data.ProvisioningState?.ToString(), + SharingPermissions = data.SharingProfile?.Permission?.ToString(), + Tags = data.Tags?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + }; + } + internal static readonly string[] s_validSshKeyPrefixes = [ "ssh-rsa ", diff --git a/tools/Azure.Mcp.Tools.Compute/src/Services/IComputeService.cs b/tools/Azure.Mcp.Tools.Compute/src/Services/IComputeService.cs index d43e7cb160..fbf905689f 100644 --- a/tools/Azure.Mcp.Tools.Compute/src/Services/IComputeService.cs +++ b/tools/Azure.Mcp.Tools.Compute/src/Services/IComputeService.cs @@ -230,4 +230,15 @@ Task DeleteDiskAsync( string subscription, string? tenant = null, CancellationToken cancellationToken = default); + + // Compute Gallery operations + Task CreateGalleryAsync( + string gallery, + string resourceGroup, + string subscription, + string? location = null, + string? description = null, + IReadOnlyDictionary? tags = null, + string? tenant = null, + CancellationToken cancellationToken = default); } diff --git a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.Tests/ComputeCommandTests.cs b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.Tests/ComputeCommandTests.cs index ef9d58ab42..c42db625d2 100644 --- a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.Tests/ComputeCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.Tests/ComputeCommandTests.cs @@ -1505,4 +1505,41 @@ await CallToolAsync( } #endregion + + #region Gallery + + [Fact] + public async Task GalleryCreate_CreatesGallery_Successfully() + { + // Gallery names disallow hyphens, so ResourceBaseName cannot be reused here. + var galleryName = RegisterOrRetrieveVariable("createdGalleryName", $"testgallery{Random.Shared.NextInt64()}"); + var location = RegisterOrRetrieveDeploymentOutputVariable("galleryLocation", "LOCATION"); + + JsonElement? result = await CallToolAsync( + "compute_gallery_create", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "gallery", galleryName }, + { "location", location }, + { "description", "Gallery created by Azure MCP live tests" }, + { "tags", "env=test,owner=azmcp" } + }); + + Assert.NotNull(result); + JsonElement gallery = result.Value.AssertProperty("Gallery"); + Assert.Equal(JsonValueKind.Object, gallery.ValueKind); + + Assert.Equal("Succeeded", gallery.AssertProperty("ProvisioningState").GetString()); + Assert.Equal(location, gallery.AssertProperty("Location").GetString()); + Assert.Equal("Gallery created by Azure MCP live tests", gallery.AssertProperty("Description").GetString()); + Assert.NotNull(gallery.AssertProperty("Name").GetString()); + + JsonElement tags = gallery.AssertProperty("Tags"); + Assert.Equal("test", tags.AssertProperty("env").GetString()); + Assert.Equal("azmcp", tags.AssertProperty("owner").GetString()); + } + + #endregion } diff --git a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.Tests/Gallery/GalleryCreateCommandTests.cs b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.Tests/Gallery/GalleryCreateCommandTests.cs new file mode 100644 index 0000000000..25047487a6 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.Tests/Gallery/GalleryCreateCommandTests.cs @@ -0,0 +1,594 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tests.Commands; +using Azure.Mcp.Tools.Compute.Commands; +using Azure.Mcp.Tools.Compute.Commands.Gallery; +using Azure.Mcp.Tools.Compute.Models; +using Azure.Mcp.Tools.Compute.Services; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.Compute.Tests.Gallery; + +/// +/// Unit tests for the GalleryCreateCommand. +/// +public class GalleryCreateCommandTests : SubscriptionCommandUnitTestsBase +{ + private const string Subscription = "test-sub"; + private const string ResourceGroup = "testrg"; + private const string GalleryName = "myGallery"; + + private static GalleryInfo CreateMockGallery( + string name = GalleryName, + string location = "eastus", + string? description = null, + Dictionary? tags = null) => new() + { + Name = name, + Id = $"/subscriptions/{Subscription}/resourceGroups/{ResourceGroup}/providers/Microsoft.Compute/galleries/{name}", + ResourceGroup = ResourceGroup, + Location = location, + Description = description, + UniqueName = $"{Subscription}-{name}", + ProvisioningState = "Succeeded", + SharingPermissions = "Private", + Tags = tags + }; + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.NotNull(Command); + Assert.Equal("create", Command.Name); + Assert.NotEqual(Guid.Empty.ToString(), Command.Id); + Assert.NotNull(Command.Description); + Assert.NotEmpty(Command.Description); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.True(metadata.Destructive); + Assert.False(metadata.Idempotent); + Assert.False(metadata.OpenWorld); + Assert.False(metadata.ReadOnly); + Assert.False(metadata.Secret); + Assert.False(metadata.LocalRequired); + } + + [Fact] + public async Task ExecuteAsync_CreateGallery_ReturnsSuccess() + { + // Arrange + Service.CreateGalleryAsync( + GalleryName, + ResourceGroup, + Subscription, + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .Returns(CreateMockGallery()); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", GalleryName); + + // Assert + var result = ValidateAndDeserializeResponse(response, ComputeJsonContext.Default.GalleryCreateCommandResult); + + Assert.NotNull(result.Gallery); + Assert.Equal(GalleryName, result.Gallery.Name); + Assert.Equal(ResourceGroup, result.Gallery.ResourceGroup); + Assert.Equal("eastus", result.Gallery.Location); + Assert.Equal("Succeeded", result.Gallery.ProvisioningState); + } + + [Fact] + public async Task ExecuteAsync_WithAllOptions_PassesValuesToService() + { + // Arrange + const string location = "westus2"; + const string description = "Gallery for VM applications"; + + Service.CreateGalleryAsync( + GalleryName, + ResourceGroup, + Subscription, + location, + description, + Arg.Any?>(), + "test-tenant", + Arg.Any()) + .Returns(CreateMockGallery(location: location, description: description)); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", GalleryName, + "--location", location, + "--description", description, + "--tenant", "test-tenant"); + + // Assert + var result = ValidateAndDeserializeResponse(response, ComputeJsonContext.Default.GalleryCreateCommandResult); + + Assert.Equal(location, result.Gallery.Location); + Assert.Equal(description, result.Gallery.Description); + + await Service.Received(1).CreateGalleryAsync( + GalleryName, + ResourceGroup, + Subscription, + location, + description, + Arg.Any?>(), + "test-tenant", + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WithoutLocation_PassesNullLocationToService() + { + // Arrange + Service.CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .Returns(CreateMockGallery()); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", GalleryName); + + // Assert - service resolves the default location from the resource group + ValidateAndDeserializeResponse(response, ComputeJsonContext.Default.GalleryCreateCommandResult); + + await Service.Received(1).CreateGalleryAsync( + GalleryName, + ResourceGroup, + Subscription, + null, + null, + Arg.Any?>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_ParsesMultipleTags() + { + // Arrange + IReadOnlyDictionary? capturedTags = null; + + Service.CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Do?>(t => capturedTags = t), + Arg.Any(), + Arg.Any()) + .Returns(CreateMockGallery()); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", GalleryName, + "--tags", "env=prod,team=compute"); + + // Assert + ValidateAndDeserializeResponse(response, ComputeJsonContext.Default.GalleryCreateCommandResult); + + Assert.NotNull(capturedTags); + Assert.Equal(2, capturedTags.Count); + Assert.Equal("prod", capturedTags["env"]); + Assert.Equal("compute", capturedTags["team"]); + } + + [Fact] + public async Task ExecuteAsync_TrimsWhitespaceInTags() + { + // Arrange + IReadOnlyDictionary? capturedTags = null; + + Service.CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Do?>(t => capturedTags = t), + Arg.Any(), + Arg.Any()) + .Returns(CreateMockGallery()); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", GalleryName, + "--tags", " env = prod , team = compute "); + + // Assert + ValidateAndDeserializeResponse(response, ComputeJsonContext.Default.GalleryCreateCommandResult); + + Assert.NotNull(capturedTags); + Assert.Equal("prod", capturedTags["env"]); + Assert.Equal("compute", capturedTags["team"]); + } + + [Fact] + public async Task ExecuteAsync_WithoutTags_PassesNullTagsToService() + { + // Arrange + Service.CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .Returns(CreateMockGallery()); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", GalleryName); + + // Assert + ValidateAndDeserializeResponse(response, ComputeJsonContext.Default.GalleryCreateCommandResult); + + await Service.Received(1).CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + null, + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_MissingGallery_ReturnsBadRequest() + { + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_MissingResourceGroup_ReturnsBadRequest() + { + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--gallery", GalleryName); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_MissingSubscription_ReturnsBadRequest() + { + var response = await ExecuteCommandAsync( + "--resource-group", ResourceGroup, + "--gallery", GalleryName); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + } + + [Theory] + [InlineData("-leading-hyphen")] + [InlineData(".leadingperiod")] + [InlineData("_leadingunderscore")] + [InlineData("my-gallery")] + [InlineData("trailinghyphen-")] + [InlineData("trailingperiod.")] + [InlineData("trailingunderscore_")] + [InlineData("has space")] + [InlineData("has/slash")] + [InlineData("has$dollar")] + public async Task ExecuteAsync_InvalidGalleryName_ReturnsBadRequest(string galleryName) + { + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", galleryName); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + + await Service.DidNotReceive().CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_GalleryNameExceeds80Characters_ReturnsBadRequest() + { + // Arrange + var longName = new string('a', 81); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", longName); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_GalleryNameAtMaxLength_ReturnsSuccess() + { + // Arrange + var maxLengthName = new string('a', 80); + + Service.CreateGalleryAsync( + maxLengthName, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .Returns(CreateMockGallery(name: maxLengthName)); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", maxLengthName); + + // Assert + var result = ValidateAndDeserializeResponse(response, ComputeJsonContext.Default.GalleryCreateCommandResult); + Assert.Equal(maxLengthName, result.Gallery.Name); + } + + [Theory] + [InlineData("envprod")] + [InlineData("env=prod,teamcompute")] + [InlineData("=prod")] + [InlineData("env=")] + [InlineData(",")] + [InlineData("env=prod,")] + [InlineData(",env=prod")] + [InlineData("env=prod,,team=compute")] + [InlineData("env=prod, ,team=compute")] + public async Task ExecuteAsync_MalformedTags_ReturnsBadRequestAndSkipsService(string tags) + { + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", GalleryName, + "--tags", tags); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + + await Service.DidNotReceive().CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_ServiceThrowsGenericException_ReturnsError() + { + // Arrange + Service.CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Service unavailable")); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", GalleryName); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.Status); + } + + [Fact] + public async Task ExecuteAsync_ResourceGroupNotFound_ReturnsNotFound() + { + // Arrange + Service.CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException(404, "Resource group not found")); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", "missing-rg", + "--gallery", GalleryName); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.Status); + Assert.Contains("Resource group not found", response.Message); + } + + [Fact] + public async Task ExecuteAsync_ConflictingOperation_ReturnsConflict() + { + // Arrange + Service.CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException(409, "Conflict")); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", GalleryName); + + // Assert + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Contains("Conflict creating the gallery", response.Message); + } + + [Fact] + public async Task ExecuteAsync_LocationMismatch_ExplainsThatLocationIsImmutable() + { + // Arrange - ARM rejects a PUT that would move an existing gallery to another region. + Service.CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException( + 409, + "The resource already exists in location 'eastus2'.", + "InvalidResourceLocation", + null)); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", GalleryName, + "--location", "westus"); + + // Assert + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Contains("location cannot be changed", response.Message); + Assert.DoesNotContain("Another operation may be in progress", response.Message); + } + + [Fact] + public async Task ExecuteAsync_AuthorizationFailure_ReturnsForbidden() + { + // Arrange + Service.CreateGalleryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException(403, "Forbidden")); + + // Act + var response = await ExecuteCommandAsync( + "--subscription", Subscription, + "--resource-group", ResourceGroup, + "--gallery", GalleryName); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.Status); + Assert.Contains("Authorization failed", response.Message); + } + + [Theory] + [InlineData("a", true)] + [InlineData("1", true)] + [InlineData("myGallery", true)] + [InlineData("my.gallery_name1", true)] + [InlineData("", false)] + [InlineData("-abc", false)] + [InlineData("abc-", false)] + [InlineData("abc.", false)] + [InlineData("abc_", false)] + // Galleries reject hyphens even though sibling Compute resources allow them. + [InlineData("my-gallery", false)] + public void IsValidGalleryName_ValidatesCorrectly(string galleryName, bool expected) + { + Assert.Equal(expected, GalleryCreateCommand.IsValidGalleryName(galleryName)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseTags_EmptyInput_ReturnsNull(string? tags) + { + Assert.Null(GalleryCreateCommand.ParseTags(tags)); + } + + [Fact] + public void ParseTags_DuplicateKeys_UsesLastValue() + { + var parsed = GalleryCreateCommand.ParseTags("env=dev,env=prod"); + + Assert.NotNull(parsed); + Assert.Single(parsed); + Assert.Equal("prod", parsed["env"]); + } + + [Fact] + public void ParseTags_ValueContainingEquals_KeepsFullValue() + { + var parsed = GalleryCreateCommand.ParseTags("connection=key=value"); + + Assert.NotNull(parsed); + Assert.Equal("key=value", parsed["connection"]); + } +} diff --git a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.Tests/assets.json b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.Tests/assets.json index 77b3e0df3e..9322bb0a7b 100644 --- a/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.Compute/tests/Azure.Mcp.Tools.Compute.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.Compute.Tests", - "Tag": "Azure.Mcp.Tools.Compute.Tests_578d1adc4f" + "Tag": "Azure.Mcp.Tools.Compute.Tests_e95dcf95b5" }